import * as Effect from "effect/Effect";import * as Duration from "effect/Duration";import * as Schema from "effect/Schema";import * as SchemaTransformation from "effect/SchemaTransformation";import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts";import { ModelSelection } from "./orchestration.ts";import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; // ── Client Settings (local-only) ─────────────────────────────── export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);export type TimestampFormat = typeof TimestampFormat.Type;export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", "separate",]);export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1;export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15;export const SidebarThreadPreviewCount = Schema.Int.check( Schema.isBetween({ minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, }),);export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type;export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without // the UI collapsing it into the same bucket as the default Codex. The // widening is backward-compatible by construction: prior provider-kind // strings satisfy the `ProviderInstanceId` slug schema, so previously // persisted favorites decode unchanged and continue to point at the // default instance for their kind (because `defaultInstanceIdForDriver(kind)` // uses the same slug). The field name is kept as `provider` for storage // stability; new call sites should treat the value as an instance id. favorites: Schema.Array( Schema.Struct({ provider: ProviderInstanceId, model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), providerModelPreferences: Schema.Record( ProviderInstanceId, Schema.Struct({ hiddenModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), sidebarProjectGroupingOverrides: Schema.Record( TrimmedNonEmptyString, SidebarProjectGroupingMode, ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ),});export type ClientSettings = typeof ClientSettingsSchema.Type; export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); // ── Server Settings (server-authoritative) ──────────────────── export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);export type ThreadEnvMode = typeof ThreadEnvMode.Type; const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( Schema.String, SchemaTransformation.transformOrFail({ decode: (value) => Effect.succeed(value || fallback), encode: (value) => Effect.succeed(value), }), ), Schema.withDecodingDefault(Effect.succeed(fallback)), ); export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; export interface ProviderSettingsFormAnnotation { readonly control?: ProviderSettingsFormControl | undefined; readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined;} export interface ProviderSettingsFormSchemaAnnotation { readonly order?: readonly string[] | undefined;} declare module "effect/Schema" { namespace Annotations { interface Annotations { readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined; readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined; } }} export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< keyof Fields, string>[]; export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>( fields: Fields, options?: { readonly order?: ProviderSettingsOrder<Fields> | undefined; },): Schema.Struct<Fields> { return Schema.Struct(fields).pipe( Schema.annotate({ providerSettingsFormSchema: options?.order === undefined ? undefined : { order: options.order }, }), );} export const CodexSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("codex").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Codex binary used by this instance.", providerSettingsForm: { placeholder: "codex", clearWhenEmpty: "omit" }, }), ), homePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "CODEX_HOME path", description: "Custom Codex home and config directory.", providerSettingsForm: { placeholder: "~/.codex", clearWhenEmpty: "omit", }, }), ), shadowHomePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Shadow home path", description: "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.", providerSettingsForm: { placeholder: "~/.codex-t3/personal", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "homePath", "shadowHomePath"], },);export type CodexSettings = typeof CodexSettings.Type; export const ClaudeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("claude").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Claude binary used by this instance.", providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" }, }), ), homePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Claude HOME path", description: "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.", providerSettingsForm: { placeholder: "~", clearWhenEmpty: "omit" }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), launchArgs: Schema.String.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Launch arguments", description: "Additional CLI arguments passed on session start.", providerSettingsForm: { placeholder: "e.g. --chrome", clearWhenEmpty: "omit", }, }), ), }, { order: ["binaryPath", "homePath", "launchArgs"], },);export type ClaudeSettings = typeof ClaudeSettings.Type; export const CursorSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("agent").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Cursor agent binary.", providerSettingsForm: { placeholder: "agent", clearWhenEmpty: "omit" }, }), ), apiEndpoint: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "API endpoint", description: "Override the Cursor API endpoint for this instance.", providerSettingsForm: { placeholder: "https://...", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "apiEndpoint"], },);export type CursorSettings = typeof CursorSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("opencode").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the OpenCode binary.", providerSettingsForm: { placeholder: "opencode", clearWhenEmpty: "omit", }, }), ), serverUrl: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Server URL", description: "Leave blank to let T3 Code spawn the server when needed.", providerSettingsForm: { placeholder: "http://127.0.0.1:4096", clearWhenEmpty: "omit", }, }), ), serverPassword: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Server password", description: "Stored in plain text on disk.", providerSettingsForm: { control: "password", placeholder: "Optional", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "serverUrl", "serverPassword"], },);export type OpenCodeSettings = typeof OpenCodeSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),});export type ObservabilitySettings = typeof ObservabilitySettings.Type; export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_GIT_TEXT_GENERATION_MODEL, }), ), ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration // shims and the server starts hydrating instances from it. Driver-specific // schemas live here for the duration of the migration; once each driver // owns its config in its own package, this struct shrinks to nothing and // is removed entirely. providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob // is `Schema.Unknown` at this layer so envelopes with unknown drivers // (forks, downgrades, in-flight PR branches) round-trip without loss. // See providerInstance.ts for the forward/backward compatibility invariant. providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),});export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()( "ServerSettingsError", { settingsPath: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), },) { override get message(): string { return `Server settings error at ${this.settingsPath}: ${this.detail}`; }} // ── Unified type ───────────────────────────────────────────────────── export type UnifiedSettings = ServerSettings & ClientSettings;export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS,}; // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── const ModelSelectionPatch = Schema.Struct({ instanceId: Schema.optionalKey(ProviderInstanceId), model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ProviderOptionSelections),}); const CodexSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)),}); const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString),}); const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), apiEndpoint: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)),}); const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), serverUrl: Schema.optionalKey(TrimmedString), serverPassword: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)),}); export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), otlpMetricsUrl: Schema.optionalKey(TrimmedString), }), ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)),});export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ provider: ProviderInstanceId, model: TrimmedNonEmptyString, }), ), ), providerModelPreferences: Schema.optionalKey( Schema.Record( ProviderInstanceId, Schema.Struct({ hiddenModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), modelOrder: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ),Lines 1–500 of 513. Load more
import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; // ── Client Settings (local-only) ─────────────────────────────── export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", "separate", ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; export const SidebarThreadPreviewCount = Schema.Int.check( Schema.isBetween({ minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, }), ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without // the UI collapsing it into the same bucket as the default Codex. The // widening is backward-compatible by construction: prior provider-kind // strings satisfy the `ProviderInstanceId` slug schema, so previously // persisted favorites decode unchanged and continue to point at the // default instance for their kind (because `defaultInstanceIdForDriver(kind)` // uses the same slug). The field name is kept as `provider` for storage // stability; new call sites should treat the value as an instance id. favorites: Schema.Array( Schema.Struct({ provider: ProviderInstanceId, model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), providerModelPreferences: Schema.Record( ProviderInstanceId, Schema.Struct({ hiddenModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), sidebarProjectGroupingOverrides: Schema.Record( TrimmedNonEmptyString, SidebarProjectGroupingMode, ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), }); export type ClientSettings = typeof ClientSettingsSchema.Type; export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); // ── Server Settings (server-authoritative) ──────────────────── export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( Schema.String, SchemaTransformation.transformOrFail({ decode: (value) => Effect.succeed(value || fallback), encode: (value) => Effect.succeed(value), }), ), Schema.withDecodingDefault(Effect.succeed(fallback)), ); export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; export interface ProviderSettingsFormAnnotation { readonly control?: ProviderSettingsFormControl | undefined; readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; } export interface ProviderSettingsFormSchemaAnnotation { readonly order?: readonly string[] | undefined; } declare module "effect/Schema" { namespace Annotations { interface Annotations { readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined; readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined; } } } export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< keyof Fields, string >[]; export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>( fields: Fields, options?: { readonly order?: ProviderSettingsOrder<Fields> | undefined; }, ): Schema.Struct<Fields> { return Schema.Struct(fields).pipe( Schema.annotate({ providerSettingsFormSchema: options?.order === undefined ? undefined : { order: options.order }, }), ); } export const CodexSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("codex").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Codex binary used by this instance.", providerSettingsForm: { placeholder: "codex", clearWhenEmpty: "omit" }, }), ), homePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "CODEX_HOME path", description: "Custom Codex home and config directory.", providerSettingsForm: { placeholder: "~/.codex", clearWhenEmpty: "omit", }, }), ), shadowHomePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Shadow home path", description: "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.", providerSettingsForm: { placeholder: "~/.codex-t3/personal", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "homePath", "shadowHomePath"], }, ); export type CodexSettings = typeof CodexSettings.Type; export const ClaudeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("claude").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Claude binary used by this instance.", providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" }, }), ), homePath: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Claude HOME path", description: "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.", providerSettingsForm: { placeholder: "~", clearWhenEmpty: "omit" }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), launchArgs: Schema.String.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Launch arguments", description: "Additional CLI arguments passed on session start.", providerSettingsForm: { placeholder: "e.g. --chrome", clearWhenEmpty: "omit", }, }), ), }, { order: ["binaryPath", "homePath", "launchArgs"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; export const CursorSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("agent").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the Cursor agent binary.", providerSettingsForm: { placeholder: "agent", clearWhenEmpty: "omit" }, }), ), apiEndpoint: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "API endpoint", description: "Override the Cursor API endpoint for this instance.", providerSettingsForm: { placeholder: "https://...", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "apiEndpoint"], }, ); export type CursorSettings = typeof CursorSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("opencode").pipe( Schema.annotateKey({ title: "Binary path", description: "Path to the OpenCode binary.", providerSettingsForm: { placeholder: "opencode", clearWhenEmpty: "omit", }, }), ), serverUrl: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Server URL", description: "Leave blank to let T3 Code spawn the server when needed.", providerSettingsForm: { placeholder: "http://127.0.0.1:4096", clearWhenEmpty: "omit", }, }), ), serverPassword: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Server password", description: "Stored in plain text on disk.", providerSettingsForm: { control: "password", placeholder: "Optional", clearWhenEmpty: "omit", }, }), ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { order: ["binaryPath", "serverUrl", "serverPassword"], }, ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), ), ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_GIT_TEXT_GENERATION_MODEL, }), ), ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration // shims and the server starts hydrating instances from it. Driver-specific // schemas live here for the duration of the migration; once each driver // owns its config in its own package, this struct shrinks to nothing and // is removed entirely. providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob // is `Schema.Unknown` at this layer so envelopes with unknown drivers // (forks, downgrades, in-flight PR branches) round-trip without loss. // See providerInstance.ts for the forward/backward compatibility invariant. providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsError>()( "ServerSettingsError", { settingsPath: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { return `Server settings error at ${this.settingsPath}: ${this.detail}`; } } // ── Unified type ───────────────────────────────────────────────────── export type UnifiedSettings = ServerSettings & ClientSettings; export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, }; // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── const ModelSelectionPatch = Schema.Struct({ instanceId: Schema.optionalKey(ProviderInstanceId), model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ProviderOptionSelections), }); const CodexSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString), }); const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), apiEndpoint: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), serverUrl: Schema.optionalKey(TrimmedString), serverPassword: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), otlpMetricsUrl: Schema.optionalKey(TrimmedString), }), ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ provider: ProviderInstanceId, model: TrimmedNonEmptyString, }), ), ), providerModelPreferences: Schema.optionalKey( Schema.Record( ProviderInstanceId, Schema.Struct({ hiddenModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), modelOrder: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), ),