authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 14:18:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 14:18:17-07:00
log2c1160783ea4b4dcc1c1f1ab04639ffc0aaa289d
tree5ac0dbc9259df0bfabebc8f285ddab66e3360ad9
parent9eea28bacd27b2ee6d5183e195c397bba8b515a0
signature Signed by PGP key B5690EEEBB952194

refactor(settings): drop the legacy per-driver providers map (#17300)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

32 files changed, 602 insertions(+), 1073 deletions(-)

apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+7-2
...@@ -7,6 +7,8 @@ import {...@@ -7,6 +7,8 @@ import {
7 CommandId,7 CommandId,
8 MessageId,8 MessageId,
9 ProjectId,9 ProjectId,
10 ProviderDriverKind,
11 ProviderInstanceId,
10 ThreadId,12 ThreadId,
11 type OrchestrationV2ThreadProjection,13 type OrchestrationV2ThreadProjection,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(...@@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(
60const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry));62const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry));
6163
62const layerServerSettings = ServerSettings.layerTest({64const layerServerSettings = ServerSettings.layerTest({
63 providers: {65 providerInstances: {
64 cursor: { enabled: true },66 [ProviderInstanceId.make("cursor")]: {
67 driver: ProviderDriverKind.make("cursor"),
68 enabled: true,
69 },
65 },70 },
66});71});
67const layerBackgroundPolicy = BackgroundPolicy.layer.pipe(72const layerBackgroundPolicy = BackgroundPolicy.layer.pipe(
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+7-2
...@@ -8,6 +8,8 @@ import {...@@ -8,6 +8,8 @@ import {
8 MessageId,8 MessageId,
9 type OrchestrationV2ThreadProjection,9 type OrchestrationV2ThreadProjection,
10 ProjectId,10 ProjectId,
11 ProviderDriverKind,
12 ProviderInstanceId,
11 ThreadId,13 ThreadId,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
13import * as Console from "effect/Console";15import * as Console from "effect/Console";
...@@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(...@@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(
59const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry));61const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry));
6062
61const layerServerSettings = ServerSettings.layerTest({63const layerServerSettings = ServerSettings.layerTest({
62 providers: {64 providerInstances: {
63 grok: { enabled: true },65 [ProviderInstanceId.make("grok")]: {
66 driver: ProviderDriverKind.make("grok"),
67 enabled: true,
68 },
64 },69 },
65});70});
66const layerBackgroundPolicy = BackgroundPolicy.layer.pipe(71const layerBackgroundPolicy = BackgroundPolicy.layer.pipe(
apps/server/src/project/AgentSessionScanner.test.ts+13-7
...@@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) =>...@@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) =>
59 Layer.provide(59 Layer.provide(
60 Layer.mergeAll(60 Layer.mergeAll(
61 ServerSettings.layerTest({61 ServerSettings.layerTest({
62 providers: {62 // Default slots point at the fixture homes; explicit test instances
63 claudeAgent: { homePath: input.claudeHomePath },63 // replace a slot with the same id.
64 codex: { homePath: input.codexHomePath },64 providerInstances: {
65 [ProviderInstanceId.make("claudeAgent")]: {
66 driver: ProviderDriverKind.make("claudeAgent"),
67 config: { homePath: input.claudeHomePath },
68 },
69 [ProviderInstanceId.make("codex")]: {
70 driver: ProviderDriverKind.make("codex"),
71 config: { homePath: input.codexHomePath },
72 },
73 ...input.providerInstances,
65 },74 },
66 ...(input.providerInstances === undefined
67 ? {}
68 : { providerInstances: input.providerInstances }),
69 }),75 }),
70 ServerConfig.layerTest(76 ServerConfig.layerTest(
71 input.claudeHomePath,77 input.claudeHomePath,
...@@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {...@@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
533 }),539 }),
534 );540 );
535541
536 it.effect("uses explicit provider instance homes instead of overridden legacy homes", () =>542 it.effect("uses explicit default-slot homes instead of the fixture homes", () =>
537 Effect.gen(function* () {543 Effect.gen(function* () {
538 const path = yield* Path.Path;544 const path = yield* Path.Path;
539 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");545 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
apps/server/src/project/AgentSessionScanner.ts+6-7
...@@ -1102,16 +1102,15 @@ export const make = Effect.gen(function* () {...@@ -1102,16 +1102,15 @@ export const make = Effect.gen(function* () {
1102 instanceId: ProviderInstanceId.make(instanceId),1102 instanceId: ProviderInstanceId.make(instanceId),
1103 config,1103 config,
1104 }));1104 }));
1105 // The built-in default instance runs with default config when settings
1106 // have no entry for it.
1105 if (!Object.hasOwn(settings.providerInstances, source)) {1107 if (!Object.hasOwn(settings.providerInstances, source)) {
1106 const legacyInstance = {1108 const defaultInstance = {
1107 instanceId: ProviderInstanceId.make(source),1109 instanceId: ProviderInstanceId.make(source),
1108 config: {1110 config: { driver: ProviderDriverKind.make(source) },
1109 driver: ProviderDriverKind.make(source),
1110 config: settings.providers[source],
1111 },
1112 };1111 };
1113 if (resolveProviderInstanceEnabled(legacyInstance.config)) {1112 if (resolveProviderInstanceEnabled(defaultInstance.config)) {
1114 instances.push(legacyInstance);1113 instances.push(defaultInstance);
1115 }1114 }
1116 }1115 }
11171116
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+1
...@@ -467,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD...@@ -467,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD
467 metadata: {467 metadata: {
468 displayName: "ACP Registry",468 displayName: "ACP Registry",
469 supportsMultipleInstances: true,469 supportsMultipleInstances: true,
470 hasDefaultInstance: false,
470 },471 },
471 configSchema: AcpRegistrySettings,472 configSchema: AcpRegistrySettings,
472 defaultConfig: () => decodeSettings({}),473 defaultConfig: () => decodeSettings({}),
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+10-54
...@@ -2,28 +2,10 @@...@@ -2,28 +2,10 @@
2 * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap`2 * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap`
3 * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it.3 * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it.
4 *4 *
5 * The server still reads two shapes:5 * `settings.providerInstances` is the source of truth. Every built-in driver
6 *6 * with a default instance also runs at `defaultInstanceIdForDriver(kind)`
7 * 1. `settings.providerInstances` — the new driver-agnostic map the7 * when that slot has no entry, using the driver's default config, so a fresh
8 * registry expects. Keyed by `ProviderInstanceId`, values are8 * install shows its built-in providers without writing settings first.
9 * `ProviderInstanceConfig` envelopes.
10 * 2. `settings.providers.<kind>` — the legacy single-instance-per-driver
11 * fields (`providers.codex`, `providers.claudeAgent`, …). These are
12 * the source of truth for every deployment that hasn't been migrated
13 * yet to an explicit `providerInstances` entry.
14 *
15 * This module bridges (2) into (1) and wires the resulting map into a
16 * mutable registry. For every built-in driver whose id is not already
17 * present in `providerInstances` (keyed on
18 * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a
19 * routing slug), we synthesize an envelope from the legacy field. The
20 * registry decodes both flavours through the same `configSchema` and ends
21 * up with one uniform `ProviderInstance` per entry.
22 *
23 * Explicit `providerInstances` entries always win — users can already
24 * override the legacy `providers.<kind>` blob by authoring a
25 * `providerInstances.codex` entry with a matching driver, and we don't
26 * want the synthesized envelope to silently stomp their config.
27 *9 *
28 * Hot-reload10 * Hot-reload
29 * ----------11 * ----------
...@@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv =...@@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv =
76 | ServerConfig.ServerConfig;58 | ServerConfig.ServerConfig;
7759
78/**60/**
79 * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot.61 * Explicit `providerInstances` entries plus an implicit default instance for
80 *62 * each built-in driver whose default slot is empty. Pure so the hydration
81 * Strategy:63 * rule can be tested without layers.
82 * 1. Copy all explicit `settings.providerInstances` entries verbatim.
83 * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key
84 * is *not* already in the explicit map, synthesize an entry from the
85 * matching legacy `settings.providers.<kind>` blob.
86 *
87 * The returned map is the input the registry consumes; pure & exported
88 * separately so the hydration logic can be exercised by unit tests
89 * without layering.
90 */64 */
91export const deriveProviderInstanceConfigMap = (65export const deriveProviderInstanceConfigMap = (
92 settings: ServerSettings,66 settings: ServerSettings,
...@@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = (...@@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = (
94 const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances };68 const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances };
9569
96 for (const driver of BUILT_IN_DRIVERS) {70 for (const driver of BUILT_IN_DRIVERS) {
71 if (driver.metadata.hasDefaultInstance === false) continue;
97 const instanceId = defaultInstanceIdForDriver(driver.driverKind);72 const instanceId = defaultInstanceIdForDriver(driver.driverKind);
98 if (instanceId in merged) {73 if (instanceId in merged) continue;
99 // Explicit `providerInstances` entry for this slot — user-authored74 merged[instanceId] = { driver: driver.driverKind };
100 // config always wins over the legacy mirror.
101 continue;
102 }
103
104 // Only built-in drivers have a legacy mirror; the registry's
105 // `providers` struct is keyed on the same literal slug as
106 // `driverKind`. Access is dynamic (the driver kind is a branded string),
107 // but it's constrained to `keyof settings.providers` by the union of
108 // built-in driver kinds.
109 const legacyKey = driver.driverKind as keyof ServerSettings["providers"];
110 const legacyConfig = settings.providers[legacyKey];
111 if (legacyConfig === undefined) {
112 continue;
113 }
114
115 merged[instanceId] = {
116 driver: driver.driverKind,
117 config: legacyConfig,
118 };
119 }75 }
12076
121 return merged as ProviderInstanceConfigMap;77 return merged as ProviderInstanceConfigMap;
apps/server/src/provider/ProviderRegistry.test.ts+33-35
...@@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings);...@@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings);
68const encodeServerSettings = Schema.encodeSync(ServerSettings);68const encodeServerSettings = Schema.encodeSync(ServerSettings);
69const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);69const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
7070
71/** Default slots that settings mark disabled, so their probes never spawn. */
72const disabledDefaultSlots = (
73 ...drivers: ReadonlyArray<string>
74): ContractServerSettings["providerInstances"] =>
75 Object.fromEntries(
76 drivers.map((driver) => [
77 ProviderInstanceId.make(driver),
78 { driver: ProviderDriverKind.make(driver), enabled: false },
79 ]),
80 );
81
71const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});82const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
72const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});83const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
73const decodeCodexSettings = Schema.decodeSync(CodexSettings);84const decodeCodexSettings = Schema.decodeSync(CodexSettings);
...@@ -2663,24 +2674,18 @@ it.layer(...@@ -2663,24 +2674,18 @@ it.layer(
2663 const serverSettings = yield* makeMutableServerSettingsService(2674 const serverSettings = yield* makeMutableServerSettingsService(
2664 decodeServerSettings(2675 decodeServerSettings(
2665 deepMerge(encodedDefaultServerSettings, {2676 deepMerge(encodedDefaultServerSettings, {
2666 providers: {
2667 // Disable every built-in probe that would otherwise spawn
2668 // on the CI host. `enabled: false` short-circuits each
2669 // driver's probe *before* it touches the spawner, so the
2670 // test environment stays isolated from the dev
2671 // machine's PATH.
2672 codex: { enabled: false },
2673 claudeAgent: { enabled: false },
2674 cursor: { enabled: false },
2675 grok: { enabled: false },
2676 opencode: { enabled: false },
2677 },
2678 // `providerInstances` keys are branded `ProviderInstanceId`;2677 // `providerInstances` keys are branded `ProviderInstanceId`;
2679 // the branded index signature rejects plain string literals2678 // the branded index signature rejects plain string literals
2680 // at the TS level even though the runtime schema happily2679 // at the TS level even though the runtime schema happily
2681 // accepts + decodes them. Cast the patch to `unknown` so2680 // accepts + decodes them. Cast the patch to `unknown` so
2682 // the `Schema.decodeSync` below does the real validation.2681 // the `Schema.decodeSync` below does the real validation.
2683 providerInstances: {2682 providerInstances: {
2683 // Disable every built-in probe that would otherwise spawn
2684 // on the CI host. `enabled: false` short-circuits each
2685 // driver's probe *before* it touches the spawner, so the
2686 // test environment stays isolated from the dev
2687 // machine's PATH.
2688 ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"),
2684 // Matches the shape the user had in `.t3/dev/settings.json`2689 // Matches the shape the user had in `.t3/dev/settings.json`
2685 // when the bug was reported: a custom enabled Codex instance2690 // when the bug was reported: a custom enabled Codex instance
2686 // pointing at a binary the server has to actually spawn.2691 // pointing at a binary the server has to actually spawn.
...@@ -2781,12 +2786,13 @@ it.layer(...@@ -2781,12 +2786,13 @@ it.layer(
2781 const mutableServerSettings = yield* makeMutableServerSettingsService(2786 const mutableServerSettings = yield* makeMutableServerSettingsService(
2782 decodeServerSettings(2787 decodeServerSettings(
2783 deepMerge(encodedDefaultServerSettings, {2788 deepMerge(encodedDefaultServerSettings, {
2784 providers: {2789 providerInstances: {
2785 codex: { enabled: true, binaryPath: firstMissing },2790 ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"),
2786 claudeAgent: { enabled: false },2791 [ProviderInstanceId.make("codex")]: {
2787 cursor: { enabled: false },2792 driver: ProviderDriverKind.make("codex"),
2788 grok: { enabled: false },2793 enabled: true,
2789 opencode: { enabled: false },2794 config: { binaryPath: firstMissing },
2795 },
2790 },2796 },
2791 }),2797 }),
2792 ),2798 ),
...@@ -2871,8 +2877,13 @@ it.layer(...@@ -2871,8 +2877,13 @@ it.layer(
2871 ),2877 ),
2872 );2878 );
2873 yield* serverSettings.updateSettings({2879 yield* serverSettings.updateSettings({
2874 providers: {2880 providerInstances: {
2875 codex: { enabled: true, binaryPath: secondMissing },2881 ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"),
2882 [ProviderInstanceId.make("codex")]: {
2883 driver: ProviderDriverKind.make("codex"),
2884 enabled: true,
2885 config: { binaryPath: secondMissing },
2886 },
2876 },2887 },
2877 });2888 });
2878 // Start the lazy stream only after publishing. A watcher that did2889 // Start the lazy stream only after publishing. A watcher that did
...@@ -2900,14 +2911,8 @@ it.layer(...@@ -2900,14 +2911,8 @@ it.layer(
2900 const serverSettings = yield* makeMutableServerSettingsService(2911 const serverSettings = yield* makeMutableServerSettingsService(
2901 decodeServerSettings(2912 decodeServerSettings(
2902 deepMerge(encodedDefaultServerSettings, {2913 deepMerge(encodedDefaultServerSettings, {
2903 providers: {
2904 codex: { enabled: false },
2905 claudeAgent: { enabled: false },
2906 cursor: { enabled: false },
2907 grok: { enabled: false },
2908 opencode: { enabled: false },
2909 },
2910 providerInstances: {2914 providerInstances: {
2915 ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"),
2911 ghost_main: {2916 ghost_main: {
2912 driver: "ghostDriver",2917 driver: "ghostDriver",
2913 displayName: "A fork-only driver we don't ship",2918 displayName: "A fork-only driver we don't ship",
...@@ -2971,14 +2976,7 @@ it.layer(...@@ -2971,14 +2976,7 @@ it.layer(
2971 const serverSettings = yield* makeMutableServerSettingsService(2976 const serverSettings = yield* makeMutableServerSettingsService(
2972 decodeServerSettings(2977 decodeServerSettings(
2973 deepMerge(encodedDefaultServerSettings, {2978 deepMerge(encodedDefaultServerSettings, {
2974 providers: {2979 providerInstances: disabledDefaultSlots("codex", "grok"),
2975 codex: {
2976 enabled: false,
2977 },
2978 grok: {
2979 enabled: false,
2980 },
2981 },
2982 }),2980 }),
2983 ),2981 ),
2984 );2982 );
apps/server/src/provider/providerInstallation.test.ts+24-3
...@@ -147,7 +147,14 @@ describe("provider installation routing", () => {...@@ -147,7 +147,14 @@ describe("provider installation routing", () => {
147 it.effect("keeps external installs manual without hiding shared install status", () =>147 it.effect("keeps external installs manual without hiding shared install status", () =>
148 Effect.gen(function* () {148 Effect.gen(function* () {
149 const harness = yield* makeHarness({149 const harness = yield* makeHarness({
150 settings: { providers: { antigravity: { binaryPath: "/external/agy" } } },150 settings: {
151 providerInstances: {
152 [ProviderInstanceId.make("antigravity")]: {
153 driver: ProviderDriverKind.make("antigravity"),
154 config: { binaryPath: "/external/agy" },
155 },
156 },
157 },
151 });158 });
152 const start = yield* Effect.flip(harness.router.start({ instanceId }));159 const start = yield* Effect.flip(harness.router.start({ instanceId }));
153 const remove = yield* Effect.flip(harness.router.remove({ instanceId }));160 const remove = yield* Effect.flip(harness.router.remove({ instanceId }));
...@@ -164,7 +171,14 @@ describe("provider installation routing", () => {...@@ -164,7 +171,14 @@ describe("provider installation routing", () => {
164 const codexId = ProviderInstanceId.make("codex");171 const codexId = ProviderInstanceId.make("codex");
165 const harness = yield* makeHarness({172 const harness = yield* makeHarness({
166 instance: instance(ProviderDriverKind.make("codex"), codexId),173 instance: instance(ProviderDriverKind.make("codex"), codexId),
167 settings: { providers: { codex: { setupMode: "managed" } } },174 settings: {
175 providerInstances: {
176 [ProviderInstanceId.make("codex")]: {
177 driver: ProviderDriverKind.make("codex"),
178 config: { setupMode: "managed" },
179 },
180 },
181 },
168 });182 });
169 assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex");183 assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex");
170 yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" });184 yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" });
...@@ -180,7 +194,14 @@ describe("provider installation routing", () => {...@@ -180,7 +194,14 @@ describe("provider installation routing", () => {
180 const codexId = ProviderInstanceId.make("codex");194 const codexId = ProviderInstanceId.make("codex");
181 const harness = yield* makeHarness({195 const harness = yield* makeHarness({
182 instance: instance(ProviderDriverKind.make("codex"), codexId),196 instance: instance(ProviderDriverKind.make("codex"), codexId),
183 settings: { providers: { codex: { setupMode: "existing" } } },197 settings: {
198 providerInstances: {
199 [ProviderInstanceId.make("codex")]: {
200 driver: ProviderDriverKind.make("codex"),
201 config: { setupMode: "existing" },
202 },
203 },
204 },
184 });205 });
185 assert.include(206 assert.include(
186 (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail,207 (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail,
apps/server/src/server.ts+1-2
...@@ -583,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -583,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
583 // The instance registry is the new routing keystone — text generation,583 // The instance registry is the new routing keystone — text generation,
584 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`584 // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId`
585 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;585 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;
586 // `providerInstances` hydration merges `settings.providers.<kind>`586 // hydration adds their default instances to `providerInstances` on boot.
587 // with explicit `providerInstances` entries on boot.
588 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),587 Layer.provideMerge(ProviderInstanceRegistryHydration.layer),
589 Layer.provideMerge(588 Layer.provideMerge(
590 Layer.mergeAll(589 Layer.mergeAll(
apps/server/src/serverSettings.test.ts+107-306
...@@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {
327327
328 it.effect("decodes nested settings patches", () =>328 it.effect("decodes nested settings patches", () =>
329 Effect.gen(function* () {329 Effect.gen(function* () {
330 assert.deepEqual(
331 yield* decodeSettingsPatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }),
332 {
333 providers: { codex: { binaryPath: "/tmp/codex" } },
334 },
335 );
336
337 assert.deepEqual(330 assert.deepEqual(
338 yield* decodeSettingsPatch({331 yield* decodeSettingsPatch({
339 textGenerationModelSelection: {332 textGenerationModelSelection: {
...@@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
374 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;367 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
375368
376 yield* serverSettings.updateSettings({369 yield* serverSettings.updateSettings({
377 providers: {370 observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" },
378 codex: {
379 binaryPath: "/usr/local/bin/codex",
380 homePath: "/Users/julius/.codex",
381 },
382 claudeAgent: {
383 binaryPath: "/usr/local/bin/claude",
384 customModels: ["claude-custom"],
385 },
386 },
387 textGenerationModelSelection: {371 textGenerationModelSelection: {
388 instanceId: ProviderInstanceId.make("codex"),372 instanceId: ProviderInstanceId.make("codex"),
389 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,373 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
...@@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
399 });383 });
400384
401 const next = yield* serverSettings.updateSettings({385 const next = yield* serverSettings.updateSettings({
402 providers: {386 observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" },
403 codex: {
404 binaryPath: "/opt/homebrew/bin/codex",
405 },
406 },
407 textGenerationModelSelection: {387 textGenerationModelSelection: {
408 options: [{ id: "fastMode", value: false }],388 options: [{ id: "fastMode", value: false }],
409 },389 },
410 });390 });
411391
412 assert.deepEqual(next.providers.codex, {392 assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces");
413 enabled: true,393 assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics");
414 binaryPath: "/opt/homebrew/bin/codex",
415 homePath: "/Users/julius/.codex",
416 shadowHomePath: "",
417 launchArgs: "",
418 customModels: [],
419 });
420 assert.deepEqual(next.providers.claudeAgent, {
421 enabled: true,
422 binaryPath: "/usr/local/bin/claude",
423 homePath: "",
424 customModels: ["claude-custom"],
425 launchArgs: "",
426 autoCompactWindow: "",
427 });
428 assert.deepEqual(394 assert.deepEqual(
429 next.textGenerationModelSelection,395 next.textGenerationModelSelection,
430 createModelSelection(396 createModelSelection(
...@@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
521 const changes = yield* serverSettings.subscribeChanges;487 const changes = yield* serverSettings.subscribeChanges;
522488
523 yield* serverSettings.updateSettings({489 yield* serverSettings.updateSettings({
524 providers: {490 addProjectBaseDirectory: "~/next",
525 codex: {
526 binaryPath: "/usr/local/bin/codex-next",
527 },
528 },
529 });491 });
530492
531 const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second"));493 const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second"));
532 assert.equal(494 assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next");
533 Option.getOrUndefined(firstChange)?.providers.codex.binaryPath,
534 "/usr/local/bin/codex-next",
535 );
536 }),495 }),
537 ).pipe(Effect.provide(layerServerSettings())),496 ).pipe(Effect.provide(layerServerSettings())),
538 );497 );
...@@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => {
654 );613 );
655614
656 it.effect(615 it.effect(
657 "uses explicit provider instance enabled state over legacy provider enabled state",616 "keeps a custom instance's selection when the driver's default instance is disabled",
658 () =>617 () =>
659 Effect.gen(function* () {618 Effect.gen(function* () {
660 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;619 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
661 const instanceId = ProviderInstanceId.make("claude_openrouter");620 const instanceId = ProviderInstanceId.make("claude_openrouter");
662621
663 const next = yield* serverSettings.updateSettings({622 const next = yield* serverSettings.updateSettings({
664 providers: {623 providerInstances: {
665 claudeAgent: {624 [ProviderInstanceId.make("claudeAgent")]: {
625 driver: ProviderDriverKind.make("claudeAgent"),
666 enabled: false,626 enabled: false,
667 },627 },
668 },
669 providerInstances: {
670 [instanceId]: {628 [instanceId]: {
671 driver: ProviderDriverKind.make("claudeAgent"),629 driver: ProviderDriverKind.make("claudeAgent"),
672 enabled: true,630 enabled: true,
...@@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
700 const fallbackInstanceId = ProviderInstanceId.make(fallbackId);658 const fallbackInstanceId = ProviderInstanceId.make(fallbackId);
701 const selection = { instanceId: writerId, model: "claude-sonnet-4-6" };659 const selection = { instanceId: writerId, model: "claude-sonnet-4-6" };
702 const providerInstances = {660 const providerInstances = {
661 [ProviderInstanceId.make("claudeAgent")]: {
662 driver: ProviderDriverKind.make("claudeAgent"),
663 enabled: false,
664 },
703 [fallbackInstanceId]: {665 [fallbackInstanceId]: {
704 driver: ProviderDriverKind.make("codex"),666 driver: ProviderDriverKind.make("codex"),
705 enabled: true,667 enabled: true,
...@@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {
713 };675 };
714676
715 yield* serverSettings.updateSettings({677 yield* serverSettings.updateSettings({
716 providers: Object.fromEntries(
717 Object.keys(DEFAULT_SERVER_SETTINGS.providers).map(
718 (provider) => [provider, { enabled: false }] as const,
719 ),
720 ),
721 providerInstances,678 providerInstances,
722 textGenerationModelSelection: selection,679 textGenerationModelSelection: selection,
723 });680 });
...@@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
754 ).pipe(Effect.provide(layerServerSettings())),711 ).pipe(Effect.provide(layerServerSettings())),
755 );712 );
756713
757 it.effect("skips explicitly disabled instances when choosing a legacy fallback", () =>714 it.effect("skips explicitly disabled default instances when choosing a fallback", () =>
758 Effect.gen(function* () {715 Effect.gen(function* () {
759 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;716 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
760 const next = yield* serverSettings.updateSettings({717 const next = yield* serverSettings.updateSettings({
...@@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => {
933 }).pipe(Effect.provide(layerServerSettings())),890 }).pipe(Effect.provide(layerServerSettings())),
934 );891 );
935892
936 it.effect("enables previously used providers from sparse settings files", () =>893 it.effect("moves customized legacy provider settings into default instances on load", () =>
937 Effect.gen(function* () {894 Effect.gen(function* () {
938 const serverConfig = yield* ServerConfig.ServerConfig;895 const serverConfig = yield* ServerConfig.ServerConfig;
939 const fileSystem = yield* FileSystem.FileSystem;896 const fileSystem = yield* FileSystem.FileSystem;
940 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;897 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
941 yield* fileSystem.writeFileString(898 yield* fileSystem.writeFileString(
942 serverConfig.settingsPath,899 serverConfig.settingsPath,
943 '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}',900 JSON.stringify({
901 addProjectBaseDirectory: "~/Development",
902 providers: {
903 codex: { binaryPath: "/opt/guard/bin/codex" },
904 claudeAgent: { enabled: false },
905 cursor: { enabled: false },
906 grok: { enabled: false },
907 opencode: { enabled: false },
908 },
909 }),
944 );910 );
945 yield* recordProviderUsage("opencode");
946911
947 const settings = yield* serverSettings.getSettings;912 const settings = yield* serverSettings.getSettings;
948913
949 assert.isFalse(settings.providers.grok.enabled);914 assert.deepEqual(settings.providerInstances, {
950 assert.isTrue(settings.providers.opencode.enabled);915 [ProviderInstanceId.make("codex")]: {
951 assert.isFalse(settings.providers.cursor.enabled);916 driver: ProviderDriverKind.make("codex"),
952 assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096");917 config: { binaryPath: "/opt/guard/bin/codex" },
918 },
919 [ProviderInstanceId.make("claudeAgent")]: {
920 driver: ProviderDriverKind.make("claudeAgent"),
921 enabled: false,
922 config: {},
923 },
924 });
925 // The file is rewritten once: instances persist and the retired map is gone.
926 const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath));
927 assert.isUndefined(persisted.providers);
928 assert.equal(persisted.addProjectBaseDirectory, "~/Development");
929 assert.deepEqual(persisted.providerInstances.codex, {
930 driver: "codex",
931 config: { binaryPath: "/opt/guard/bin/codex" },
932 });
953 }).pipe(Effect.provide(layerServerSettings())),933 }).pipe(Effect.provide(layerServerSettings())),
954 );934 );
955935
956 it.effect("preserves existing provider instances without explicit enabled flags", () =>936 it.effect("keeps an explicit default instance over the legacy blob for its driver", () =>
957 Effect.gen(function* () {937 Effect.gen(function* () {
958 const serverConfig = yield* ServerConfig.ServerConfig;938 const serverConfig = yield* ServerConfig.ServerConfig;
959 const fileSystem = yield* FileSystem.FileSystem;939 const fileSystem = yield* FileSystem.FileSystem;
960 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;940 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
961 yield* fileSystem.writeFileString(941 yield* fileSystem.writeFileString(
962 serverConfig.settingsPath,942 serverConfig.settingsPath,
963 '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}',943 JSON.stringify({
944 providers: { codex: { binaryPath: "/legacy/codex" } },
945 providerInstances: {
946 codex: { driver: "codex", enabled: true, config: { binaryPath: "/explicit/codex" } },
947 },
948 }),
964 );949 );
965 yield* recordProviderUsage("cursor", "cursor_work");
966 yield* recordProviderUsage("grok", null);
967 yield* recordProviderUsage("opencode", "opencode_work");
968950
969 const settings = yield* serverSettings.getSettings;951 const settings = yield* serverSettings.getSettings;
970952
971 assert.isTrue(settings.providers.cursor.enabled);953 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
972 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);954 driver: ProviderDriverKind.make("codex"),
973 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);955 enabled: true,
974 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled);956 config: { binaryPath: "/explicit/codex" },
975 const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")];957 });
976 assert.isDefined(unused);
977 assert.isFalse(resolveProviderInstanceEnabled(unused));
978 }).pipe(Effect.provide(layerServerSettings())),958 }).pipe(Effect.provide(layerServerSettings())),
979 );959 );
980960
981 it.effect("preserves explicit provider disables in existing settings files", () =>961 it.effect("enables previously used optional providers while migrating legacy settings", () =>
982 Effect.gen(function* () {962 Effect.gen(function* () {
983 const serverConfig = yield* ServerConfig.ServerConfig;963 const serverConfig = yield* ServerConfig.ServerConfig;
984 const fileSystem = yield* FileSystem.FileSystem;964 const fileSystem = yield* FileSystem.FileSystem;
985 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;965 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
986 yield* fileSystem.writeFileString(966 yield* fileSystem.writeFileString(
987 serverConfig.settingsPath,967 serverConfig.settingsPath,
988 '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}',968 JSON.stringify({
969 providers: { opencode: { serverUrl: "http://127.0.0.1:4096" }, cursor: {} },
970 providerInstances: {
971 cursor_work: { driver: "cursor", config: {} },
972 opencode_unused: { driver: "opencode", config: {} },
973 },
974 }),
989 );975 );
990 yield* recordProviderUsage("grok");
991 yield* recordProviderUsage("opencode");976 yield* recordProviderUsage("opencode");
992 yield* recordProviderUsage("cursor");977 yield* recordProviderUsage("grok", null);
978 yield* recordProviderUsage("cursor", "cursor_work");
993979
994 const settings = yield* serverSettings.getSettings;980 const settings = yield* serverSettings.getSettings;
995981
996 assert.isFalse(settings.providers.grok.enabled);982 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], {
997 assert.isFalse(settings.providers.opencode.enabled);983 driver: ProviderDriverKind.make("opencode"),
998 assert.isFalse(settings.providers.cursor.enabled);984 enabled: true,
999 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);985 config: { serverUrl: "http://127.0.0.1:4096" },
1000 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled);986 });
1001 assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled);987 // Used without any legacy blob or instance: the slot is created enabled.
988 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled);
989 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);
990 // Using any cursor instance counts as opting into the driver.
991 assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled);
992 const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")];
993 assert.isDefined(unused);
994 assert.isFalse(resolveProviderInstanceEnabled(unused));
1002 }).pipe(Effect.provide(layerServerSettings())),995 }).pipe(Effect.provide(layerServerSettings())),
1003 );996 );
1004997
1005 it.effect("skips a disabled provider instance when picking the text generation fallback", () =>998 it.effect("keeps explicit legacy disables even when provider history shows use", () =>
1006 Effect.gen(function* () {999 Effect.gen(function* () {
1007 const serverConfig = yield* ServerConfig.ServerConfig;1000 const serverConfig = yield* ServerConfig.ServerConfig;
1008 const fileSystem = yield* FileSystem.FileSystem;1001 const fileSystem = yield* FileSystem.FileSystem;
1009 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1002 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1010 // The Providers UI writes providerInstances only, so the legacy providers
1011 // map decodes to defaults where codex is enabled and listed first.
1012 yield* fileSystem.writeFileString(1003 yield* fileSystem.writeFileString(
1013 serverConfig.settingsPath,1004 serverConfig.settingsPath,
1014 '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}',1005 JSON.stringify({ providers: { grok: { enabled: false, binaryPath: "/opt/grok" } } }),
1015 );1006 );
1007 yield* recordProviderUsage("grok");
10161008
1017 const settings = yield* serverSettings.getSettings;1009 const settings = yield* serverSettings.getSettings;
10181010
1019 assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");1011 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("grok")], {
1012 driver: ProviderDriverKind.make("grok"),
1013 enabled: false,
1014 config: { binaryPath: "/opt/grok" },
1015 });
1020 }).pipe(Effect.provide(layerServerSettings())),1016 }).pipe(Effect.provide(layerServerSettings())),
1021 );1017 );
10221018
1023 it.effect("keeps unused providers disabled in existing sparse settings files", () =>1019 it.effect("runs provider history only for files that still carry the legacy map", () =>
1024 Effect.gen(function* () {1020 Effect.gen(function* () {
1025 const serverConfig = yield* ServerConfig.ServerConfig;1021 const serverConfig = yield* ServerConfig.ServerConfig;
1026 const fileSystem = yield* FileSystem.FileSystem;1022 const fileSystem = yield* FileSystem.FileSystem;
1027 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1023 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1028 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");1024 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
1029
1030 const settings = yield* serverSettings.getSettings;
1031
1032 assert.isFalse(settings.providers.grok.enabled);
1033 assert.isFalse(settings.providers.opencode.enabled);
1034 assert.isFalse(settings.providers.cursor.enabled);
1035 }).pipe(Effect.provide(layerServerSettings())),
1036 );
1037
1038 it.effect("preserves provider history when no settings file exists", () =>
1039 Effect.gen(function* () {
1040 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1041 yield* recordProviderUsage("grok");1025 yield* recordProviderUsage("grok");
10421026
1043 const settings = yield* serverSettings.getSettings;1027 const settings = yield* serverSettings.getSettings;
10441028
1045 assert.isTrue(settings.providers.grok.enabled);1029 assert.deepEqual(settings.providerInstances, {});
1046 assert.isFalse(settings.providers.opencode.enabled);
1047 assert.isFalse(settings.providers.cursor.enabled);
1048 }).pipe(Effect.provide(layerServerSettings())),1030 }).pipe(Effect.provide(layerServerSettings())),
1049 );1031 );
10501032
1051 it.effect("preserves provider history when the settings file is invalid", () =>1033 it.effect("migrates legacy providers from an invalid file without rewriting it", () =>
1052 Effect.gen(function* () {1034 Effect.gen(function* () {
1053 const serverConfig = yield* ServerConfig.ServerConfig;1035 const serverConfig = yield* ServerConfig.ServerConfig;
1054 const fileSystem = yield* FileSystem.FileSystem;1036 const fileSystem = yield* FileSystem.FileSystem;
1055 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1037 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1056 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json");1038 const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}';
1057 yield* recordProviderUsage("cursor");1039 yield* fileSystem.writeFileString(serverConfig.settingsPath, raw);
10581040
1059 const settings = yield* serverSettings.getSettings;1041 const settings = yield* serverSettings.getSettings;
10601042
1061 assert.isTrue(settings.providers.cursor.enabled);1043 // The readable provider settings still apply, but the file stays for the user to repair.
1062 assert.isFalse(settings.providers.grok.enabled);1044 assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], {
1063 assert.isFalse(settings.providers.opencode.enabled);1045 driver: ProviderDriverKind.make("codex"),
1046 config: { binaryPath: "/x" },
1047 });
1048 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw);
1064 }).pipe(Effect.provide(layerServerSettings())),1049 }).pipe(Effect.provide(layerServerSettings())),
1065 );1050 );
10661051
1067 it.effect("preserves valid provider flags when another settings field is invalid", () =>1052 it.effect("skips a disabled provider instance when picking the text generation fallback", () =>
1068 Effect.gen(function* () {1053 Effect.gen(function* () {
1069 const serverConfig = yield* ServerConfig.ServerConfig;1054 const serverConfig = yield* ServerConfig.ServerConfig;
1070 const fileSystem = yield* FileSystem.FileSystem;1055 const fileSystem = yield* FileSystem.FileSystem;
1071 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1056 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1072 yield* fileSystem.writeFileString(1057 yield* fileSystem.writeFileString(
1073 serverConfig.settingsPath,1058 serverConfig.settingsPath,
1074 '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}',1059 '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}',
1075 );1060 );
1076 yield* recordProviderUsage("cursor");
1077
1078 const settings = yield* serverSettings.getSettings;
1079
1080 assert.isFalse(settings.providers.cursor.enabled);
1081 assert.isTrue(settings.providers.grok.enabled);
1082 assert.isFalse(settings.providers.opencode.enabled);
1083 }).pipe(Effect.provide(layerServerSettings())),
1084 );
1085
1086 it.effect("restores providers from persisted runtime sessions", () =>
1087 Effect.gen(function* () {
1088 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1089 const sql = yield* SqlClient.SqlClient;
1090 yield* sql`
1091 INSERT INTO provider_session_runtime (
1092 thread_id,
1093 provider_name,
1094 provider_instance_id,
1095 adapter_key,
1096 status,
1097 last_seen_at
1098 )
1099 VALUES (
1100 ${"thread-opencode-runtime"},
1101 ${"opencode"},
1102 ${"opencode"},
1103 ${"opencode"},
1104 ${"ready"},
1105 ${"2026-08-25T00:00:00.000Z"}
1106 )
1107 `;
11081061
1109 const settings = yield* serverSettings.getSettings;1062 const settings = yield* serverSettings.getSettings;
11101063
1111 assert.isFalse(settings.providers.grok.enabled);1064 assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent");
1112 assert.isTrue(settings.providers.opencode.enabled);
1113 assert.isFalse(settings.providers.cursor.enabled);
1114 }).pipe(Effect.provide(layerServerSettings())),
1115 );
1116
1117 it.effect("persists explicit disables after a provider has been used", () =>
1118 Effect.gen(function* () {
1119 const serverConfig = yield* ServerConfig.ServerConfig;
1120 const fileSystem = yield* FileSystem.FileSystem;
1121 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1122 yield* recordProviderUsage("grok");
1123
1124 assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled);
1125
1126 const settings = yield* serverSettings.updateSettings({
1127 providers: { grok: { enabled: false } },
1128 });
1129 assert.isFalse(settings.providers.grok.enabled);
1130
1131 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1132 assert.isFalse(JSON.parse(raw).providers.grok.enabled);
1133 }).pipe(Effect.provide(layerServerSettings())),
1134 );
1135
1136 it.effect("persists explicit provider enables before their first use", () =>
1137 Effect.gen(function* () {
1138 const serverConfig = yield* ServerConfig.ServerConfig;
1139 const fileSystem = yield* FileSystem.FileSystem;
1140 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1141
1142 yield* serverSettings.updateSettings({
1143 providers: {
1144 cursor: { enabled: true },
1145 grok: { enabled: true },
1146 opencode: { enabled: true },
1147 },
1148 });
1149 yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" });
1150
1151 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1152 const persisted = JSON.parse(raw);
1153 assert.isTrue(persisted.providers.cursor.enabled);
1154 assert.isTrue(persisted.providers.grok.enabled);
1155 assert.isTrue(persisted.providers.opencode.enabled);
1156 }).pipe(Effect.provide(layerServerSettings())),1065 }).pipe(Effect.provide(layerServerSettings())),
1157 );1066 );
11581067
1159 it.effect("keeps optional providers disabled after a new installation writes settings", () =>1068 it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () =>
1160 Effect.gen(function* () {1069 Effect.gen(function* () {
1161 const serverConfig = yield* ServerConfig.ServerConfig;1070 const serverConfig = yield* ServerConfig.ServerConfig;
1162 const fileSystem = yield* FileSystem.FileSystem;1071 const fileSystem = yield* FileSystem.FileSystem;
1163 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1072 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
11641073
1165 const initial = yield* serverSettings.getSettings;
1166 assert.isFalse(initial.providers.grok.enabled);
1167 assert.isFalse(initial.providers.opencode.enabled);
1168 assert.isFalse(initial.providers.cursor.enabled);
1169
1170 const next = yield* serverSettings.updateSettings({1074 const next = yield* serverSettings.updateSettings({
1171 addProjectBaseDirectory: "~/Development",
1172 providerInstances: {1075 providerInstances: {
1173 [ProviderInstanceId.make("grok")]: {1076 [ProviderInstanceId.make("grok")]: {
1174 driver: ProviderDriverKind.make("grok"),1077 driver: ProviderDriverKind.make("grok"),
...@@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1177 },1080 },
1178 });1081 });
11791082
1180 assert.isFalse(next.providers.grok.enabled);
1181 assert.isFalse(next.providers.opencode.enabled);
1182 assert.isFalse(next.providers.cursor.enabled);
1183 const grok = next.providerInstances[ProviderInstanceId.make("grok")];1083 const grok = next.providerInstances[ProviderInstanceId.make("grok")];
1184 assert.isDefined(grok);1084 assert.isDefined(grok);
1185 assert.isFalse(resolveProviderInstanceEnabled(grok));1085 assert.isFalse(resolveProviderInstanceEnabled(grok));
11861086 const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath));
1187 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);1087 assert.isUndefined(persisted.providers);
1188 const persisted = JSON.parse(raw);
1189 assert.isFalse(persisted.providers.cursor.enabled);
1190 assert.isFalse(persisted.providers.grok.enabled);
1191 assert.isFalse(persisted.providers.opencode.enabled);
1192 assert.isUndefined(persisted.providerInstances.grok.enabled);1088 assert.isUndefined(persisted.providerInstances.grok.enabled);
1193 }).pipe(Effect.provide(layerServerSettings())),1089 }).pipe(Effect.provide(layerServerSettings())),
1194 );1090 );
...@@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1252 }).pipe(Effect.provide(layerServerSettings())),1148 }).pipe(Effect.provide(layerServerSettings())),
1253 );1149 );
12541150
1255 it.effect("trims provider path settings when updates are applied", () =>
1256 Effect.gen(function* () {
1257 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1258
1259 const next = yield* serverSettings.updateSettings({
1260 providers: {
1261 codex: {
1262 binaryPath: " /opt/homebrew/bin/codex ",
1263 homePath: " ",
1264 },
1265 claudeAgent: {
1266 binaryPath: " /opt/homebrew/bin/claude ",
1267 },
1268 opencode: {
1269 binaryPath: " /opt/homebrew/bin/opencode ",
1270 serverUrl: " http://127.0.0.1:4096 ",
1271 serverPassword: " secret-password ",
1272 },
1273 },
1274 });
1275
1276 assert.deepEqual(next.providers.codex, {
1277 enabled: true,
1278 binaryPath: "/opt/homebrew/bin/codex",
1279 homePath: "",
1280 shadowHomePath: "",
1281 launchArgs: "",
1282 customModels: [],
1283 });
1284 assert.deepEqual(next.providers.claudeAgent, {
1285 enabled: true,
1286 binaryPath: "/opt/homebrew/bin/claude",
1287 homePath: "",
1288 customModels: [],
1289 launchArgs: "",
1290 autoCompactWindow: "",
1291 });
1292 assert.deepEqual(next.providers.opencode, {
1293 // OpenCode is disabled by default; this update only touches paths.
1294 enabled: false,
1295 binaryPath: "/opt/homebrew/bin/opencode",
1296 serverUrl: "http://127.0.0.1:4096",
1297 serverPassword: "secret-password",
1298 customModels: [],
1299 });
1300 }).pipe(Effect.provide(layerServerSettings())),
1301 );
1302
1303 it.effect("trims observability settings when updates are applied", () =>1151 it.effect("trims observability settings when updates are applied", () =>
1304 Effect.gen(function* () {1152 Effect.gen(function* () {
1305 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1153 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
...@@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1322 }).pipe(Effect.provide(layerServerSettings())),1170 }).pipe(Effect.provide(layerServerSettings())),
1323 );1171 );
13241172
1325 it.effect("defaults blank binary paths to provider executables", () =>1173 it.effect("writes only non-default settings to disk", () =>
1326 Effect.gen(function* () {
1327 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1328
1329 const next = yield* serverSettings.updateSettings({
1330 providers: {
1331 codex: {
1332 binaryPath: " ",
1333 },
1334 claudeAgent: {
1335 binaryPath: "",
1336 },
1337 },
1338 });
1339
1340 assert.equal(next.providers.codex.binaryPath, "codex");
1341 assert.equal(next.providers.claudeAgent.binaryPath, "claude");
1342 }).pipe(Effect.provide(layerServerSettings())),
1343 );
1344
1345 it.effect("writes non-default settings and explicit optional provider defaults to disk", () =>
1346 Effect.gen(function* () {1174 Effect.gen(function* () {
1347 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1175 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1348 const serverConfig = yield* ServerConfig.ServerConfig;1176 const serverConfig = yield* ServerConfig.ServerConfig;
1349 const fileSystem = yield* FileSystem.FileSystem;1177 const fileSystem = yield* FileSystem.FileSystem;
1350 const next = yield* serverSettings.updateSettings({1178 yield* serverSettings.updateSettings({
1351 addProjectBaseDirectory: "~/Development",1179 addProjectBaseDirectory: "~/Development",
1352 observability: {1180 observability: {
1353 otlpTracesUrl: "http://localhost:4318/v1/traces",1181 otlpTracesUrl: "http://localhost:4318/v1/traces",
1354 otlpMetricsUrl: "http://localhost:4318/v1/metrics",1182 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
1355 },1183 },
1356 providers: {
1357 codex: {
1358 binaryPath: "/opt/homebrew/bin/codex",
1359 },
1360 opencode: {
1361 serverUrl: "http://127.0.0.1:4096",
1362 serverPassword: "secret-password",
1363 },
1364 },
1365 automaticGitFetchInterval: Duration.seconds(10),1184 automaticGitFetchInterval: Duration.seconds(10),
1366 });1185 });
13671186
1368 assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
1369
1370 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);1187 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1371 assert.deepEqual(JSON.parse(raw), {1188 assert.deepEqual(JSON.parse(raw), {
1372 addProjectBaseDirectory: "~/Development",1189 addProjectBaseDirectory: "~/Development",
...@@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1374 otlpTracesUrl: "http://localhost:4318/v1/traces",1191 otlpTracesUrl: "http://localhost:4318/v1/traces",
1375 otlpMetricsUrl: "http://localhost:4318/v1/metrics",1192 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
1376 },1193 },
1377 providers: {
1378 codex: {
1379 binaryPath: "/opt/homebrew/bin/codex",
1380 },
1381 cursor: {
1382 enabled: false,
1383 },
1384 grok: {
1385 enabled: false,
1386 },
1387 opencode: {
1388 enabled: false,
1389 serverUrl: "http://127.0.0.1:4096",
1390 serverPassword: "secret-password",
1391 },
1392 },
1393 backgroundActivity: {1194 backgroundActivity: {
1394 schemaVersion: 1,1195 schemaVersion: 1,
1395 profile: "custom",1196 profile: "custom",
apps/server/src/serverSettings.ts+102-67
...@@ -23,6 +23,8 @@ import {...@@ -23,6 +23,8 @@ import {
23 type ProviderInstanceEnvironmentVariable,23 type ProviderInstanceEnvironmentVariable,
24 type UsageLimitSourceConfig,24 type UsageLimitSourceConfig,
25 type ProviderInstanceMutation,25 type ProviderInstanceMutation,
26 defaultInstanceIdForDriver,
27 isProviderDriverKind,
26 ProviderDriverKind,28 ProviderDriverKind,
27 ProviderInstanceId,29 ProviderInstanceId,
28 resolveProviderInstanceEnabled,30 resolveProviderInstanceEnabled,
...@@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson(...@@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson(
384 }),386 }),
385);387);
386const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);388const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
387const PersistedOptionalProviderSettings = Schema.Struct({389/**
388 providers: Schema.optionalKey(390 * The retired `providers.<kind>` map, read without its old schemas. Before
389 Schema.Struct({391 * `providerInstances` existed each built-in driver had one blob there; it
390 cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),392 * now only feeds `migrateLegacyProviderSettings`.
391 grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),393 */
392 opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),394const LegacyProviderSettingsJson = fromLenientJson(
393 }),395 Schema.Struct({
394 ),396 providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
395});397 }),
396const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit(
397 fromLenientJson(PersistedOptionalProviderSettings),
398);398);
399const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson);
399400
400function restoreUsedProviders(401// Drivers that start disabled, so a session in the history means the user
402// turned them on before the instance kept an explicit flag.
403const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
404 ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)),
405);
406
407/**
408 * Move each customized legacy `providers.<kind>` blob into the driver's
409 * default `providerInstances` slot. An explicit instance already in that slot
410 * wins. Blobs that match a fresh install (empty, or only an `enabled` flag
411 * equal to the driver default) carry nothing and are dropped.
412 *
413 * A cursor/grok/opencode slot without an `enabled` flag is enabled when
414 * provider history shows the driver was used, which is how those drivers
415 * were opted into before they had an explicit flag. Callers run this only
416 * while the settings file still has the retired map, so it happens once.
417 */
418function migrateLegacyProviderSettings(
401 settings: ServerSettings,419 settings: ServerSettings,
402 persisted: typeof PersistedOptionalProviderSettings.Type,420 legacyProviders: Readonly<Record<string, unknown>>,
403 providerHistory: ReadonlyArray<{421 providerHistory: ReadonlyArray<{
404 readonly providerName: string;422 readonly providerName: string;
405 readonly providerInstanceId: string | null;423 readonly providerInstanceId: string | null;
406 }>,424 }>,
407): ServerSettings {425): ServerSettings {
408 const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName));426 // History rows are raw SQL text; compare them as strings.
409 const usedProviderInstances = new Set(427 const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName));
428 const usedProviderInstances = new Set<string>(
410 providerHistory.map(429 providerHistory.map(
411 ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,430 ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,
412 ),431 ),
413 );432 );
414 const providerInstances = Object.fromEntries(433
415 Object.entries(settings.providerInstances).map(([instanceId, instance]) => [434 const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {};
416 instanceId,435 for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array<
436 [ProviderInstanceId, ProviderInstanceConfig]
437 >) {
438 providerInstances[instanceId] =
417 instance.enabled === undefined &&439 instance.enabled === undefined &&
418 (instance.driver === "cursor" ||440 HISTORY_RESTORED_DRIVERS.has(instance.driver) &&
419 instance.driver === "grok" ||
420 instance.driver === "opencode") &&
421 usedProviderInstances.has(instanceId)441 usedProviderInstances.has(instanceId)
422 ? { ...instance, enabled: true }442 ? { ...instance, enabled: true }
423 : instance,443 : instance;
424 ]),444 }
425 );445
446 // History restores a never-configured cursor/grok/opencode slot too.
447 const legacyEntries = new Map(Object.entries(legacyProviders));
448 for (const driver of HISTORY_RESTORED_DRIVERS) {
449 if (!legacyEntries.has(driver)) legacyEntries.set(driver, {});
450 }
451 for (const [kind, blob] of legacyEntries) {
452 if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue;
453 if (Array.isArray(blob)) continue;
454 const driver = kind;
455 const instanceId = defaultInstanceIdForDriver(driver);
456 if (Object.hasOwn(providerInstances, instanceId)) continue;
457
458 const { enabled: rawEnabled, ...config } = blob as Record<string, unknown>;
459 const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
460 const enabled =
461 explicitEnabled ??
462 (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined);
463 const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} });
464 if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) {
465 continue;
466 }
467 providerInstances[instanceId] = {
468 driver,
469 ...(enabled === undefined ? {} : { enabled }),
470 config,
471 } satisfies ProviderInstanceConfig;
472 }
426473
427 return {474 return {
428 ...settings,475 ...settings,
429 providers: {
430 ...settings.providers,
431 cursor: {
432 ...settings.providers.cursor,
433 enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"),
434 },
435 grok: {
436 ...settings.providers.grok,
437 enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"),
438 },
439 opencode: {
440 ...settings.providers.opencode,
441 enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"),
442 },
443 },
444 providerInstances,476 providerInstances,
445 };477 };
446}478}
447479
448const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");480const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry");
449481
482const TEXT_GENERATION_FALLBACK_DRIVERS = [
483 "codex",
484 "claudeAgent",
485 "cursor",
486 "grok",
487 "muse",
488 "pi",
489 "opencode",
490 "antigravity",
491].map((driver) => ProviderDriverKind.make(driver));
492
450/** ACP Registry instances reject every application text-generation operation. */493/** ACP Registry instances reject every application text-generation operation. */
451function selectionSupportsTextGeneration(494function selectionSupportsTextGeneration(
452 settings: ServerSettings,495 settings: ServerSettings,
...@@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings...@@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings
463}506}
464507
465function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {508function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings {
466 // Same precedence as isModelSelectionProviderEnabled: an explicit provider509 // The built-in default instances in preference order. A slot without an
467 // instance wins over the legacy providers map, which decodes to defaults510 // explicit instance uses its driver's default enabled state.
468 // (codex enabled) when the Providers UI has only written providerInstances.511 const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) =>
469 const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => {512 resolveProviderInstanceEnabled(
470 const instance = settings.providerInstances[ProviderInstanceId.make(driver)];513 settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} },
471 return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance);514 ),
472 });515 );
473 const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined;
474 if (!fallback) {516 if (!fallback) {
475 return settings;517 return settings;
476 }518 }
...@@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([...@@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
497 "pullRequestMergeMethod",539 "pullRequestMergeMethod",
498]);540]);
499541
500// Preserve both enabled states because provider history cannot recover a new opt-in.
501const PERSISTED_SERVER_SETTINGS_DEFAULTS = {
502 ...DEFAULT_SERVER_SETTINGS,
503 providers: {
504 ...DEFAULT_SERVER_SETTINGS.providers,
505 cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },
506 grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined },
507 opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined },
508 },
509};
510
511function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {542function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
512 if (Array.isArray(current) || Array.isArray(defaults)) {543 if (Array.isArray(current) || Array.isArray(defaults)) {
513 return Equal.equals(current, defaults) ? undefined : current;544 return Equal.equals(current, defaults) ? undefined : current;
...@@ -671,7 +702,7 @@ const make = Effect.gen(function* () {...@@ -671,7 +702,7 @@ const make = Effect.gen(function* () {
671 const writeSettingsAtomically = Effect.fnUntraced(702 const writeSettingsAtomically = Effect.fnUntraced(
672 function* (settings: ServerSettings) {703 function* (settings: ServerSettings) {
673 const sparseSettingsJson = yield* encodeServerSettingsJson(704 const sparseSettingsJson = yield* encodeServerSettingsJson(
674 stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},705 stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
675 );706 );
676707
677 return yield* writeFileStringAtomically({708 return yield* writeFileStringAtomically({
...@@ -740,7 +771,7 @@ const make = Effect.gen(function* () {...@@ -740,7 +771,7 @@ const make = Effect.gen(function* () {
740771
741 const loadSettingsFromDisk = Effect.gen(function* () {772 const loadSettingsFromDisk = Effect.gen(function* () {
742 let settings = DEFAULT_SERVER_SETTINGS;773 let settings = DEFAULT_SERVER_SETTINGS;
743 let persisted: typeof PersistedOptionalProviderSettings.Type = {};774 let legacyProviders: Readonly<Record<string, unknown>> | undefined;
744 // A file that failed to decode must stay on disk for the user to repair;775 // A file that failed to decode must stay on disk for the user to repair;
745 // the fold below only writes when it started from the file's real contents.776 // the fold below only writes when it started from the file's real contents.
746 let settingsFileTrusted = true;777 let settingsFileTrusted = true;
...@@ -748,12 +779,12 @@ const make = Effect.gen(function* () {...@@ -748,12 +779,12 @@ const make = Effect.gen(function* () {
748 if (yield* readConfigExists) {779 if (yield* readConfigExists) {
749 const raw = yield* readRawConfig;780 const raw = yield* readRawConfig;
750 const decoded = decodeServerSettingsJsonExit(raw);781 const decoded = decodeServerSettingsJsonExit(raw);
751 const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw);782 const legacySettings = decodeLegacyProviderSettingsJsonExit(raw);
752 if (persistedSettings._tag === "Success") {783 if (legacySettings._tag === "Success") {
753 persisted = persistedSettings.value;784 legacyProviders = legacySettings.value.providers;
754 }785 }
755 if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {786 if (decoded._tag === "Failure" || legacySettings._tag === "Failure") {
756 const failure = decoded._tag === "Failure" ? decoded : persistedSettings;787 const failure = decoded._tag === "Failure" ? decoded : legacySettings;
757 settingsFileTrusted = false;788 settingsFileTrusted = false;
758 if (failure._tag === "Failure") {789 if (failure._tag === "Failure") {
759 yield* Effect.logWarning("failed to parse settings.json, using defaults", {790 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
...@@ -817,14 +848,18 @@ const make = Effect.gen(function* () {...@@ -817,14 +848,18 @@ const make = Effect.gen(function* () {
817 );848 );
818849
819 const loaded = foldProviderInstanceEnabledFlags(850 const loaded = foldProviderInstanceEnabledFlags(
820 restoreUsedProviders(settings, persisted, providerHistory),851 legacyProviders === undefined
852 ? settings
853 : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory),
821 );854 );
822 const folded = settingsFileTrusted855 const folded = settingsFileTrusted
823 ? foldLegacyProjectSettings(loaded, legacyProjectRows)856 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
824 : loaded;857 : loaded;
825 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.858 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
826 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;859 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;
827 if (migrated !== loaded) {860 // A file still carrying the retired `providers` map is rewritten once so
861 // the migrated instances persist and the old key disappears.
862 if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) {
828 yield* writeSettingsAtomically(migrated);863 yield* writeSettingsAtomically(migrated);
829 }864 }
830 return migrated;865 return migrated;
apps/server/src/terminal/Manager.test.ts+21-23
...@@ -2272,7 +2272,7 @@ it.layer(...@@ -2272,7 +2272,7 @@ it.layer(
2272 ),2272 ),
2273 );2273 );
22742274
2275 it.effect("resolves the legacy Codex default instance", () =>2275 it.effect("resolves the Codex default slot", () =>
2276 Effect.gen(function* () {2276 Effect.gen(function* () {
2277 const path = yield* Path.Path;2277 const path = yield* Path.Path;
2278 const serverSettings = yield* ServerSettings.ServerSettingsService;2278 const serverSettings = yield* ServerSettings.ServerSettingsService;
...@@ -2283,18 +2283,22 @@ it.layer(...@@ -2283,18 +2283,22 @@ it.layer(
2283 env: undefined,2283 env: undefined,
2284 });2284 });
22852285
2286 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/);2286 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/);
2287 }).pipe(2287 }).pipe(
2288 Effect.provide(2288 Effect.provide(
2289 ServerSettings.ServerSettingsService.layerTest({2289 ServerSettings.ServerSettingsService.layerTest({
2290 providerInstances: {},2290 providerInstances: {
2291 providers: { codex: { homePath: "~/.codex-legacy" } },2291 [ProviderInstanceId.make("codex")]: {
2292 driver: ProviderDriverKind.make("codex"),
2293 config: { homePath: "~/.codex-default" },
2294 },
2295 },
2292 }),2296 }),
2293 ),2297 ),
2294 ),2298 ),
2295 );2299 );
22962300
2297 it.effect("resolves the legacy Claude default instance", () =>2301 it.effect("resolves the Claude default slot", () =>
2298 Effect.gen(function* () {2302 Effect.gen(function* () {
2299 const path = yield* Path.Path;2303 const path = yield* Path.Path;
2300 const serverSettings = yield* ServerSettings.ServerSettingsService;2304 const serverSettings = yield* ServerSettings.ServerSettingsService;
...@@ -2305,18 +2309,22 @@ it.layer(...@@ -2305,18 +2309,22 @@ it.layer(
2305 env: undefined,2309 env: undefined,
2306 });2310 });
23072311
2308 expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/);2312 expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/);
2309 }).pipe(2313 }).pipe(
2310 Effect.provide(2314 Effect.provide(
2311 ServerSettings.ServerSettingsService.layerTest({2315 ServerSettings.ServerSettingsService.layerTest({
2312 providerInstances: {},2316 providerInstances: {
2313 providers: { claudeAgent: { homePath: "~/.claude-legacy" } },2317 [ProviderInstanceId.make("claudeAgent")]: {
2318 driver: ProviderDriverKind.make("claudeAgent"),
2319 config: { homePath: "~/.claude-default" },
2320 },
2321 },
2314 }),2322 }),
2315 ),2323 ),
2316 ),2324 ),
2317 );2325 );
23182326
2319 it.effect("prefers an explicit default instance over legacy provider settings", () =>2327 it.effect("resolves an empty Codex default slot with default config", () =>
2320 Effect.gen(function* () {2328 Effect.gen(function* () {
2321 const path = yield* Path.Path;2329 const path = yield* Path.Path;
2322 const serverSettings = yield* ServerSettings.ServerSettingsService;2330 const serverSettings = yield* ServerSettings.ServerSettingsService;
...@@ -2324,26 +2332,16 @@ it.layer(...@@ -2324,26 +2332,16 @@ it.layer(
2324 serverSettings,2332 serverSettings,
2325 path,2333 path,
2326 rawProviderInstanceId: "codex",2334 rawProviderInstanceId: "codex",
2327 env: undefined,2335 env: { CODEX_HOME: "/inherited/codex-home" },
2328 });2336 });
23292337
2330 expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/);2338 expect(environment.CODEX_HOME).toBe("/inherited/codex-home");
2331 }).pipe(2339 }).pipe(
2332 Effect.provide(2340 Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })),
2333 ServerSettings.ServerSettingsService.layerTest({
2334 providers: { codex: { homePath: "~/.codex-legacy" } },
2335 providerInstances: {
2336 [ProviderInstanceId.make("codex")]: {
2337 driver: "codex",
2338 config: { homePath: "~/.codex-explicit" },
2339 },
2340 },
2341 }),
2342 ),
2343 ),2341 ),
2344 );2342 );
23452343
2346 it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () =>2344 it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () =>
2347 Effect.gen(function* () {2345 Effect.gen(function* () {
2348 const path = yield* Path.Path;2346 const path = yield* Path.Path;
2349 const serverSettings = yield* ServerSettings.ServerSettingsService;2347 const serverSettings = yield* ServerSettings.ServerSettingsService;
apps/server/src/usage/UsageService.test.ts+97-92
...@@ -73,9 +73,15 @@ const setup = Effect.gen(function* () {...@@ -73,9 +73,15 @@ const setup = Effect.gen(function* () {
73 home,73 home,
74 transcript: NodePath.join(transcriptDir, "session.jsonl"),74 transcript: NodePath.join(transcriptDir, "session.jsonl"),
75 settings: {75 settings: {
76 providers: {76 providerInstances: {
77 claudeAgent: { homePath: NodePath.join(home, "claude") },77 [ProviderInstanceId.make("claudeAgent")]: {
78 codex: { homePath: NodePath.join(home, "codex") },78 driver: ProviderDriverKind.make("claudeAgent"),
79 config: { homePath: NodePath.join(home, "claude") },
80 },
81 [ProviderInstanceId.make("codex")]: {
82 driver: ProviderDriverKind.make("codex"),
83 config: { homePath: NodePath.join(home, "codex") },
84 },
79 },85 },
80 },86 },
81 };87 };
...@@ -227,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) {...@@ -227,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) {
227}233}
228234
229describe("UsageService", () => {235describe("UsageService", () => {
230 it.live.each([236 it.live("reads shared managed default and disabled extra account history once", () =>
231 { explicitDefault: true, label: "explicit" },237 Effect.gen(function* () {
232 { explicitDefault: false, label: "legacy" },238 const { home, settings } = yield* setup;
233 ])(239 const summary = yield* Effect.gen(function* () {
234 "reads shared managed $label default and disabled extra account history once",240 for (const [id, output] of [
235 ({ explicitDefault }) =>241 ["codex", 17],
236 Effect.gen(function* () {242 ["codex-personal", 23],
237 const { home, settings } = yield* setup;243 ] as const) {
238 const summary = yield* Effect.gen(function* () {244 const sessions = NodePath.join(home, "shared-codex", "sessions");
239 for (const [id, output] of [245 yield* Effect.promise(async () => {
240 ["codex", 17],246 await NodeFSP.mkdir(sessions, { recursive: true });
241 ["codex-personal", 23],247 await NodeFSP.writeFile(
242 ] as const) {248 NodePath.join(sessions, `${id}-rollout.jsonl`),
243 const sessions = NodePath.join(home, "shared-codex", "sessions");249 [
244 yield* Effect.promise(async () => {250 { type: "session_meta", payload: { id } },
245 await NodeFSP.mkdir(sessions, { recursive: true });251 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
246 await NodeFSP.writeFile(252 {
247 NodePath.join(sessions, `${id}-rollout.jsonl`),253 type: "event_msg",
248 [254 timestamp: "2026-08-01T10:00:00Z",
249 { type: "session_meta", payload: { id } },255 payload: {
250 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },256 type: "token_count",
251 {257 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },
252 type: "event_msg",
253 timestamp: "2026-08-01T10:00:00Z",
254 payload: {
255 type: "token_count",
256 info: { last_token_usage: { input_tokens: 10, output_tokens: output } },
257 },
258 },258 },
259 ]
260 .map((line) => encodeUnknownJsonString(line))
261 .join("\n") + "\n",
262 );
263 });
264 }
265 const service = yield* UsageService.make;
266 return yield* service.readSummary(WINDOW);
267 }).pipe(
268 // Scoped inside the state directory, so pending cache writes land
269 // before it is removed.
270 Effect.scoped,
271 Effect.provide(
272 layerService({
273 prefix: "usage-managed-accounts",
274 home,
275 settings: {
276 ...settings,
277 providers: {
278 ...settings.providers,
279 codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") },
280 },259 },
281 providerInstances: {260 ]
282 ...(explicitDefault261 .map((line) => encodeUnknownJsonString(line))
283 ? {262 .join("\n") + "\n",
284 [ProviderInstanceId.make("codex")]: {263 );
285 driver: ProviderDriverKind.make("codex"),264 });
286 config: {265 }
287 setupMode: "managed",266 const service = yield* UsageService.make;
288 homePath: NodePath.join(home, "shared-codex"),267 return yield* service.readSummary(WINDOW);
289 },268 }).pipe(
290 },269 // Scoped inside the state directory, so pending cache writes land
291 }270 // before it is removed.
292 : {}),271 Effect.scoped,
293 [ProviderInstanceId.make("codex-personal")]: {272 Effect.provide(
294 driver: ProviderDriverKind.make("codex"),273 layerService({
295 enabled: false,274 prefix: "usage-managed-accounts",
296 config: {275 home,
297 setupMode: "managed",276 settings: {
298 homePath: NodePath.join(home, "shared-codex"),277 ...settings,
299 shadowHomePath: NodePath.join(home, "personal-shadow"),278 providerInstances: {
300 },279 ...settings.providerInstances,
301 environment: [280 [ProviderInstanceId.make("codex")]: {
302 {281 driver: ProviderDriverKind.make("codex"),
303 name: "CODEX_HOME",282 config: {
304 value: NodePath.join(home, "ignored-environment"),283 setupMode: "managed",
305 sensitive: false,284 homePath: NodePath.join(home, "shared-codex"),
306 },285 },
307 ],286 },
287 [ProviderInstanceId.make("codex-personal")]: {
288 driver: ProviderDriverKind.make("codex"),
289 enabled: false,
290 config: {
291 setupMode: "managed",
292 homePath: NodePath.join(home, "shared-codex"),
293 shadowHomePath: NodePath.join(home, "personal-shadow"),
308 },294 },
295 environment: [
296 {
297 name: "CODEX_HOME",
298 value: NodePath.join(home, "ignored-environment"),
299 sensitive: false,
300 },
301 ],
309 },302 },
310 },303 },
311 }),304 },
312 ),305 }),
313 );306 ),
314 assert.strictEqual(totalOutputTokens(summary), 40);307 );
315 assert.strictEqual(308 assert.strictEqual(totalOutputTokens(summary), 40);
316 summary.sources.filter(309 assert.strictEqual(
317 (source) => source.fingerprint.provider === "codex" && source.status === "ok",310 summary.sources.filter(
318 ).length,311 (source) => source.fingerprint.provider === "codex" && source.status === "ok",
319 1,312 ).length,
320 );313 1,
321 }).pipe(Effect.scoped),314 );
315 }).pipe(Effect.scoped),
322 );316 );
323 it.live("omits Cursor account usage when no file login is saved", () =>317 it.live("omits Cursor account usage when no file login is saved", () =>
324 Effect.gen(function* () {318 Effect.gen(function* () {
...@@ -788,6 +782,7 @@ describe("UsageService", () => {...@@ -788,6 +782,7 @@ describe("UsageService", () => {
788 settings: {782 settings: {
789 ...settings,783 ...settings,
790 providerInstances: {784 providerInstances: {
785 ...settings.providerInstances,
791 [ProviderInstanceId.make("claude-work")]: {786 [ProviderInstanceId.make("claude-work")]: {
792 driver: ProviderDriverKind.make("claudeAgent"),787 driver: ProviderDriverKind.make("claudeAgent"),
793 enabled: false,788 enabled: false,
...@@ -847,7 +842,7 @@ describe("UsageService", () => {...@@ -847,7 +842,7 @@ describe("UsageService", () => {
847 );842 );
848843
849 it.live(844 it.live(
850 "uses explicit account settings before environment and legacy homes, then refreshes them",845 "uses explicit account settings before environment and default homes, then refreshes them",
851 () =>846 () =>
852 Effect.gen(function* () {847 Effect.gen(function* () {
853 const { transcript, settings, home } = yield* setup;848 const { transcript, settings, home } = yield* setup;
...@@ -884,6 +879,7 @@ describe("UsageService", () => {...@@ -884,6 +879,7 @@ describe("UsageService", () => {
884 );879 );
885 yield* settingsService.updateSettings({880 yield* settingsService.updateSettings({
886 providerInstances: {881 providerInstances: {
882 ...settings.providerInstances,
887 [ProviderInstanceId.make("claudeAgent")]: {883 [ProviderInstanceId.make("claudeAgent")]: {
888 driver: ProviderDriverKind.make("claudeAgent"),884 driver: ProviderDriverKind.make("claudeAgent"),
889 config: { homePath: "" },885 config: { homePath: "" },
...@@ -912,6 +908,7 @@ describe("UsageService", () => {...@@ -912,6 +908,7 @@ describe("UsageService", () => {
912 settings: {908 settings: {
913 ...settings,909 ...settings,
914 providerInstances: {910 providerInstances: {
911 ...settings.providerInstances,
915 [ProviderInstanceId.make("claudeAgent")]: {912 [ProviderInstanceId.make("claudeAgent")]: {
916 driver: ProviderDriverKind.make("claudeAgent"),913 driver: ProviderDriverKind.make("claudeAgent"),
917 config: { homePath: configured },914 config: { homePath: configured },
...@@ -1271,7 +1268,15 @@ describe("UsageService", () => {...@@ -1271,7 +1268,15 @@ describe("UsageService", () => {
1271 layerService({1268 layerService({
1272 prefix: "usage-service-cleanup-test",1269 prefix: "usage-service-cleanup-test",
1273 home,1270 home,
1274 settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } },1271 settings: {
1272 providerInstances: {
1273 ...settings.providerInstances,
1274 [ProviderInstanceId.make("claudeAgent")]: {
1275 driver: ProviderDriverKind.make("claudeAgent"),
1276 config: { homePath: alias },
1277 },
1278 },
1279 },
1275 ratesDocument: {1280 ratesDocument: {
1276 "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 },1281 "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 },
1277 },1282 },
apps/server/src/usage/UsageService.ts+3-6
...@@ -351,18 +351,15 @@ export const make = Effect.gen(function* () {...@@ -351,18 +351,15 @@ export const make = Effect.gen(function* () {
351 }> = [];351 }> = [];
352 const seen = new Set<string>();352 const seen = new Set<string>();
353 for (const driver of ["claudeAgent", "codex", "grok"] as const) {353 for (const driver of ["claudeAgent", "codex", "grok"] as const) {
354 // Disabled accounts still have history. Explicit default slots replace354 // Disabled accounts still have history. An unconfigured default slot
355 // the legacy settings, just as they do in the provider registry.355 // runs with default config, just as it does in the provider registry.
356 const instances: Array<356 const instances: Array<
357 Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId }357 Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId }
358 > = Object.entries(settings.providerInstances)358 > = Object.entries(settings.providerInstances)
359 .filter(([, instance]) => instance.driver === driver)359 .filter(([, instance]) => instance.driver === driver)
360 .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) }));360 .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) }));
361 if (!Object.hasOwn(settings.providerInstances, driver)) {361 if (!Object.hasOwn(settings.providerInstances, driver)) {
362 instances.push({362 instances.push({ instanceId: ProviderInstanceId.make(driver) });
363 config: settings.providers[driver],
364 instanceId: ProviderInstanceId.make(driver),
365 });
366 }363 }
367 for (const instance of instances) {364 for (const instance of instances) {
368 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);365 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-4
...@@ -898,8 +898,7 @@ function OnboardingCodexSetup({...@@ -898,8 +898,7 @@ function OnboardingCodexSetup({
898 const settings = serverConfig.settings;898 const settings = serverConfig.settings;
899 const instance = settings.providerInstances[instanceId] ?? {899 const instance = settings.providerInstances[instanceId] ?? {
900 driver: ProviderDriverKind.make("codex"),900 driver: ProviderDriverKind.make("codex"),
901 enabled: settings.providers.codex.enabled,901 config: createdAccount ? { enabled: true, setupMode: "managed" } : {},
902 config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex,
903 };902 };
904 const mode = readCodexSetupMode(instance.config);903 const mode = readCodexSetupMode(instance.config);
905 const existingChosen =904 const existingChosen =
...@@ -915,8 +914,6 @@ function OnboardingCodexSetup({...@@ -915,8 +914,6 @@ function OnboardingCodexSetup({
915 patch: buildProviderInstanceUpdatePatch({914 patch: buildProviderInstanceUpdatePatch({
916 settings,915 settings,
917 instanceId,916 instanceId,
918 driver: ProviderDriverKind.make("codex"),
919 isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")),
920 instance: {917 instance: {
921 ...instance,918 ...instance,
922 enabled: true,919 enabled: true,
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+6-11
...@@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react";...@@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react";
7import { useMemo, useState } from "react";7import { useMemo, useState } from "react";
8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";8import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import {9import {
10 DEFAULT_UNIFIED_SETTINGS,
11 type AcpRegistrySearchAgent,10 type AcpRegistrySearchAgent,
12 AuthProvidersManageScope,11 AuthProvidersManageScope,
13 ProviderInstanceId,12 ProviderInstanceId,
...@@ -21,7 +20,6 @@ import {...@@ -21,7 +20,6 @@ import {
21 useEnvironmentSettings,20 useEnvironmentSettings,
22 usePersistEnvironmentProviderInstanceMutation,21 usePersistEnvironmentProviderInstanceMutation,
23} from "../../hooks/useSettings";22} from "../../hooks/useSettings";
24import * as Equal from "effect/Equal";
2523
26import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
27import { normalizeProviderAccentColor } from "../../providerInstances";25import { normalizeProviderAccentColor } from "../../providerInstances";
...@@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({...@@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({
139 const [isSaving, setIsSaving] = useState(false);137 const [isSaving, setIsSaving] = useState(false);
140 const [createdInstanceId, setCreatedInstanceId] = useState<ProviderInstanceId | null>(null);138 const [createdInstanceId, setCreatedInstanceId] = useState<ProviderInstanceId | null>(null);
141139
142 const existingIds = useMemo(() => {140 // Codex and Claude run at their default slots before they are configured, so
143 const ids = new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]);141 // those ids stay reserved; other unconfigured default slots are free to take.
144 const defaults = DEFAULT_UNIFIED_SETTINGS.providers as Record<string, unknown>;142 const existingIds = useMemo(
145 // Reserve configured legacy slots too, so adding an account cannot replace them.143 () => new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]),
146 for (const [kind, config] of Object.entries(settings.providers ?? {})) {144 [settings.providerInstances],
147 if (!Equal.equals(config, defaults[kind])) ids.add(kind);145 );
148 }
149 return ids;
150 }, [settings.providerInstances, settings.providers]);
151146
152 const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION;147 const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION;
153 const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND;148 const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND;
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+8-8
...@@ -276,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -276,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => {
276 it("keeps legacy provider configuration visible when disabled", () => {276 it("keeps legacy provider configuration visible when disabled", () => {
277 settingsState.value = {277 settingsState.value = {
278 ...DEFAULT_UNIFIED_SETTINGS,278 ...DEFAULT_UNIFIED_SETTINGS,
279 providers: {279 providerInstances: {
280 ...DEFAULT_UNIFIED_SETTINGS.providers,280 [ProviderInstanceId.make("grok")]: {
281 grok: {281 driver: ProviderDriverKind.make("grok"),
282 ...DEFAULT_UNIFIED_SETTINGS.providers.grok,
283 enabled: false,282 enabled: false,
284 binaryPath: "/custom/grok",283 config: {
284 binaryPath: "/custom/grok",
285 },
285 },286 },
286 },287 },
287 };288 };
...@@ -516,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -516,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => {
516517
517 const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? [];518 const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? [];
518 expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId });519 expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId });
519 expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providers"]);520 // Removing the instance is the whole reset; shared preferences stay untouched.
520 expect(resetPatch).not.toHaveProperty("favorites");521 expect(resetPatch ?? {}).toEqual({});
521 expect(resetPatch).not.toHaveProperty("providerModelPreferences");
522 });522 });
523523
524 it("updates one provider instance without sending a stale whole map", async () => {524 it("updates one provider instance without sending a stale whole map", async () => {
apps/web/src/components/settings/ProviderSettingsPanel.tsx+22-64
...@@ -147,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null {...@@ -147,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null {
147147
148const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({148const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({
149 provider: definition.driverKind,149 provider: definition.driverKind,
150 hasDefaultInstance: definition.hasDefaultInstance !== false,
150}));151}));
151152
152function configuredBinaryPath(config: unknown): string {153function configuredBinaryPath(config: unknown): string {
...@@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({...@@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({
757 );758 );
758759
759 for (const providerSettings of visibleProviderSettings) {760 for (const providerSettings of visibleProviderSettings) {
760 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];
761 const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>;
762 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<
763 string,
764 LegacyProviderSettings
765 >;
766 const driver = providerSettings.provider;761 const driver = providerSettings.provider;
767 const defaultInstanceId = defaultInstanceIdForDriver(driver);762 const defaultInstanceId = defaultInstanceIdForDriver(driver);
768 const explicitInstance = settings.providerInstances?.[defaultInstanceId];763 const explicitInstance = settings.providerInstances?.[defaultInstanceId];
769 // A remote device may run a server version whose settings predate this764 // An unconfigured default slot runs with the driver's default config.
770 // driver, so the legacy mirror can be absent. Without either an explicit765 const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver };
771 // instance or a legacy blob there is nothing to render for the slot.766 const isDirty = explicitInstance !== undefined;
772 const legacyConfig = legacyProviders[providerSettings.provider];767 // Drivers without a default instance list only their configured instances.
773 const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider];768 const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined;
774 // The envelope is the single enabled flag: keep the legacy in-config769 if (
775 // flag out of the synthesized blob, or an explicit `enabled: false`770 hasDefaultSlot &&
776 // would keep winning over the envelope and the Switch could never771 (driver === "codex" ||
777 // turn a default-off provider on.
778 const synthesizedInstance = (): ProviderInstanceConfig | undefined => {
779 if (legacyConfig === undefined) {
780 return undefined;
781 }
782 const { enabled: legacyEnabled, ...legacyConfigRest } = legacyConfig;
783 return {
784 driver,
785 enabled: legacyEnabled,
786 config: legacyConfigRest,
787 } satisfies ProviderInstanceConfig;
788 };
789 const effectiveInstance: ProviderInstanceConfig | undefined =
790 explicitInstance ?? synthesizedInstance();
791 // Only the default slot depends on the legacy blob; custom instances for
792 // the driver must still render even when the slot has nothing to show.
793 if (effectiveInstance !== undefined) {
794 const isDirty =
795 explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig);
796 if (
797 driver === "codex" ||
798 driver === "claudeAgent" ||772 driver === "claudeAgent" ||
799 isDirty ||773 isDirty ||
800 resolveProviderInstanceEnabled(effectiveInstance) ||774 resolveProviderInstanceEnabled(effectiveInstance) ||
801 defaultInstanceId === targetInstanceId775 defaultInstanceId === targetInstanceId)
802 ) {776 ) {
803 rows.push({777 rows.push({
804 instanceId: defaultInstanceId,778 instanceId: defaultInstanceId,
805 instance: effectiveInstance,779 instance: effectiveInstance,
806 driver,780 driver,
807 isDefault: true,781 isDefault: true,
808 isDirty,782 isDirty,
809 });783 });
810 }
811 }784 }
812 for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {785 for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
813 if (id === defaultInstanceId) continue;786 if (id === defaultInstanceId) continue;
...@@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({...@@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({
847 settings,820 settings,
848 instanceId: row.instanceId,821 instanceId: row.instanceId,
849 instance: next,822 instance: next,
850 driver: row.driver,
851 isDefault: row.isDefault,
852 textGenerationModelSelection: options?.textGenerationModelSelection,823 textGenerationModelSelection: options?.textGenerationModelSelection,
853 });824 });
854 const result = await persistProviderInstance(825 const result = await persistProviderInstance(
...@@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({...@@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({
947 };918 };
948919
949 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {920 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {
950 type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers];921 const result = await persistProviderInstance({
951 const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record<922 operation: "remove",
952 string,923 instanceId: defaultInstanceIdForDriver(driverKind),
953 LegacyProviderSettings | undefined924 });
954 >;
955 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
956 const defaultLegacyProvider = defaultLegacyProviders[driverKind];
957 if (defaultLegacyProvider === undefined) return;
958 const result = await persistProviderInstance(
959 { operation: "remove", instanceId: defaultInstanceId },
960 {
961 providers: {
962 ...settings.providers,
963 [driverKind]: defaultLegacyProvider,
964 } as typeof settings.providers,
965 },
966 );
967 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {925 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
968 const error = squashAtomCommandFailure(result);926 const error = squashAtomCommandFailure(result);
969 toastManager.add({927 toastManager.add({
apps/web/src/components/settings/SettingsPanels.logic.test.ts+8-38
...@@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => {...@@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => {
190});190});
191191
192describe("buildProviderInstanceUpdatePatch", () => {192describe("buildProviderInstanceUpdatePatch", () => {
193 it("promotes an edited default provider into providerInstances and resets the legacy provider", () => {193 it("replaces one instance and keeps the others", () => {
194 const instanceId = ProviderInstanceId.make("codex");194 const instanceId = ProviderInstanceId.make("codex");
195 const nextInstance = {195 const otherId = ProviderInstanceId.make("codex_personal");
196 const other = {
196 driver: ProviderDriverKind.make("codex"),197 driver: ProviderDriverKind.make("codex"),
197 enabled: true,198 config: { homePath: "/Users/example/.codex-personal" },
198 config: {
199 binaryPath: "/opt/t3/codex",
200 },
201 } satisfies ProviderInstanceConfig;199 } satisfies ProviderInstanceConfig;
202
203 const patch = buildProviderInstanceUpdatePatch({
204 settings: {
205 ...DEFAULT_SERVER_SETTINGS,
206 providers: {
207 ...DEFAULT_SERVER_SETTINGS.providers,
208 codex: {
209 ...DEFAULT_SERVER_SETTINGS.providers.codex,
210 binaryPath: "/legacy/codex",
211 },
212 },
213 },
214 instanceId,
215 instance: nextInstance,
216 driver: ProviderDriverKind.make("codex"),
217 isDefault: true,
218 });
219
220 expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance);
221 expect(patch.providers?.codex).toEqual(DEFAULT_SERVER_SETTINGS.providers.codex);
222 });
223
224 it("updates custom instances without touching legacy provider settings", () => {
225 const instanceId = ProviderInstanceId.make("codex_personal");
226 const nextInstance = {200 const nextInstance = {
227 driver: ProviderDriverKind.make("codex"),201 driver: ProviderDriverKind.make("codex"),
228 enabled: true,202 enabled: true,
229 config: {203 config: { binaryPath: "/opt/t3/codex" },
230 homePath: "/Users/example/.codex-personal",
231 },
232 } satisfies ProviderInstanceConfig;204 } satisfies ProviderInstanceConfig;
233205
234 const patch = buildProviderInstanceUpdatePatch({206 const patch = buildProviderInstanceUpdatePatch({
235 settings: DEFAULT_SERVER_SETTINGS,207 settings: { providerInstances: { [otherId]: other } },
236 instanceId,208 instanceId,
237 instance: nextInstance,209 instance: nextInstance,
238 driver: ProviderDriverKind.make("codex"),
239 isDefault: false,
240 });210 });
241211
242 expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance);212 expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance });
243 expect(patch.providers).toBeUndefined();213 expect(patch).not.toHaveProperty("textGenerationModelSelection");
244 });214 });
245});215});
246216
apps/web/src/components/settings/SettingsPanels.logic.ts+1-18
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import type {1import type {
2 BackgroundActivityProfile,2 BackgroundActivityProfile,
3 BackgroundActivitySettings,3 BackgroundActivitySettings,
4 ProviderDriverKind,
5 ProviderInstanceConfig,4 ProviderInstanceConfig,
6 PreviewViewportSetting,5 PreviewViewportSetting,
7 ProviderInstanceId,6 ProviderInstanceId,
...@@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: {...@@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: {
255}254}
256255
257export function buildProviderInstanceUpdatePatch(input: {256export function buildProviderInstanceUpdatePatch(input: {
258 readonly settings: Pick<ServerSettings, "providers" | "providerInstances">;257 readonly settings: Pick<ServerSettings, "providerInstances">;
259 readonly instanceId: ProviderInstanceId;258 readonly instanceId: ProviderInstanceId;
260 readonly instance: ProviderInstanceConfig;259 readonly instance: ProviderInstanceConfig;
261 readonly driver: ProviderDriverKind;
262 readonly isDefault: boolean;
263 readonly textGenerationModelSelection?:260 readonly textGenerationModelSelection?:
264 | ServerSettings["textGenerationModelSelection"]261 | ServerSettings["textGenerationModelSelection"]
265 | undefined;262 | undefined;
266}): Partial<UnifiedSettings> {263}): Partial<UnifiedSettings> {
267 type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
268 const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record<
269 string,
270 LegacyProviderSettings | undefined
271 >;
272 const legacyProviderDefault = input.isDefault ? legacyProviderDefaults[input.driver] : undefined;
273 return {264 return {
274 ...(legacyProviderDefault !== undefined
275 ? {
276 providers: {
277 ...input.settings.providers,
278 [input.driver]: legacyProviderDefault,
279 } as ServerSettings["providers"],
280 }
281 : {}),
282 providerInstances: {265 providerInstances: {
283 ...input.settings.providerInstances,266 ...input.settings.providerInstances,
284 [input.instanceId]: input.instance,267 [input.instanceId]: input.instance,
apps/web/src/modelSelection.test.ts+4-7
...@@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => {...@@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => {
636 const nativeModel = "gemini-3.1-pro";636 const nativeModel = "gemini-3.1-pro";
637 const settings: UnifiedSettings = {637 const settings: UnifiedSettings = {
638 ...DEFAULT_UNIFIED_SETTINGS,638 ...DEFAULT_UNIFIED_SETTINGS,
639 providers: {
640 ...DEFAULT_UNIFIED_SETTINGS.providers,
641 antigravity: {
642 ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity,
643 customModels: ["api-only-model"],
644 },
645 },
646 providerInstances: {639 providerInstances: {
640 [ProviderInstanceId.make("antigravity")]: {
641 driver,
642 config: { customModels: ["api-only-model"] },
643 },
647 [customId]: { driver, config: { customModels: ["unknown-model"] } },644 [customId]: { driver, config: { customModels: ["unknown-model"] } },
648 },645 },
649 };646 };
apps/web/src/modelSelection.ts+3-14
...@@ -66,15 +66,7 @@ function readInstanceCustomModels(...@@ -66,15 +66,7 @@ function readInstanceCustomModels(
66 return readCustomModelEntries(value);66 return readCustomModelEntries(value);
67 }67 }
68 }68 }
69 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);69 return [];
70 if (instanceId !== defaultInstanceId) {
71 return [];
72 }
73 const legacyProviders = settings.providers as Record<
74 string,
75 { readonly customModels: ReadonlyArray<unknown> } | undefined
76 >;
77 return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []);
78}70}
7971
80export interface AppModelOption {72export interface AppModelOption {
...@@ -228,11 +220,8 @@ function getAppModelOptions(...@@ -228,11 +220,8 @@ function getAppModelOptions(
228 * come from the instance's own `entry.models` snapshot (rather than the220 * come from the instance's own `entry.models` snapshot (rather than the
229 * first-matching-kind fallback in `getProviderModels`), so each custom221 * first-matching-kind fallback in `getProviderModels`), so each custom
230 * instance gets the precise model list its driver reported. Custom model222 * instance gets the precise model list its driver reported. Custom model
231 * slugs come from the instance's own `providerInstances[id].config.customModels`223 * slugs come from the instance's own `providerInstances[id].config.customModels`,
232 * when present, falling back to the legacy per-kind224 * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are
233 * `settings.providers[driverKind].customModels` bucket for default
234 * instances only. This keeps two instances of the same kind from leaking
235 * custom slugs into each other. Custom rows reported by the server are
236 * ignored so a slug removed in Settings disappears without waiting for the225 * ignored so a slug removed in Settings disappears without waiting for the
237 * next provider probe.226 * next provider probe.
238 */227 */
apps/web/src/onboarding/providerReadiness.logic.test.ts+36-30
...@@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
196 readyCodex,196 readyCodex,
197 {197 {
198 ...DEFAULT_SERVER_SETTINGS,198 ...DEFAULT_SERVER_SETTINGS,
199 providers: {199 providerInstances: {
200 ...DEFAULT_SERVER_SETTINGS.providers,200 [ProviderInstanceId.make("codex")]: {
201 codex: {201 driver: ProviderDriverKind.make("codex"),
202 ...DEFAULT_SERVER_SETTINGS.providers.codex,202 config: {
203 binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe",203 binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe",
204 },
204 },205 },
205 },206 },
206 },207 },
...@@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
221 provider,222 provider,
222 {223 {
223 ...DEFAULT_SERVER_SETTINGS,224 ...DEFAULT_SERVER_SETTINGS,
224 providers: {225 providerInstances: {
225 ...DEFAULT_SERVER_SETTINGS.providers,226 [ProviderInstanceId.make("claude")]: {
226 claudeAgent: {227 driver: ProviderDriverKind.make("claudeAgent"),
227 ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent,228 config: {
228 binaryPath: "/opt/Claude Tools/$current/claude",229 binaryPath: "/opt/Claude Tools/$current/claude",
230 },
229 },231 },
230 },232 },
231 },233 },
...@@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
246 readyCodex,248 readyCodex,
247 {249 {
248 ...DEFAULT_SERVER_SETTINGS,250 ...DEFAULT_SERVER_SETTINGS,
249 providers: {251 providerInstances: {
250 ...DEFAULT_SERVER_SETTINGS.providers,252 [ProviderInstanceId.make("codex")]: {
251 codex: {253 driver: ProviderDriverKind.make("codex"),
252 ...DEFAULT_SERVER_SETTINGS.providers.codex,254 config: {
253 binaryPath,255 binaryPath,
256 },
254 },257 },
255 },258 },
256 },259 },
...@@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
265 readyCodex,268 readyCodex,
266 {269 {
267 ...DEFAULT_SERVER_SETTINGS,270 ...DEFAULT_SERVER_SETTINGS,
268 providers: {271 providerInstances: {
269 ...DEFAULT_SERVER_SETTINGS.providers,272 [ProviderInstanceId.make("codex")]: {
270 codex: {273 driver: ProviderDriverKind.make("codex"),
271 ...DEFAULT_SERVER_SETTINGS.providers.codex,274 config: {
272 binaryPath: "/opt/codex\\work/codex",275 binaryPath: "/opt/codex\\work/codex",
276 },
273 },277 },
274 },278 },
275 },279 },
...@@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
284 readyCodex,288 readyCodex,
285 {289 {
286 ...DEFAULT_SERVER_SETTINGS,290 ...DEFAULT_SERVER_SETTINGS,
287 providers: {291 providerInstances: {
288 ...DEFAULT_SERVER_SETTINGS.providers,292 [ProviderInstanceId.make("codex")]: {
289 codex: {293 driver: ProviderDriverKind.make("codex"),
290 ...DEFAULT_SERVER_SETTINGS.providers.codex,294 config: {
291 binaryPath: "C:\\Tools\\codex.exe",295 binaryPath: "C:\\Tools\\codex.exe",
296 },
292 },297 },
293 },298 },
294 },299 },
...@@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {...@@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => {
303 readyCodex,308 readyCodex,
304 {309 {
305 ...DEFAULT_SERVER_SETTINGS,310 ...DEFAULT_SERVER_SETTINGS,
306 providers: {311 providerInstances: {
307 ...DEFAULT_SERVER_SETTINGS.providers,312 [ProviderInstanceId.make("codex")]: {
308 codex: {313 driver: ProviderDriverKind.make("codex"),
309 ...DEFAULT_SERVER_SETTINGS.providers.codex,314 config: {
310 binaryPath: "/opt/Codex Tools/codex",315 binaryPath: "/opt/Codex Tools/codex",
316 },
311 },317 },
312 },318 },
313 },319 },
apps/web/src/onboarding/providerReadiness.logic.ts+2-6
...@@ -112,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand(...@@ -112,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand(
112 const instance = settings.providerInstances[provider.instanceId];112 const instance = settings.providerInstances[provider.instanceId];
113113
114 if (provider.driver === "claudeAgent") {114 if (provider.driver === "claudeAgent") {
115 const config = decodeClaudeSettings(115 const config = decodeClaudeSettings(instance?.config ?? {});
116 instance ? (instance.config ?? {}) : settings.providers.claudeAgent,
117 );
118 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude";116 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude";
119 return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`;117 return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`;
120 }118 }
121119
122 if (provider.driver === "codex") {120 if (provider.driver === "codex") {
123 const config = decodeCodexSettings(121 const config = decodeCodexSettings(instance?.config ?? {});
124 instance ? (instance.config ?? {}) : settings.providers.codex,
125 );
126 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex";122 const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex";
127 return `${quoteProviderBinary(binaryPath, "codex", platform)} login`;123 return `${quoteProviderBinary(binaryPath, "codex", platform)} login`;
128 }124 }
apps/web/src/providerInstances.test.ts+7-19
...@@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => {...@@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => {
143 enabled: false,143 enabled: false,
144 },144 },
145 },145 },
146 providers: {} as never,
147 });146 });
148147
149 expect(entry?.enabled).toBe(false);148 expect(entry?.enabled).toBe(false);
...@@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => {...@@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => {
158 ]);157 ]);
159 const [entry] = applyProviderInstanceSettings(entries, {158 const [entry] = applyProviderInstanceSettings(entries, {
160 providerInstances: {},159 providerInstances: {},
161 providers: {} as never,
162 });160 });
163161
164 expect(entry?.enabled).toBe(false);162 expect(entry?.enabled).toBe(false);
...@@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => {...@@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => {
175 ]);173 ]);
176 const [entry] = applyProviderInstanceSettings(entries, {174 const [entry] = applyProviderInstanceSettings(entries, {
177 providerInstances: {},175 providerInstances: {},
178 providers: {} as never,
179 });176 });
180177
181 expect(entry?.enabled).toBe(false);178 expect(entry?.enabled).toBe(false);
...@@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => {...@@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => {
197 enabled: false,194 enabled: false,
198 },195 },
199 },196 },
200 providers: {} as never,
201 });197 });
202198
203 expect(entry?.enabled).toBe(false);199 expect(entry?.enabled).toBe(false);
...@@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => {...@@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => {
213 ]);209 ]);
214 const [entry] = applyProviderInstanceSettings(entries, {210 const [entry] = applyProviderInstanceSettings(entries, {
215 providerInstances: {},211 providerInstances: {},
216 providers: {} as never,
217 });212 });
218213
219 expect(entry?.isDefault).toBe(true);214 expect(entry?.isDefault).toBe(true);
220 expect(entry?.enabled).toBe(false);215 expect(entry?.enabled).toBe(false);
221 });216 });
222217
223 it("uses legacy settings for a built-in default instance", () => {218 it("uses the driver default for an unconfigured built-in default instance", () => {
224 const entries = deriveProviderInstanceEntries([219 const entries = deriveProviderInstanceEntries([
225 provider({220 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),
226 provider: ProviderDriverKind.make("codex"),221 provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }),
227 instanceId: "codex",
228 }),
229 ]);222 ]);
230 const [entry] = applyProviderInstanceSettings(entries, {223 const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} });
231 providerInstances: {},
232 providers: { codex: { enabled: false } } as never,
233 });
234224
235 expect(entry?.enabled).toBe(false);225 // Settings decide over a stale probe: Codex starts on, Grok starts off.
226 expect(codex?.enabled).toBe(true);
227 expect(grok?.enabled).toBe(false);
236 });228 });
237});229});
238230
...@@ -261,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => {...@@ -261,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => {
261 agentId,253 agentId,
262 [snapshot],254 [snapshot],
263 {255 {
264 providers: {} as never,
265 providerInstances: {256 providerInstances: {
266 [instanceId]: {257 [instanceId]: {
267 driver: ProviderDriverKind.make("acpRegistry"),258 driver: ProviderDriverKind.make("acpRegistry"),
...@@ -629,7 +620,6 @@ describe("provider icon metadata", () => {...@@ -629,7 +620,6 @@ describe("provider icon metadata", () => {
629 const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };620 const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };
630 const brandedEntries = applyProviderInstanceSettings(snapshots, {621 const brandedEntries = applyProviderInstanceSettings(snapshots, {
631 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },622 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
632 providers: {} as never,
633 });623 });
634 expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");624 expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");
635 expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);625 expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);
...@@ -642,7 +632,6 @@ describe("provider icon metadata", () => {...@@ -642,7 +632,6 @@ describe("provider icon metadata", () => {
642 config: { ...registryConfig, source: "local", commandPath: "dsh" },632 config: { ...registryConfig, source: "local", commandPath: "dsh" },
643 },633 },
644 },634 },
645 providers: {} as never,
646 });635 });
647 expect(localEntry?.acpRegistryAgentId).toBeUndefined();636 expect(localEntry?.acpRegistryAgentId).toBeUndefined();
648 expect(localEntry?.acpRegistryIconUrl).toBeUndefined();637 expect(localEntry?.acpRegistryIconUrl).toBeUndefined();
...@@ -651,7 +640,6 @@ describe("provider icon metadata", () => {...@@ -651,7 +640,6 @@ describe("provider icon metadata", () => {
651640
652 const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], {641 const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], {
653 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },642 providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
654 providers: {} as never,
655 });643 });
656 expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");644 expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");
657 expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);645 expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);
apps/web/src/providerInstances.ts+9-19
...@@ -15,6 +15,7 @@...@@ -15,6 +15,7 @@
15import {15import {
16 DEFAULT_MODEL_BY_PROVIDER,16 DEFAULT_MODEL_BY_PROVIDER,
17 defaultInstanceIdForDriver,17 defaultInstanceIdForDriver,
18 isUnconfiguredDefaultInstanceEnabled,
18 resolveProviderInstanceEnabled,19 resolveProviderInstanceEnabled,
19 type ModelSelection,20 type ModelSelection,
20 type ProviderDriverKind,21 type ProviderDriverKind,
...@@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries(...@@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries(
153 */154 */
154export function deriveProviderEntriesByEnvironment(155export function deriveProviderEntriesByEnvironment(
155 providersByEnvironment: Iterable<156 providersByEnvironment: Iterable<
156 readonly [157 readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?]
157 string,
158 ReadonlyArray<ServerProvider>,
159 Pick<ServerSettings, "providerInstances" | "providers">?,
160 ]
161 >,158 >,
162): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {159): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {
163 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();160 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
...@@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment(...@@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment(
178 * settings write, so picker visibility must follow settings rather than waiting175 * settings write, so picker visibility must follow settings rather than waiting
179 * for probe reconciliation.176 * for probe reconciliation.
180 *177 *
181 * Only built-in default instances have a legacy `providers` entry. Every178 * A built-in default instance without a `providerInstances` entry keeps its
182 * other instance exists through `providerInstances`; if it is absent there,179 * streamed state, since the server runs it with default config. Any other
183 * its streamed snapshot is stale (for example immediately after deletion)180 * instance missing from `providerInstances` is stale (for example
184 * and is treated as disabled.181 * immediately after deletion) and is treated as disabled.
185 */182 */
186export function applyProviderInstanceSettings(183export function applyProviderInstanceSettings(
187 entries: ReadonlyArray<ProviderInstanceEntry>,184 entries: ReadonlyArray<ProviderInstanceEntry>,
188 settings: Pick<ServerSettings, "providerInstances" | "providers">,185 settings: Pick<ServerSettings, "providerInstances">,
189): ReadonlyArray<ProviderInstanceEntry> {186): ReadonlyArray<ProviderInstanceEntry> {
190 const legacyProviders = settings.providers as Readonly<
191 Record<string, { readonly enabled?: boolean } | undefined>
192 >;
193
194 return entries.map((entry) => {187 return entries.map((entry) => {
195 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)188 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)
196 ? settings.providerInstances[entry.instanceId]189 ? settings.providerInstances[entry.instanceId]
197 : undefined;190 : undefined;
198 const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind)
199 ? legacyProviders[entry.driverKind]
200 : undefined;
201 const enabled = explicitInstance191 const enabled = explicitInstance
202 ? resolveProviderInstanceEnabled(explicitInstance)192 ? resolveProviderInstanceEnabled(explicitInstance)
203 : entry.isDefault && legacyProvider193 : entry.isDefault
204 ? (legacyProvider.enabled ?? entry.enabled)194 ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId)
205 : false;195 : false;
206 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {196 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {
207 return enabled === entry.enabled ? entry : { ...entry, enabled };197 return enabled === entry.enabled ? entry : { ...entry, enabled };
docs/internals/adding-a-provider.md+3-2
...@@ -103,8 +103,9 @@ produce catches them....@@ -103,8 +103,9 @@ produce catches them.
103103
104## Where a driver plugs in104## Where a driver plugs in
105105
106- **Contracts:** settings schema and patch, default model, and display name in106- **Contracts:** settings schema, default model, and display name in
107 [`packages/contracts`](../../packages/contracts/src). New providers are off by default.107 [`packages/contracts`](../../packages/contracts/src). Instances store that config in
108 `providerInstances`. New providers are off by default.
108- **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry109- **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry
109 in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in110 in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in
110 the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy111 the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy
packages/contracts/src/settings.test.ts+7-100
...@@ -208,17 +208,6 @@ describe("custom model settings", () => {...@@ -208,17 +208,6 @@ describe("custom model settings", () => {
208 { slug: "named", name: "Named", capabilities },208 { slug: "named", name: "Named", capabilities },
209 ]);209 ]);
210 });210 });
211
212 it("accepts entries at the settings patch boundary", () => {
213 expect(
214 decodeServerSettingsPatch({
215 providers: { codex: { customModels: [{ slug: "x", capabilities }] } },
216 }).providers?.codex?.customModels,
217 ).toEqual([{ slug: "x", capabilities }]);
218 expect(() =>
219 decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }),
220 ).toThrow();
221 });
222});211});
223212
224describe("ClaudeSettings auto-compaction", () => {213describe("ClaudeSettings auto-compaction", () => {
...@@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => {...@@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => {
239 expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow();228 expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow();
240 },229 },
241 );230 );
242
243 it("rejects an unsupported threshold at the settings patch boundary", () => {
244 expect(() =>
245 decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }),
246 ).toThrow();
247 expect(
248 decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }),
249 ).toBeDefined();
250 });
251});231});
252232
253describe("ClientSettings notifications", () => {233describe("ClientSettings notifications", () => {
...@@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {...@@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
752 it("decodes a fully empty config (legacy on-disk shape) without complaint", () => {732 it("decodes a fully empty config (legacy on-disk shape) without complaint", () => {
753 const decoded = decodeServerSettings({});733 const decoded = decodeServerSettings({});
754 expect(decoded.providerInstances).toEqual({});734 expect(decoded.providerInstances).toEqual({});
755 // Legacy `providers` struct is still hydrated with its per-driver defaults
756 // so existing call sites keep working through the migration.
757 expect(decoded.providers.codex.enabled).toBe(true);
758 });735 });
759736
760 it("decodes a multi-instance map mixing first-party and fork drivers", () => {737 it("decodes a multi-instance map mixing first-party and fork drivers", () => {
...@@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {...@@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
803describe("provider enabled defaults", () => {780describe("provider enabled defaults", () => {
804 it("keeps Muse disabled until a configured instance opts in", () => {781 it("keeps Muse disabled until a configured instance opts in", () => {
805 const muse = ProviderDriverKind.make("muse");782 const muse = ProviderDriverKind.make("muse");
806 expect(decodeServerSettings({}).providers.muse.enabled).toBe(false);
807 expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false);783 expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false);
808 expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true);784 expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true);
809 expect(785 expect(
...@@ -812,26 +788,13 @@ describe("provider enabled defaults", () => {...@@ -812,26 +788,13 @@ describe("provider enabled defaults", () => {
812 });788 });
813789
814 it("enables only the stable bindings by default", () => {790 it("enables only the stable bindings by default", () => {
815 const decoded = decodeServerSettings({});791 const enabledByDefault = (driver: string) =>
816 expect(decoded.providers.codex.enabled).toBe(true);792 resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} });
817 expect(decoded.providers.claudeAgent.enabled).toBe(true);793 expect(enabledByDefault("codex")).toBe(true);
818 expect(decoded.providers.cursor.enabled).toBe(false);794 expect(enabledByDefault("claudeAgent")).toBe(true);
819 expect(decoded.providers.grok.enabled).toBe(false);795 for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) {
820 expect(decoded.providers.opencode.enabled).toBe(false);796 expect(enabledByDefault(driver)).toBe(false);
821 });797 }
822
823 it("keeps Cursor enabled when an existing user explicitly opted in", () => {
824 const cursor = ProviderDriverKind.make("cursor");
825 const cursorId = ProviderInstanceId.make("cursor");
826 const decoded = decodeServerSettings({
827 providers: { cursor: { enabled: true } },
828 providerInstances: {
829 [cursorId]: { driver: cursor, enabled: true, config: {} },
830 },
831 });
832
833 expect(decoded.providers.cursor.enabled).toBe(true);
834 expect(resolveProviderInstanceEnabled(decoded.providerInstances[cursorId]!)).toBe(true);
835 });798 });
836799
837 it("resolves instance enabled state with explicit false winning", () => {800 it("resolves instance enabled state with explicit false winning", () => {
...@@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => {...@@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => {
903 });866 });
904});867});
905868
906describe("ServerSettings Cursor legacy settings", () => {
907 it("preserves V1 Cursor CLI settings when reading and writing shared settings", () => {
908 const decoded = decodeServerSettings({
909 providers: {
910 cursor: {
911 enabled: true,
912 binaryPath: "cursor-agent",
913 apiEndpoint: "http://127.0.0.1:3774",
914 },
915 },
916 });
917
918 expect(decoded.providers.cursor.enabled).toBe(true);
919 expect(encodeServerSettings(decoded).providers?.cursor).toMatchObject({
920 binaryPath: "cursor-agent",
921 apiEndpoint: "http://127.0.0.1:3774",
922 });
923 });
924
925 it("ignores obsolete Cursor CLI settings in patches", () => {
926 const patch = decodeServerSettingsPatch({
927 providers: {
928 cursor: {
929 enabled: true,
930 binaryPath: "cursor-agent",
931 apiEndpoint: "http://127.0.0.1:3774",
932 },
933 },
934 });
935
936 expect(patch.providers?.cursor?.enabled).toBe(true);
937 expect(patch.providers?.cursor).not.toHaveProperty("binaryPath");
938 expect(patch.providers?.cursor).not.toHaveProperty("apiEndpoint");
939 });
940});
941
942describe("ServerSettings.sourceControlWritingStyle", () => {869describe("ServerSettings.sourceControlWritingStyle", () => {
943 it("defaults all style settings for legacy configs", () => {870 it("defaults all style settings for legacy configs", () => {
944 const settings = decodeServerSettings({});871 const settings = decodeServerSettings({});
...@@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => {
1011 observability: {938 observability: {
1012 otlpTracesUrl: " http://localhost:4318/v1/traces ",939 otlpTracesUrl: " http://localhost:4318/v1/traces ",
1013 },940 },
1014 providers: {
1015 codex: {
1016 binaryPath: " /opt/homebrew/bin/codex ",
1017 homePath: " ~/.codex ",
1018 launchArgs: " --strict-config --enable foo ",
1019 },
1020 },
1021 providerInstances: {941 providerInstances: {
1022 codex_personal: {942 codex_personal: {
1023 driver: " codex ",943 driver: " codex ",
...@@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => {
1030 expect(patch.addProjectBaseDirectory).toBe("~/Development");950 expect(patch.addProjectBaseDirectory).toBe("~/Development");
1031 expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini");951 expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini");
1032 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");952 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
1033 expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
1034 expect(patch.providers?.codex?.homePath).toBe("~/.codex");
1035 expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo");
1036 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(953 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
1037 "codex",954 "codex",
1038 );955 );
...@@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => {...@@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => {
1049 const encoded = encodeServerSettings({966 const encoded = encodeServerSettings({
1050 ...defaultSettings,967 ...defaultSettings,
1051 addProjectBaseDirectory: " ~/Development ",968 addProjectBaseDirectory: " ~/Development ",
1052 providers: {
1053 ...defaultSettings.providers,
1054 codex: {
1055 ...defaultSettings.providers.codex,
1056 binaryPath: " /opt/homebrew/bin/codex ",
1057 launchArgs: " --strict-config ",
1058 },
1059 },
1060 });969 });
1061970
1062 expect(encoded.addProjectBaseDirectory).toBe("~/Development");971 expect(encoded.addProjectBaseDirectory).toBe("~/Development");
1063 expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex");
1064 expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config");
1065 });972 });
1066});973});
1067974
packages/contracts/src/settings.ts+30-106
...@@ -40,7 +40,7 @@ import {...@@ -40,7 +40,7 @@ import {
40import {40import {
41 ProviderInstanceConfig,41 ProviderInstanceConfig,
42 ProviderInstanceId,42 ProviderInstanceId,
43 type ProviderDriverKind,43 ProviderDriverKind,
44} from "./providerInstance.ts";44} from "./providerInstance.ts";
45import { PullRequestMergeMethod } from "./pullRequest.ts";45import { PullRequestMergeMethod } from "./pullRequest.ts";
4646
...@@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({...@@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({
1457 Schema.withDecodingDefault(Effect.succeed(null)),1457 Schema.withDecodingDefault(Effect.succeed(null)),
1458 ),1458 ),
14591459
1460 // Legacy single-instance-per-driver settings. Continues to be the source
1461 // of truth until `providerInstances` (below) lands per-driver migration
1462 // shims and the server starts hydrating instances from it. Driver-specific
1463 // schemas live here for the duration of the migration; once each driver
1464 // owns its config in its own package, this struct shrinks to nothing and
1465 // is removed entirely.
1466 providers: Schema.Struct({
1467 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1468 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1469 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1470 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1471 muse: MuseSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1472 pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1473 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1474 antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1475 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1476 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values1460 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
1477 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob1461 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob
1478 // is `Schema.Unknown` at this layer so envelopes with unknown drivers1462 // is `Schema.Unknown` at this layer so envelopes with unknown drivers
...@@ -1512,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS...@@ -1512,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS
1512/**1496/**
1513 * Read the legacy `enabled` flag embedded in a provider instance config1497 * Read the legacy `enabled` flag embedded in a provider instance config
1514 * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single1498 * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single
1515 * flag going forward; this reader exists for legacy `providers.<kind>`1499 * flag going forward; this reader exists for old settings files that still
1516 * blobs and old settings files that still carry the flag in-config.1500 * carry the flag in-config.
1517 */1501 */
1518export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => {1502export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => {
1519 if (config === null || typeof config !== "object" || Array.isArray(config)) {1503 if (config === null || typeof config !== "object" || Array.isArray(config)) {
...@@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un...@@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un
1524};1508};
15251509
1526/**1510/**
1527 * Default enabled state for a built-in driver when neither the envelope nor1511 * Built-in drivers that stay off until the user turns them on. Matches the
1528 * the config blob carries a flag. Derived from the driver's settings schema1512 * `enabled` decoding default of each driver's settings schema.
1529 * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays
1530 * the single source of truth. Unknown (fork) drivers default to enabled.
1531 */1513 */
1532const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => {1514const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set(
1533 const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record<1515 ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) =>
1534 string,1516 ProviderDriverKind.make(driver),
1535 { readonly enabled?: boolean } | undefined1517 ),
1536 >;1518);
1537 return legacyDefaults[driver]?.enabled ?? true;1519
1538};1520/** Built-in drivers whose default instance runs before the user configures it. */
1521const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet<ProviderInstanceId> = new Set(
1522 ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)),
1523);
1524
1525/**
1526 * Whether the built-in default instance at `instanceId` is enabled while
1527 * settings have no `providerInstances` entry for it. Only Codex and Claude
1528 * start on; any other id without an entry has no running instance.
1529 */
1530export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean =>
1531 DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId);
1532
1533/**
1534 * Default enabled state for a driver when neither the envelope nor the config
1535 * blob carries a flag. Unknown (fork) drivers default to enabled.
1536 */
1537const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean =>
1538 !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver);
15391539
1540/**1540/**
1541 * Resolve whether a configured provider instance is enabled. An explicit1541 * Resolve whether a configured provider instance is enabled. An explicit
...@@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({...@@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({
1608 options: Schema.optionalKey(ProviderOptionSelections),1608 options: Schema.optionalKey(ProviderOptionSelections),
1609});1609});
16101610
1611const CodexSettingsPatch = Schema.Struct({
1612 enabled: Schema.optionalKey(Schema.Boolean),
1613 binaryPath: Schema.optionalKey(TrimmedString),
1614 homePath: Schema.optionalKey(TrimmedString),
1615 shadowHomePath: Schema.optionalKey(TrimmedString),
1616 launchArgs: Schema.optionalKey(TrimmedString),
1617 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1618});
1619
1620const ClaudeSettingsPatch = Schema.Struct({
1621 enabled: Schema.optionalKey(Schema.Boolean),
1622 binaryPath: Schema.optionalKey(TrimmedString),
1623 homePath: Schema.optionalKey(TrimmedString),
1624 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1625 launchArgs: Schema.optionalKey(TrimmedString),
1626 // Validated at the patch boundary so a typo fails the one update with a
1627 // schema error instead of a generic whole-settings failure.
1628 autoCompactWindow: Schema.optionalKey(
1629 TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)),
1630 ),
1631});
1632
1633const CursorSettingsPatch = Schema.Struct({
1634 enabled: Schema.optionalKey(Schema.Boolean),
1635 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1636});
1637
1638const GrokSettingsPatch = Schema.Struct({
1639 enabled: Schema.optionalKey(Schema.Boolean),
1640 binaryPath: Schema.optionalKey(TrimmedString),
1641 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1642});
1643
1644const AntigravitySettingsPatch = Schema.Struct({
1645 enabled: Schema.optionalKey(Schema.Boolean),
1646 authMethod: Schema.optionalKey(AntigravityAuthMethod),
1647 apiKey: Schema.optionalKey(TrimmedString),
1648 gcpProject: Schema.optionalKey(TrimmedString),
1649 gcpLocation: Schema.optionalKey(TrimmedString),
1650 binaryPath: Schema.optionalKey(TrimmedString),
1651 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1652});
1653
1654const MuseSettingsPatch = Schema.Struct({
1655 enabled: Schema.optionalKey(Schema.Boolean),
1656 binaryPath: Schema.optionalKey(TrimmedString),
1657 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1658});
1659
1660const PiSettingsPatch = Schema.Struct({
1661 enabled: Schema.optionalKey(Schema.Boolean),
1662 binaryPath: Schema.optionalKey(TrimmedString),
1663 launchArgs: Schema.optionalKey(TrimmedString),
1664 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1665});
1666
1667const OpenCodeSettingsPatch = Schema.Struct({
1668 enabled: Schema.optionalKey(Schema.Boolean),
1669 binaryPath: Schema.optionalKey(TrimmedString),
1670 serverUrl: Schema.optionalKey(TrimmedString),
1671 serverPassword: Schema.optionalKey(TrimmedString),
1672 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1673});
1674
1675export const ServerSettingsPatch = Schema.Struct({1611export const ServerSettingsPatch = Schema.Struct({
1676 worktreeCleanup: Schema.optionalKey(1612 worktreeCleanup: Schema.optionalKey(
1677 Schema.NullOr(1613 Schema.NullOr(
...@@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({
1791 tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)),1727 tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)),
1792 }),1728 }),
1793 ),1729 ),
1794 providers: Schema.optionalKey(
1795 Schema.Struct({
1796 codex: Schema.optionalKey(CodexSettingsPatch),
1797 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
1798 cursor: Schema.optionalKey(CursorSettingsPatch),
1799 grok: Schema.optionalKey(GrokSettingsPatch),
1800 muse: Schema.optionalKey(MuseSettingsPatch),
1801 pi: Schema.optionalKey(PiSettingsPatch),
1802 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
1803 antigravity: Schema.optionalKey(AntigravitySettingsPatch),
1804 }),
1805 ),
1806 // Whole-map replacement for the new instance config. Patching individual1730 // Whole-map replacement for the new instance config. Patching individual
1807 // entries is intentionally out of scope: the map is small, and partial1731 // entries is intentionally out of scope: the map is small, and partial
1808 // patches risk leaving driver-specific config in a half-merged state.1732 // patches risk leaving driver-specific config in a half-merged state.
packages/provider-core/src/server/driver.ts+8-5
...@@ -58,6 +58,12 @@ export interface ProviderDriverMetadata {...@@ -58,6 +58,12 @@ export interface ProviderDriverMetadata {
58 * rejects multi-instance configurations with a clear error.58 * rejects multi-instance configurations with a clear error.
59 */59 */
60 readonly supportsMultipleInstances?: boolean;60 readonly supportsMultipleInstances?: boolean;
61 /**
62 * Whether the server runs an instance at `defaultInstanceIdForDriver(kind)`
63 * even when settings have no entry for it. Defaults to `true`; drivers that
64 * need user input before they can run (such as the ACP Registry) opt out.
65 */
66 readonly hasDefaultInstance?: boolean;
61}67}
6268
63export type ProviderWorkspaceSnapshot = ServerProvider &69export type ProviderWorkspaceSnapshot = ServerProvider &
...@@ -188,11 +194,8 @@ export interface ProviderDriver<Config, R = never> {...@@ -188,11 +194,8 @@ export interface ProviderDriver<Config, R = never> {
188 */194 */
189 readonly configSchema: Schema.Codec<Config, unknown>;195 readonly configSchema: Schema.Codec<Config, unknown>;
190 /**196 /**
191 * Default config payload used when the legacy197 * Config used when an instance has no config blob, such as the default
192 * `ServerSettings.providers.<kind>` entry is empty or when the driver198 * instance of a driver the user never configured.
193 * is auto-bootstrapped without user configuration. Returning a typed
194 * default keeps the migration path simple — no special-casing needed
195 * to construct a "blank" instance.
196 */199 */
197 readonly defaultConfig: () => Config;200 readonly defaultConfig: () => Config;
198 /**201 /**
packages/shared/src/projectSettings.test.ts+13-2
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 PROJECT_SCOPED_SERVER_SETTING_KEYS,3 PROJECT_SCOPED_SERVER_SETTING_KEYS,
4 ProjectId,4 ProjectId,
5 ProviderDriverKind,
5 ProviderInstanceId,6 ProviderInstanceId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
...@@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => {...@@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => {
78 it("keeps the environment text generation model when the override's provider is disabled", () => {79 it("keeps the environment text generation model when the override's provider is disabled", () => {
79 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");80 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
80 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {81 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
81 providers: { claudeAgent: { enabled: false } },82 providerInstances: {
83 [ProviderInstanceId.make("claudeAgent")]: {
84 driver: ProviderDriverKind.make("claudeAgent"),
85 enabled: false,
86 },
87 },
82 projectSettingsOverrides: {88 projectSettingsOverrides: {
83 [projectId]: { textGenerationModelSelection: disabledSelection },89 [projectId]: { textGenerationModelSelection: disabledSelection },
84 },90 },
...@@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => {...@@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => {
128 it("keeps the environment default model when the override's provider is disabled", () => {134 it("keeps the environment default model when the override's provider is disabled", () => {
129 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");135 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
130 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {136 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
131 providers: { claudeAgent: { enabled: false } },137 providerInstances: {
138 [ProviderInstanceId.make("claudeAgent")]: {
139 driver: ProviderDriverKind.make("claudeAgent"),
140 enabled: false,
141 },
142 },
132 projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } },143 projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } },
133 });144 });
134 const resolved = resolveProjectSettings(settings, projectId);145 const resolved = resolveProjectSettings(settings, projectId);
packages/shared/src/serverSettings.ts+2-14
...@@ -1,13 +1,12 @@...@@ -1,13 +1,12 @@
1import {1import {
2 isProviderDriverKind,
3 isProviderAvailable,2 isProviderAvailable,
3 isUnconfiguredDefaultInstanceEnabled,
4 resolveProviderInstanceEnabled,4 resolveProviderInstanceEnabled,
5 isProviderTextGenerationCapable,5 isProviderTextGenerationCapable,
6 type ModelSelection,6 type ModelSelection,
7 type ProjectId,7 type ProjectId,
8 type ProjectScopedServerSettingKey,8 type ProjectScopedServerSettingKey,
9 type ProjectSettingsOverrides,9 type ProjectSettingsOverrides,
10 type ProviderDriverKind,
11 type ServerProvider,10 type ServerProvider,
12 ServerSettings,11 ServerSettings,
13 type ServerSettingsPatch,12 type ServerSettingsPatch,
...@@ -59,14 +58,6 @@ export function resolveProjectAutoPull(...@@ -59,14 +58,6 @@ export function resolveProjectAutoPull(
59 );58 );
60}59}
6160
62type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
63
64const getLegacyProviderSettings = (
65 settings: ServerSettings,
66 provider: ProviderDriverKind,
67): LegacyProviderSettings | undefined =>
68 (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider];
69
70export function isModelSelectionProviderEnabled(61export function isModelSelectionProviderEnabled(
71 settings: ServerSettings,62 settings: ServerSettings,
72 selection: ModelSelection,63 selection: ModelSelection,
...@@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled(...@@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled(
76 return resolveProviderInstanceEnabled(instanceConfig);67 return resolveProviderInstanceEnabled(instanceConfig);
77 }68 }
7869
79 return (70 return isUnconfiguredDefaultInstanceEnabled(selection.instanceId);
80 isProviderDriverKind(selection.instanceId) &&
81 getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true
82 );
83}71}
8472
85export function resolveSourceControlWriterModelSelection(73export function resolveSourceControlWriterModelSelection(