| 1 | import { SshDeviceHostConfigs } from "./device.ts"; |
| 2 | import { |
| 3 | AuthOrchestrationOperateScope, |
| 4 | AuthSettingsWriteScope, |
| 5 | AuthProvidersManageScope, |
| 6 | type AuthEnvironmentScope, |
| 7 | } from "./auth.ts"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Duration from "effect/Duration"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 12 | import { |
| 13 | ForwardCompatibleNullable, |
| 14 | ForwardCompatibleOptional, |
| 15 | OmittedWhenNull, |
| 16 | ProjectId, |
| 17 | ThreadId, |
| 18 | TrimmedNonEmptyString, |
| 19 | TrimmedString, |
| 20 | } from "./baseSchemas.ts"; |
| 21 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; |
| 22 | import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; |
| 23 | import { KeybindingShortcut } from "./keybindings.ts"; |
| 24 | import { |
| 25 | CustomModelSetting, |
| 26 | DEFAULT_TEXT_GENERATION_MODEL, |
| 27 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 28 | ProviderOptionSelections, |
| 29 | } from "./model.ts"; |
| 30 | import { ModelSelection } from "./modelSelection.ts"; |
| 31 | import { ProjectScript, type ProjectMutation } from "./project.ts"; |
| 32 | import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; |
| 33 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; |
| 34 | import { |
| 35 | DEFAULT_PREVIEW_APPEARANCE, |
| 36 | DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 37 | FILL_PREVIEW_VIEWPORT, |
| 38 | PreviewAppearancePreference, |
| 39 | PreviewViewportSetting, |
| 40 | PreviewZoomFactor, |
| 41 | } from "./preview.ts"; |
| 42 | import { |
| 43 | ProviderInstanceConfig, |
| 44 | ProviderInstanceId, |
| 45 | ProviderDriverKind, |
| 46 | } from "./providerInstance.ts"; |
| 47 | import { PullRequestMergeMethod } from "./pullRequest.ts"; |
| 48 | import { SharedAccountId } from "./sharedAccounts.ts"; |
| 49 | |
| 50 | // ── Client Settings (local-only) ─────────────────────────────── |
| 51 | |
| 52 | export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); |
| 53 | export type TimestampFormat = typeof TimestampFormat.Type; |
| 54 | const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; |
| 55 | |
| 56 | export const DiffLayout = Schema.Literals(["stacked", "split"]); |
| 57 | export type DiffLayout = typeof DiffLayout.Type; |
| 58 | const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; |
| 59 | |
| 60 | export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); |
| 61 | export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; |
| 62 | export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; |
| 63 | |
| 64 | export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); |
| 65 | export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; |
| 66 | // Not exported: mobile was the last consumer of the value itself; the |
| 67 | // wire field keeps its decoding default below. |
| 68 | const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; |
| 69 | |
| 70 | export const SidebarProjectGroupingMode = Schema.Literals([ |
| 71 | "repository", |
| 72 | "repository_path", |
| 73 | "separate", |
| 74 | ]); |
| 75 | export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; |
| 76 | const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; |
| 77 | export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; |
| 78 | export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; |
| 79 | export const SidebarThreadPreviewCount = Schema.Int.check( |
| 80 | Schema.isBetween({ |
| 81 | minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, |
| 82 | maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, |
| 83 | }), |
| 84 | ); |
| 85 | export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; |
| 86 | const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; |
| 87 | export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; |
| 88 | export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; |
| 89 | export const SidebarAutoSettleAfterDays = Schema.Number.check( |
| 90 | Schema.isBetween({ |
| 91 | minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 92 | maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, |
| 93 | }), |
| 94 | ); |
| 95 | export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; |
| 96 | const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; |
| 97 | export const MIN_GLASS_OPACITY = 40; |
| 98 | export const MAX_GLASS_OPACITY = 100; |
| 99 | export const GlassOpacity = Schema.Int.check( |
| 100 | Schema.isBetween({ |
| 101 | minimum: MIN_GLASS_OPACITY, |
| 102 | maximum: MAX_GLASS_OPACITY, |
| 103 | }), |
| 104 | ); |
| 105 | export type GlassOpacity = typeof GlassOpacity.Type; |
| 106 | const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; |
| 107 | |
| 108 | export const MIN_APPEARANCE_CONTRAST = 50; |
| 109 | export const MAX_APPEARANCE_CONTRAST = 200; |
| 110 | export const AppearanceContrast = Schema.Int.check( |
| 111 | Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }), |
| 112 | ); |
| 113 | export type AppearanceContrast = typeof AppearanceContrast.Type; |
| 114 | const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; |
| 115 | export const MIN_PANEL_ANIMATION_DURATION_MS = 0; |
| 116 | export const MAX_PANEL_ANIMATION_DURATION_MS = 400; |
| 117 | export const PanelAnimationDurationMs = Schema.Int.check( |
| 118 | Schema.isBetween({ |
| 119 | minimum: MIN_PANEL_ANIMATION_DURATION_MS, |
| 120 | maximum: MAX_PANEL_ANIMATION_DURATION_MS, |
| 121 | }), |
| 122 | ); |
| 123 | export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; |
| 124 | const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; |
| 125 | /** |
| 126 | * Font size preferences, in CSS pixels. The ranges are deliberately narrow: |
| 127 | * the interface size scales every rem-based dimension in the app, so the |
| 128 | * bounds keep layouts intact rather than offering unusable extremes. |
| 129 | */ |
| 130 | export const MIN_INTERFACE_FONT_SIZE = 12; |
| 131 | export const MAX_INTERFACE_FONT_SIZE = 20; |
| 132 | export const InterfaceFontSize = Schema.Int.check( |
| 133 | Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }), |
| 134 | ); |
| 135 | export type InterfaceFontSize = typeof InterfaceFontSize.Type; |
| 136 | export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; |
| 137 | |
| 138 | export const MIN_PROMPT_FONT_SIZE = 12; |
| 139 | export const MAX_PROMPT_FONT_SIZE = 20; |
| 140 | export const PromptFontSize = Schema.Int.check( |
| 141 | Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }), |
| 142 | ); |
| 143 | export type PromptFontSize = typeof PromptFontSize.Type; |
| 144 | export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; |
| 145 | |
| 146 | export const MIN_CODE_FONT_SIZE = 10; |
| 147 | export const MAX_CODE_FONT_SIZE = 18; |
| 148 | export const CodeFontSize = Schema.Int.check( |
| 149 | Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }), |
| 150 | ); |
| 151 | export type CodeFontSize = typeof CodeFontSize.Type; |
| 152 | export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; |
| 153 | |
| 154 | export const MIN_TERMINAL_FONT_SIZE = 8; |
| 155 | export const MAX_TERMINAL_FONT_SIZE = 20; |
| 156 | export const TerminalFontSize = Schema.Int.check( |
| 157 | Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), |
| 158 | ); |
| 159 | export type TerminalFontSize = typeof TerminalFontSize.Type; |
| 160 | const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; |
| 161 | |
| 162 | export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); |
| 163 | export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; |
| 164 | export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; |
| 165 | |
| 166 | export const SnapShotKeyChord = KeybindingShortcut.check( |
| 167 | Schema.makeFilter( |
| 168 | (shortcut) => |
| 169 | shortcut.metaKey || |
| 170 | shortcut.ctrlKey || |
| 171 | shortcut.shiftKey || |
| 172 | shortcut.altKey || |
| 173 | shortcut.modKey || |
| 174 | "Snapshot shortcut requires a modifier.", |
| 175 | ), |
| 176 | ); |
| 177 | export type SnapShotKeyChord = typeof SnapShotKeyChord.Type; |
| 178 | export const SNAP_SHOT_MODIFIERS = ["shift", "meta", "control", "alt"] as const; |
| 179 | export const SnapShotModifier = Schema.Literals(SNAP_SHOT_MODIFIERS); |
| 180 | export type SnapShotModifier = typeof SnapShotModifier.Type; |
| 181 | export const SnapShotShortcut = Schema.Union([ |
| 182 | Schema.Struct({ kind: Schema.Literal("both-shift-keys") }), |
| 183 | Schema.Struct({ kind: Schema.Literal("modifier-pair"), modifier: SnapShotModifier }), |
| 184 | SnapShotKeyChord, |
| 185 | ]); |
| 186 | export type SnapShotShortcut = typeof SnapShotShortcut.Type; |
| 187 | export const SnapShotSound = Schema.Literals(["soft-pop", "camera-shutter"]); |
| 188 | export type SnapShotSound = typeof SnapShotSound.Type; |
| 189 | const DEFAULT_SNAP_SHOT_SOUND: SnapShotSound = "soft-pop"; |
| 190 | |
| 191 | export type SnapShotModifierPairShortcut = Extract<SnapShotShortcut, { readonly kind: string }>; |
| 192 | |
| 193 | export function isModifierPairShortcut( |
| 194 | shortcut: SnapShotShortcut, |
| 195 | ): shortcut is SnapShotModifierPairShortcut { |
| 196 | return "kind" in shortcut; |
| 197 | } |
| 198 | |
| 199 | export function snapShotShortcutModifierPair( |
| 200 | shortcut: SnapShotModifierPairShortcut, |
| 201 | ): SnapShotModifier { |
| 202 | return shortcut.kind === "both-shift-keys" ? "shift" : shortcut.modifier; |
| 203 | } |
| 204 | |
| 205 | const APPLE_MODIFIER_LABELS: Record<SnapShotModifier, string> = { |
| 206 | shift: "Shift", |
| 207 | meta: "Command", |
| 208 | control: "Control", |
| 209 | alt: "Option", |
| 210 | }; |
| 211 | const OTHER_MODIFIER_LABELS: Record<SnapShotModifier, string> = { |
| 212 | shift: "Shift", |
| 213 | meta: "Super", |
| 214 | control: "Ctrl", |
| 215 | alt: "Alt", |
| 216 | }; |
| 217 | |
| 218 | export function snapShotModifierPairLabel(modifier: SnapShotModifier, apple: boolean): string { |
| 219 | const label = (apple ? APPLE_MODIFIER_LABELS : OTHER_MODIFIER_LABELS)[modifier]; |
| 220 | return `${label} + ${label}`; |
| 221 | } |
| 222 | const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = { |
| 223 | kind: "both-shift-keys", |
| 224 | }; |
| 225 | |
| 226 | export const NotificationMode = Schema.Literals([ |
| 227 | "off", |
| 228 | "notifications", |
| 229 | "sound", |
| 230 | "notifications-and-sound", |
| 231 | ]); |
| 232 | export type NotificationMode = typeof NotificationMode.Type; |
| 233 | |
| 234 | export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); |
| 235 | export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; |
| 236 | const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; |
| 237 | |
| 238 | const LegacyConfirmQuit = Schema.Boolean.pipe( |
| 239 | Schema.decodeTo( |
| 240 | QuitConfirmationMode, |
| 241 | SchemaTransformation.transform({ |
| 242 | decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), |
| 243 | encode: (mode) => mode === "hold", |
| 244 | }), |
| 245 | ), |
| 246 | ); |
| 247 | |
| 248 | const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); |
| 249 | |
| 250 | /** |
| 251 | * A user-chosen font family (a single name or a comma-separated list). Empty |
| 252 | * means "use the app default"; clients compose their own fallback stacks. |
| 253 | */ |
| 254 | export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); |
| 255 | export type FontFamilyPreference = typeof FontFamilyPreference.Type; |
| 256 | |
| 257 | /** |
| 258 | * The environment's theme, set with `t3 theme set <id>`. Each client applies |
| 259 | * it once per value — live when connected, on its next connect otherwise — so |
| 260 | * setting it switches every client, while a theme a user picks in Settings |
| 261 | * afterwards sticks until the next set. Empty means "no environment theme", |
| 262 | * which is also how it is cleared. |
| 263 | */ |
| 264 | export const DefaultThemePreference = Schema.String.check(Schema.isMaxLength(64)); |
| 265 | // Deliberately absent from ServerSettingsPatch: `t3 theme set` checks that an |
| 266 | // id is syntactically valid and actually resolvable, and a generic RPC patch |
| 267 | // would let a client write a theme no client can resolve, bypassing both. |
| 268 | export type DefaultThemePreference = typeof DefaultThemePreference.Type; |
| 269 | |
| 270 | /** |
| 271 | * Defaults for the in-app preview browser, applied whenever a tab is opened |
| 272 | * without an explicit viewport/zoom/appearance — by the user opening a browser |
| 273 | * tab, or by an agent calling `preview_open` with no size. Recording quality is |
| 274 | * client-local for the same reason: the Chromium guest being captured belongs |
| 275 | * to the desktop app. |
| 276 | */ |
| 277 | export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT; |
| 278 | export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true; |
| 279 | export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const; |
| 280 | export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES); |
| 281 | export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type; |
| 282 | export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30; |
| 283 | /** |
| 284 | * Where a clicked link goes: the OS default browser, or a tab in the in-app |
| 285 | * browser beside the thread. "system" is the default because that is what |
| 286 | * every link did before the setting existed. |
| 287 | */ |
| 288 | export const BrowserLinkTarget = Schema.Literals(["system", "app"]); |
| 289 | export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; |
| 290 | export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; |
| 291 | |
| 292 | export const LoadBalancingWeights = Schema.Record( |
| 293 | TrimmedNonEmptyString, |
| 294 | Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), |
| 295 | ); |
| 296 | |
| 297 | export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); |
| 298 | |
| 299 | /** Maximum width of the chat timeline and composer on wide screens. */ |
| 300 | export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); |
| 301 | export type ChatWidth = typeof ChatWidth.Type; |
| 302 | |
| 303 | /** Preferences shared by clients connected to the same environment. */ |
| 304 | export const ModelFavorite = Schema.Struct({ |
| 305 | // The historical field name is retained; default instance ids also decode old provider-kind favorites. |
| 306 | provider: ProviderInstanceId, |
| 307 | model: TrimmedNonEmptyString, |
| 308 | }); |
| 309 | export const ModelFavorites = Schema.Array(ModelFavorite); |
| 310 | export const ProviderModelPreferences = Schema.Record( |
| 311 | ProviderInstanceId, |
| 312 | Schema.Struct({ |
| 313 | hiddenModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 314 | modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 315 | }), |
| 316 | ); |
| 317 | export type ModelPreferences = { |
| 318 | readonly favorites: typeof ModelFavorites.Type; |
| 319 | readonly providerModelPreferences: typeof ProviderModelPreferences.Type; |
| 320 | }; |
| 321 | |
| 322 | export const ClientSettingsSchema = Schema.Struct({ |
| 323 | notificationMode: NotificationMode.pipe( |
| 324 | Schema.withDecodingDefault(Effect.succeed("off" as const)), |
| 325 | ), |
| 326 | inAppNotificationsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 327 | diffColorScheme: DiffColorScheme.pipe( |
| 328 | Schema.withDecodingDefault(Effect.succeed("red-green" as const)), |
| 329 | ), |
| 330 | chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), |
| 331 | loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 332 | loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 333 | appearanceContrast: AppearanceContrast.pipe( |
| 334 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), |
| 335 | ), |
| 336 | // Panel motion defaults to zero because width and height transitions cause |
| 337 | // layout work on every frame, which is noticeable on lower-power clients. |
| 338 | panelAnimationDurationMs: PanelAnimationDurationMs.pipe( |
| 339 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_PANEL_ANIMATION_DURATION_MS)), |
| 340 | ), |
| 341 | browserDefaultViewport: PreviewViewportSetting.pipe( |
| 342 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)), |
| 343 | ), |
| 344 | browserDefaultZoomFactor: PreviewZoomFactor.pipe( |
| 345 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_ZOOM_FACTOR)), |
| 346 | ), |
| 347 | browserDefaultAppearance: PreviewAppearancePreference.pipe( |
| 348 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_APPEARANCE)), |
| 349 | ), |
| 350 | browserRecordingFrameRate: BrowserRecordingFrameRate.pipe( |
| 351 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)), |
| 352 | ), |
| 353 | browserRecordingShowKeyPresses: Schema.Boolean.pipe( |
| 354 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 355 | ), |
| 356 | browserRecordingShowMousePresses: Schema.Boolean.pipe( |
| 357 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 358 | ), |
| 359 | /** |
| 360 | * Where links clicked in a thread (chat markdown, terminal output) open. |
| 361 | * Only the desktop app has an in-app browser, so other clients ignore "app". |
| 362 | */ |
| 363 | browserLinkTarget: BrowserLinkTarget.pipe( |
| 364 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)), |
| 365 | ), |
| 366 | /** |
| 367 | * Whether an agent using a preview pops the floating mini player into |
| 368 | * view. Only applies when the agent didn't ask either way — an explicit |
| 369 | * `open`/`show` on `preview_open` still wins, since that is the agent |
| 370 | * deliberately showing or hiding its work. |
| 371 | */ |
| 372 | browserAutoShowFloatingPreview: Schema.Boolean.pipe( |
| 373 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), |
| 374 | ), |
| 375 | /** |
| 376 | * User-created browser profiles. The built-in Default and Incognito profiles |
| 377 | * are synthesized by `resolveBrowserProfiles`, not stored here, so they |
| 378 | * cannot be renamed away or deleted. |
| 379 | */ |
| 380 | browserProfiles: Schema.Array(BrowserProfile).pipe( |
| 381 | Schema.withDecodingDefault(Effect.succeed([])), |
| 382 | ), |
| 383 | /** Profile new tabs open under. Falls back to Default if it no longer exists. */ |
| 384 | browserDefaultProfileId: BrowserProfileId.pipe( |
| 385 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), |
| 386 | ), |
| 387 | // Desktop-only. Boolean values from older settings files decode to their |
| 388 | // equivalent mode and encode back as the canonical string value. |
| 389 | confirmQuit: QuitConfirmationModeSetting.pipe( |
| 390 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), |
| 391 | ), |
| 392 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 393 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 394 | confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 395 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| 396 | Schema.withDecodingDefault(Effect.succeed([])), |
| 397 | ), |
| 398 | diffFilesCollapsed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 399 | diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 400 | diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), |
| 401 | environmentIdentificationMode: EnvironmentIdentificationMode.pipe( |
| 402 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE)), |
| 403 | ), |
| 404 | glassOpacity: GlassOpacity.pipe( |
| 405 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), |
| 406 | ), |
| 407 | fontSizeInterface: InterfaceFontSize.pipe( |
| 408 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTERFACE_FONT_SIZE)), |
| 409 | ), |
| 410 | fontSizePrompt: PromptFontSize.pipe( |
| 411 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROMPT_FONT_SIZE)), |
| 412 | ), |
| 413 | fontSizeCode: CodeFontSize.pipe( |
| 414 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_CODE_FONT_SIZE)), |
| 415 | ), |
| 416 | fontSizeTerminal: TerminalFontSize.pipe( |
| 417 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_FONT_SIZE)), |
| 418 | ), |
| 419 | fontFamilyCode: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 420 | fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 421 | fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 422 | fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 423 | // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); |
| 424 | // disabling restores the platform's heavier default. No effect off macOS. |
| 425 | fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 426 | // When the first-run welcome wizard finished (or was skipped), as an ISO |
| 427 | // timestamp. `null` alone does not mean "show the wizard" — every install |
| 428 | // that predates this field decodes to `null` — so the gate also requires an |
| 429 | // empty workspace before it treats the client as a fresh install. |
| 430 | onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( |
| 431 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 432 | ), |
| 433 | persistComposerContextStrip: Schema.Boolean.pipe( |
| 434 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 435 | ), |
| 436 | // Legacy client values retained for migration to the connected environment. |
| 437 | favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 438 | providerModelPreferences: ProviderModelPreferences.pipe( |
| 439 | Schema.withDecodingDefault(Effect.succeed({})), |
| 440 | ), |
| 441 | pullRequestMergeMethodOverrides: Schema.Record( |
| 442 | TrimmedNonEmptyString, |
| 443 | PullRequestMergeMethod, |
| 444 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 445 | // Legacy plan mode. The composer's Build/Plan toggle was removed from the |
| 446 | // default UI; this beta flag restores it (plus the /plan and /default slash |
| 447 | // commands) for users who still rely on the old workflow. |
| 448 | planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 449 | // Legacy context window meter. The composer hides it by default; users who |
| 450 | // still want the old usage indicator can restore it from Settings. |
| 451 | contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 452 | // Desktop resting composer: scrolling an existing thread's conversation |
| 453 | // settles the composer into its single-line layout. Losing focus never does. |
| 454 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 455 | // Rich text is the default; users can opt out for literal Markdown editing. |
| 456 | composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 457 | sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( |
| 458 | Schema.withDecodingDefault(Effect.succeed("enter")), |
| 459 | ), |
| 460 | followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( |
| 461 | Schema.withDecodingDefault(Effect.succeed("queue")), |
| 462 | ), |
| 463 | proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 464 | showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 465 | // Legacy sidebar (the original per-project tree). Deliberately a fresh key |
| 466 | // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the |
| 467 | // old keys, so everyone, including prior beta opt-outs, resets to the new |
| 468 | // default sidebar. |
| 469 | legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 470 | // Beta: working and monitoring threads fold into a Working shelf and return |
| 471 | // to the top of the inbox once they need the user. The inbox then orders by |
| 472 | // time, so manual placement there is ignored (and kept) while it is on. |
| 473 | sidebarWorkingShelfEnabled: Schema.Boolean.pipe( |
| 474 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 475 | ), |
| 476 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 477 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 478 | ), |
| 479 | sidebarProjectGroupingOverrides: Schema.Record( |
| 480 | TrimmedNonEmptyString, |
| 481 | SidebarProjectGroupingMode, |
| 482 | ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 483 | sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( |
| 484 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), |
| 485 | ), |
| 486 | sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( |
| 487 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), |
| 488 | ), |
| 489 | sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( |
| 490 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), |
| 491 | ), |
| 492 | timestampFormat: TimestampFormat.pipe( |
| 493 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), |
| 494 | ), |
| 495 | snapShotEnabled: Schema.Boolean.pipe( |
| 496 | Schema.decodeTo( |
| 497 | Schema.Boolean, |
| 498 | SchemaTransformation.transform({ decode: () => false, encode: () => false }), |
| 499 | ), |
| 500 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 501 | ), |
| 502 | snapShotIncludeAccessibility: Schema.Boolean.pipe( |
| 503 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 504 | ), |
| 505 | snapShotShortcut: SnapShotShortcut.pipe( |
| 506 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNAP_SHOT_SHORTCUT)), |
| 507 | ), |
| 508 | snapShotPlaySound: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 509 | snapShotSound: SnapShotSound.pipe( |
| 510 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SNAP_SHOT_SOUND)), |
| 511 | ), |
| 512 | snapShotFlash: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 513 | snapShotAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 514 | wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 515 | }); |
| 516 | export type ClientSettings = typeof ClientSettingsSchema.Type; |
| 517 | |
| 518 | export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); |
| 519 | |
| 520 | // ── Server Settings (server-authoritative) ──────────────────── |
| 521 | |
| 522 | const UsageModelTokenPrice = Schema.Number.check( |
| 523 | Schema.isFinite(), |
| 524 | Schema.isGreaterThanOrEqualTo(0), |
| 525 | ); |
| 526 | |
| 527 | /** USD per million tokens. Omitted cache rates use the input rate. */ |
| 528 | export const UsageModelPriceOverride = Schema.Struct({ |
| 529 | inputCostPerMillionTokens: UsageModelTokenPrice, |
| 530 | outputCostPerMillionTokens: UsageModelTokenPrice, |
| 531 | cacheReadCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), |
| 532 | cacheWriteCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice), |
| 533 | }); |
| 534 | export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; |
| 535 | |
| 536 | /** A binary path setting that decodes empty input to the provider's default executable. */ |
| 537 | export const makeBinaryPathSetting = (fallback: string) => |
| 538 | TrimmedString.pipe( |
| 539 | Schema.decodeTo( |
| 540 | Schema.String, |
| 541 | SchemaTransformation.transformEffect({ |
| 542 | decode: (value) => Effect.succeed(value || fallback), |
| 543 | encode: (value) => Effect.succeed(value), |
| 544 | }), |
| 545 | ), |
| 546 | Schema.withDecodingDefault(Effect.succeed(fallback)), |
| 547 | ); |
| 548 | |
| 549 | export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; |
| 550 | |
| 551 | export interface ProviderSettingsFormOption { |
| 552 | readonly value: string; |
| 553 | readonly label: string; |
| 554 | } |
| 555 | |
| 556 | export interface ProviderSettingsFormAnnotation { |
| 557 | readonly control?: ProviderSettingsFormControl | undefined; |
| 558 | readonly placeholder?: string | undefined; |
| 559 | readonly hidden?: boolean | undefined; |
| 560 | readonly clearWhenEmpty?: "omit" | "persist" | undefined; |
| 561 | /** Choices for a `select` control. The first entry is the default. */ |
| 562 | readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined; |
| 563 | } |
| 564 | |
| 565 | /** Secret fields declared by a settings schema, shared by persistence and client redaction. */ |
| 566 | export function secretSettingsFields(schema: { |
| 567 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| 568 | }): ReadonlyArray<string> { |
| 569 | return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => { |
| 570 | return isSecretSetting(fieldSchema) ? [field] : []; |
| 571 | }); |
| 572 | } |
| 573 | |
| 574 | export interface ProviderSettingsFormSchemaAnnotation { |
| 575 | readonly order?: readonly string[] | undefined; |
| 576 | } |
| 577 | |
| 578 | declare module "effect/Schema" { |
| 579 | namespace Annotations { |
| 580 | interface Annotations { |
| 581 | /** Store this credential separately and expose only whether it is configured to clients. */ |
| 582 | readonly settingsSecret?: boolean | undefined; |
| 583 | readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined; |
| 584 | readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined; |
| 585 | } |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | /** Mark a string or a record of strings as a credential in its settings schema. */ |
| 590 | export function secretSetting<S extends Schema.Top>(schema: S): S["Rebuild"] { |
| 591 | return schema.annotateKey({ settingsSecret: true }); |
| 592 | } |
| 593 | |
| 594 | export function isSecretSetting(schema: Schema.Top): boolean { |
| 595 | const annotations = Schema.resolveAnnotationsKey(schema) ?? Schema.resolveAnnotations(schema); |
| 596 | return annotations?.settingsSecret === true; |
| 597 | } |
| 598 | |
| 599 | export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< |
| 600 | keyof Fields, |
| 601 | string |
| 602 | >[]; |
| 603 | |
| 604 | /** A provider settings struct whose fields the settings form renders in `order`. */ |
| 605 | export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>( |
| 606 | fields: Fields, |
| 607 | options?: { |
| 608 | readonly order?: ProviderSettingsOrder<Fields> | undefined; |
| 609 | }, |
| 610 | ): Schema.Struct<Fields> { |
| 611 | return Schema.Struct(fields).pipe( |
| 612 | Schema.annotate({ |
| 613 | providerSettingsFormSchema: |
| 614 | options?.order === undefined ? undefined : { order: options.order }, |
| 615 | }), |
| 616 | ); |
| 617 | } |
| 618 | |
| 619 | export const CodexSettings = makeProviderSettingsSchema( |
| 620 | { |
| 621 | sharedAccountId: Schema.optionalKey(SharedAccountId).pipe( |
| 622 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 623 | ), |
| 624 | setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe( |
| 625 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 626 | ), |
| 627 | enabled: Schema.Boolean.pipe( |
| 628 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 629 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 630 | ), |
| 631 | binaryPath: makeBinaryPathSetting("codex").pipe( |
| 632 | Schema.annotateKey({ |
| 633 | title: "Binary path", |
| 634 | description: "Path to the Codex binary used by this instance.", |
| 635 | providerSettingsForm: { placeholder: "codex", clearWhenEmpty: "omit" }, |
| 636 | }), |
| 637 | ), |
| 638 | homePath: TrimmedString.pipe( |
| 639 | Schema.withDecodingDefault(Effect.succeed("")), |
| 640 | Schema.annotateKey({ |
| 641 | title: "CODEX_HOME path", |
| 642 | description: "Custom Codex home and config directory.", |
| 643 | providerSettingsForm: { |
| 644 | placeholder: "~/.codex", |
| 645 | clearWhenEmpty: "omit", |
| 646 | }, |
| 647 | }), |
| 648 | ), |
| 649 | shadowHomePath: TrimmedString.pipe( |
| 650 | Schema.withDecodingDefault(Effect.succeed("")), |
| 651 | Schema.annotateKey({ |
| 652 | title: "Shadow home path", |
| 653 | description: |
| 654 | "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.", |
| 655 | providerSettingsForm: { |
| 656 | placeholder: "~/.codex-t3/personal", |
| 657 | clearWhenEmpty: "omit", |
| 658 | }, |
| 659 | }), |
| 660 | ), |
| 661 | launchArgs: TrimmedString.pipe( |
| 662 | Schema.withDecodingDefault(Effect.succeed("")), |
| 663 | Schema.annotateKey({ |
| 664 | title: "Launch arguments", |
| 665 | description: "Additional CLI arguments passed to codex app-server on session start.", |
| 666 | }), |
| 667 | ), |
| 668 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 669 | Schema.withDecodingDefault(Effect.succeed([])), |
| 670 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 671 | ), |
| 672 | }, |
| 673 | { |
| 674 | order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"], |
| 675 | }, |
| 676 | ); |
| 677 | export type CodexSettings = typeof CodexSettings.Type; |
| 678 | |
| 679 | // Empty, or an integer from 100,000 to 1,000,000. Shared by the full |
| 680 | // Claude settings schema and its patch so an out-of-range value fails at |
| 681 | // the update that introduced it. |
| 682 | const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; |
| 683 | |
| 684 | export const ClaudeSettings = makeProviderSettingsSchema( |
| 685 | { |
| 686 | // Claude children read setup tokens at spawn; a revision change rebuilds the instance. |
| 687 | sharedAccountRevision: Schema.optionalKey(Schema.Int).pipe( |
| 688 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 689 | ), |
| 690 | sharedAccountId: Schema.optionalKey(SharedAccountId).pipe( |
| 691 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 692 | ), |
| 693 | enabled: Schema.Boolean.pipe( |
| 694 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 695 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 696 | ), |
| 697 | binaryPath: makeBinaryPathSetting("claude").pipe( |
| 698 | Schema.annotateKey({ |
| 699 | title: "Binary path", |
| 700 | description: "Path to the Claude binary used by this instance.", |
| 701 | providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" }, |
| 702 | }), |
| 703 | ), |
| 704 | homePath: TrimmedString.pipe( |
| 705 | Schema.withDecodingDefault(Effect.succeed("")), |
| 706 | Schema.annotateKey({ |
| 707 | title: "CLAUDE_CONFIG_DIR path", |
| 708 | description: |
| 709 | "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", |
| 710 | providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, |
| 711 | }), |
| 712 | ), |
| 713 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 714 | Schema.withDecodingDefault(Effect.succeed([])), |
| 715 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 716 | ), |
| 717 | launchArgs: Schema.String.pipe( |
| 718 | Schema.withDecodingDefault(Effect.succeed("")), |
| 719 | Schema.annotateKey({ |
| 720 | title: "Launch arguments", |
| 721 | description: "Additional CLI arguments passed on session start.", |
| 722 | providerSettingsForm: { |
| 723 | placeholder: "e.g. --chrome", |
| 724 | clearWhenEmpty: "omit", |
| 725 | }, |
| 726 | }), |
| 727 | ), |
| 728 | autoCompactWindow: TrimmedString.check( |
| 729 | Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN), |
| 730 | ).pipe( |
| 731 | Schema.withDecodingDefault(Effect.succeed("")), |
| 732 | Schema.annotateKey({ |
| 733 | title: "Auto-compact after", |
| 734 | description: |
| 735 | "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.", |
| 736 | providerSettingsForm: { |
| 737 | placeholder: "e.g. 300000", |
| 738 | clearWhenEmpty: "omit", |
| 739 | }, |
| 740 | }), |
| 741 | ), |
| 742 | }, |
| 743 | { |
| 744 | order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"], |
| 745 | }, |
| 746 | ); |
| 747 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 748 | |
| 749 | /** |
| 750 | * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in |
| 751 | * in the browser. The API key and Agent Platform methods take credentials from |
| 752 | * the instance config and never open a browser. |
| 753 | */ |
| 754 | export const ANTIGRAVITY_AUTH_METHODS = [ |
| 755 | { value: "oauth-personal", label: "Google account" }, |
| 756 | { value: "oauth-business", label: "Gemini Enterprise" }, |
| 757 | { value: "gemini-api-key", label: "Gemini API key" }, |
| 758 | { value: "agent-platform", label: "Agent Platform (Vertex AI)" }, |
| 759 | ] as const satisfies ReadonlyArray<ProviderSettingsFormOption>; |
| 760 | export const AntigravityAuthMethod = Schema.Literals( |
| 761 | ANTIGRAVITY_AUTH_METHODS.map((method) => method.value), |
| 762 | ); |
| 763 | export type AntigravityAuthMethod = typeof AntigravityAuthMethod.Type; |
| 764 | |
| 765 | export const AntigravitySettings = makeProviderSettingsSchema( |
| 766 | { |
| 767 | enabled: Schema.Boolean.pipe( |
| 768 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 769 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 770 | ), |
| 771 | authMethod: AntigravityAuthMethod.pipe( |
| 772 | Schema.withDecodingDefault(Effect.succeed("oauth-personal" as const)), |
| 773 | Schema.annotateKey({ |
| 774 | title: "Sign-in method", |
| 775 | description: |
| 776 | "Google accounts use your subscription; API keys and Agent Platform bill usage.", |
| 777 | providerSettingsForm: { |
| 778 | control: "select", |
| 779 | options: ANTIGRAVITY_AUTH_METHODS, |
| 780 | clearWhenEmpty: "omit", |
| 781 | }, |
| 782 | }), |
| 783 | ), |
| 784 | apiKey: TrimmedString.pipe( |
| 785 | secretSetting, |
| 786 | Schema.withDecodingDefault(Effect.succeed("")), |
| 787 | Schema.annotateKey({ |
| 788 | title: "API key", |
| 789 | description: "Gemini or Vertex AI express key. Stored separately on the host.", |
| 790 | providerSettingsForm: { |
| 791 | control: "password", |
| 792 | placeholder: "Optional", |
| 793 | clearWhenEmpty: "omit", |
| 794 | }, |
| 795 | }), |
| 796 | ), |
| 797 | gcpProject: TrimmedString.pipe( |
| 798 | Schema.withDecodingDefault(Effect.succeed("")), |
| 799 | Schema.annotateKey({ |
| 800 | title: "GCP project", |
| 801 | description: |
| 802 | "Required for Gemini Enterprise. Agent Platform uses it when no API key is set.", |
| 803 | providerSettingsForm: { placeholder: "my-project-id", clearWhenEmpty: "omit" }, |
| 804 | }), |
| 805 | ), |
| 806 | gcpLocation: TrimmedString.pipe( |
| 807 | Schema.withDecodingDefault(Effect.succeed("")), |
| 808 | Schema.annotateKey({ |
| 809 | title: "GCP location", |
| 810 | description: "Region for Gemini Enterprise or Agent Platform.", |
| 811 | providerSettingsForm: { placeholder: "us-central1", clearWhenEmpty: "omit" }, |
| 812 | }), |
| 813 | ), |
| 814 | binaryPath: TrimmedString.pipe( |
| 815 | Schema.withDecodingDefault(Effect.succeed("")), |
| 816 | Schema.annotateKey({ |
| 817 | title: "Binary path", |
| 818 | description: "Custom ACP executable. Leave empty to select automatically.", |
| 819 | providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, |
| 820 | }), |
| 821 | ), |
| 822 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 823 | Schema.withDecodingDefault(Effect.succeed([])), |
| 824 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 825 | ), |
| 826 | }, |
| 827 | { order: ["authMethod", "apiKey", "gcpProject", "gcpLocation", "binaryPath"] }, |
| 828 | ); |
| 829 | export type AntigravitySettings = typeof AntigravitySettings.Type; |
| 830 | |
| 831 | /** |
| 832 | * A read-only quota source outside this environment's provider CLIs. The |
| 833 | * only kind today is a CLIProxyAPI hub, whose management API reports the |
| 834 | * windows of every pooled account. The key travels in settings for now, like |
| 835 | * provider environment secrets; it is redacted before reaching a client. |
| 836 | */ |
| 837 | export const UsageLimitSourceConfig = Schema.Struct({ |
| 838 | kind: Schema.Literal("cliproxy"), |
| 839 | label: Schema.optional(TrimmedNonEmptyString), |
| 840 | url: TrimmedNonEmptyString, |
| 841 | managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting), |
| 842 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 843 | }); |
| 844 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; |
| 845 | |
| 846 | /** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */ |
| 847 | export const GitHubHost = TrimmedNonEmptyString.pipe( |
| 848 | Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), |
| 849 | ); |
| 850 | |
| 851 | /** |
| 852 | * One source control host's saved settings. Each host package declares the fields it reads in |
| 853 | * its client definition's settings schema and decodes this blob with it; the environment stores |
| 854 | * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret |
| 855 | * holds a string, or a string per server host, whose value lives in the server's secret store: |
| 856 | * settings and clients only see a redaction marker for a saved one. |
| 857 | */ |
| 858 | export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); |
| 859 | export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; |
| 860 | |
| 861 | export const ObservabilitySettings = Schema.Struct({ |
| 862 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 863 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 864 | otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 865 | }); |
| 866 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; |
| 867 | |
| 868 | export const SourceControlWritingStyleMode = Schema.Literals([ |
| 869 | "repo_conventions", |
| 870 | "conventional_commits", |
| 871 | "custom", |
| 872 | ]); |
| 873 | export type SourceControlWritingStyleMode = typeof SourceControlWritingStyleMode.Type; |
| 874 | |
| 875 | export const SourceControlWritingStyleSettings = Schema.Struct({ |
| 876 | mode: SourceControlWritingStyleMode.pipe( |
| 877 | Schema.withDecodingDefault(Effect.succeed("repo_conventions" as const)), |
| 878 | ), |
| 879 | customInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 880 | followChangeRequestTemplates: Schema.Boolean.pipe( |
| 881 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 882 | ), |
| 883 | }); |
| 884 | export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type; |
| 885 | |
| 886 | export const BranchNamingMode = Schema.Literals(["static", "semantic", "custom"]); |
| 887 | export type BranchNamingMode = typeof BranchNamingMode.Type; |
| 888 | |
| 889 | export interface BranchNamingOptions { |
| 890 | mode: BranchNamingMode; |
| 891 | prefix: string; |
| 892 | instructions: string; |
| 893 | } |
| 894 | |
| 895 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); |
| 896 | export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5); |
| 897 | |
| 898 | export const BackgroundActivityProfile = Schema.Literals([ |
| 899 | "balanced", |
| 900 | "performance", |
| 901 | "battery-saver", |
| 902 | ]); |
| 903 | export type BackgroundActivityProfile = typeof BackgroundActivityProfile.Type; |
| 904 | export const DEFAULT_BACKGROUND_ACTIVITY_PROFILE: BackgroundActivityProfile = "balanced"; |
| 905 | |
| 906 | export const BackgroundActivityProfileSelection = Schema.Literals([ |
| 907 | "balanced", |
| 908 | "performance", |
| 909 | "battery-saver", |
| 910 | "custom", |
| 911 | ]); |
| 912 | export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfileSelection.Type; |
| 913 | |
| 914 | export const BackgroundActivityOverrides = Schema.Struct({ |
| 915 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 916 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 917 | hostPowerMonitorActiveInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 918 | hostPowerMonitorIdleInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 919 | idleClientTtl: Schema.optionalKey(Schema.DurationFromMillis), |
| 920 | pauseWhenHostLocked: Schema.optionalKey(Schema.Boolean), |
| 921 | pauseWhenHostLowPower: Schema.optionalKey(Schema.Boolean), |
| 922 | pauseWhenClientLowPower: Schema.optionalKey(Schema.Boolean), |
| 923 | pauseWhenOnBattery: Schema.optionalKey(Schema.Boolean), |
| 924 | }); |
| 925 | export type BackgroundActivityOverrides = typeof BackgroundActivityOverrides.Type; |
| 926 | |
| 927 | export const BackgroundActivitySettings = Schema.Struct({ |
| 928 | schemaVersion: Schema.Literal(1).pipe(Schema.withDecodingDefault(Effect.succeed(1 as const))), |
| 929 | profile: BackgroundActivityProfileSelection.pipe( |
| 930 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), |
| 931 | ), |
| 932 | baseProfile: Schema.optionalKey(BackgroundActivityProfile), |
| 933 | overrides: BackgroundActivityOverrides.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 934 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); |
| 935 | export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; |
| 936 | |
| 937 | /** |
| 938 | * How assistant text reaches clients while a turn runs. |
| 939 | * - `turn`: hold the whole message until the turn finishes or pauses. |
| 940 | * - `paragraph`: deliver each finished paragraph or closed code block. |
| 941 | */ |
| 942 | export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph"]); |
| 943 | export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; |
| 944 | |
| 945 | /** |
| 946 | * Server settings a project may override. Every other server setting is |
| 947 | * environment-wide: providers, keybindings, observability, device hosts, |
| 948 | * background activity, theme. UI, search and the write planner derive |
| 949 | * eligibility from this list, so adding a key here is the whole opt-in. |
| 950 | */ |
| 951 | const StorageRetentionDays = Schema.NullOr( |
| 952 | Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), |
| 953 | ); |
| 954 | const SettledWorktreeRetentionDays = Schema.NullOr( |
| 955 | Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3650 })), |
| 956 | ); |
| 957 | |
| 958 | export const WorktreeKeepWhen = Schema.Literals([ |
| 959 | "any-local-files", |
| 960 | "uncommitted-changes", |
| 961 | "tracked-changes", |
| 962 | ]); |
| 963 | export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; |
| 964 | |
| 965 | export const WorktreeCleanupRules = Schema.Struct({ |
| 966 | worktreeKeepWhen: WorktreeKeepWhen.pipe( |
| 967 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), |
| 968 | ), |
| 969 | worktreeAfterDays: StorageRetentionDays, |
| 970 | worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe( |
| 971 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 972 | ), |
| 973 | worktreeOnMerge: Schema.Boolean, |
| 974 | worktreeOnDelete: Schema.Boolean, |
| 975 | worktreeUnchanged: Schema.Boolean, |
| 976 | }); |
| 977 | export type WorktreeCleanupRules = typeof WorktreeCleanupRules.Type; |
| 978 | |
| 979 | export const WorktreeCleanup = Schema.NullOr( |
| 980 | Schema.Union([ |
| 981 | Schema.Struct({ mode: Schema.Literal("off") }), |
| 982 | Schema.Struct({ mode: Schema.Literal("custom"), rules: WorktreeCleanupRules }), |
| 983 | ]), |
| 984 | ); |
| 985 | export type WorktreeCleanup = typeof WorktreeCleanup.Type; |
| 986 | |
| 987 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ |
| 988 | "worktreeCleanup", |
| 989 | "defaultModelSelection", |
| 990 | "defaultRuntimeMode", |
| 991 | "defaultThreadEnvMode", |
| 992 | "newWorktreesStartFromOrigin", |
| 993 | "worktreeSubmodules", |
| 994 | "defaultAutoPull", |
| 995 | "defaultProjectScripts", |
| 996 | "enableAgentBrowserAccess", |
| 997 | "enableAgentDeviceAccess", |
| 998 | "textGenerationModelSelection", |
| 999 | "sourceControlWriterModelSelection", |
| 1000 | "reviewGuideModelSelection", |
| 1001 | "reviewGuideInstructions", |
| 1002 | "sourceControlWritingStyle", |
| 1003 | "removeAgentCreditsOnMerge", |
| 1004 | "branchNamingMode", |
| 1005 | "branchNamePrefix", |
| 1006 | "branchNameInstructions", |
| 1007 | "pullRequestMergeMethod", |
| 1008 | "sidebarAutoSettleOnMerge", |
| 1009 | "sidebarAutoSettleAfterDays", |
| 1010 | "sidebarAutoSettleKeepPullRequests", |
| 1011 | "continueThreadsAfterServerUpdate", |
| 1012 | "responseStreamingMode", |
| 1013 | ] as const; |
| 1014 | export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; |
| 1015 | |
| 1016 | export const ProjectProviderInstanceOverride = Schema.Struct({ |
| 1017 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1018 | }); |
| 1019 | export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOverride.Type; |
| 1020 | |
| 1021 | /** |
| 1022 | * One project's overrides. An absent key inherits the environment value; |
| 1023 | * `null` is a real value where the environment type is nullable (no default |
| 1024 | * model, no dedicated writer model, never auto-settle). |
| 1025 | * |
| 1026 | * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: |
| 1027 | * every other key replaces its `ServerSettings` field whole, while this one |
| 1028 | * keeps the settings.json shape but overrides field by field, per |
| 1029 | * `ProviderInstanceId`. Only `enabled` is overridable today. An instance id |
| 1030 | * or field absent here inherits the machine value, and a present `enabled` |
| 1031 | * wins in either direction, so a project can turn on an instance the machine |
| 1032 | * disabled, or turn off one the machine enabled. See |
| 1033 | * `resolveProjectProviderInstanceEnabled` for the combined resolution. |
| 1034 | */ |
| 1035 | export const ProjectSettingsOverrides = Schema.Struct({ |
| 1036 | worktreeCleanup: Schema.optionalKey(WorktreeCleanup), |
| 1037 | defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1038 | defaultRuntimeMode: Schema.optionalKey(RuntimeMode), |
| 1039 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 1040 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 1041 | worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), |
| 1042 | defaultAutoPull: Schema.optionalKey(Schema.Boolean), |
| 1043 | defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), |
| 1044 | enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), |
| 1045 | enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), |
| 1046 | textGenerationModelSelection: Schema.optionalKey(ModelSelection), |
| 1047 | sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1048 | reviewGuideModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1049 | reviewGuideInstructions: Schema.optionalKey(TrimmedString), |
| 1050 | sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), |
| 1051 | removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1052 | branchNamingMode: Schema.optionalKey(BranchNamingMode), |
| 1053 | branchNamePrefix: Schema.optionalKey(TrimmedString), |
| 1054 | branchNameInstructions: Schema.optionalKey(TrimmedString), |
| 1055 | pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), |
| 1056 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1057 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 1058 | sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), |
| 1059 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1060 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 1061 | providerInstances: Schema.optionalKey( |
| 1062 | Schema.Record(ProviderInstanceId, ProjectProviderInstanceOverride), |
| 1063 | ), |
| 1064 | } satisfies Record<ProjectScopedServerSettingKey | "providerInstances", unknown>); |
| 1065 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 1066 | |
| 1067 | /** |
| 1068 | * Whether `null` is a stored override value for this key rather than "unset". |
| 1069 | * Clients writing a project override treat null for every other key as a |
| 1070 | * request to remove the override, so a picker's "Inherit" item and the row's |
| 1071 | * reset do the same thing. |
| 1072 | */ |
| 1073 | export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { |
| 1074 | return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); |
| 1075 | } |
| 1076 | const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettingKey> = new Set< |
| 1077 | { |
| 1078 | [K in ProjectScopedServerSettingKey]: null extends ProjectSettingsOverrides[K] ? K : never; |
| 1079 | }[ProjectScopedServerSettingKey] |
| 1080 | >([ |
| 1081 | "defaultModelSelection", |
| 1082 | "sourceControlWriterModelSelection", |
| 1083 | "reviewGuideModelSelection", |
| 1084 | "pullRequestMergeMethod", |
| 1085 | "sidebarAutoSettleAfterDays", |
| 1086 | ]); |
| 1087 | |
| 1088 | export const StorageCleanupReportEntry = Schema.Struct({ |
| 1089 | kind: Schema.Literals(["worktree", "browser-artifacts", "logs"]), |
| 1090 | outcome: Schema.Literals(["removed", "kept", "failed"]), |
| 1091 | reason: Schema.String, |
| 1092 | path: Schema.NullOr(Schema.String), |
| 1093 | threadId: Schema.NullOr(ThreadId), |
| 1094 | threadTitle: Schema.NullOr(Schema.String), |
| 1095 | bytes: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1096 | files: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1097 | }); |
| 1098 | export type StorageCleanupReportEntry = typeof StorageCleanupReportEntry.Type; |
| 1099 | export const StorageCleanupReport = Schema.Struct({ |
| 1100 | trigger: Schema.Literals(["automatic", "manual"]), |
| 1101 | startedAt: Schema.String, |
| 1102 | finishedAt: Schema.String, |
| 1103 | entries: Schema.Array(StorageCleanupReportEntry), |
| 1104 | counts: Schema.Struct({ removed: Schema.Number, kept: Schema.Number, failed: Schema.Number }), |
| 1105 | omittedCount: Schema.Number, |
| 1106 | bytesFreed: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))), |
| 1107 | }); |
| 1108 | export type StorageCleanupReport = typeof StorageCleanupReport.Type; |
| 1109 | |
| 1110 | export const StorageCleanupSettings = Schema.Struct({ |
| 1111 | worktreeKeepWhen: WorktreeKeepWhen.pipe( |
| 1112 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), |
| 1113 | ), |
| 1114 | worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1115 | worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe( |
| 1116 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1117 | ), |
| 1118 | worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1119 | worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1120 | worktreeUnchanged: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1121 | browserArtifactsAfterDays: StorageRetentionDays.pipe( |
| 1122 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1123 | ), |
| 1124 | logsAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1125 | }); |
| 1126 | export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; |
| 1127 | |
| 1128 | export const ServerSettings = Schema.Struct({ |
| 1129 | // Null means no client has migrated this preference yet; an empty value is |
| 1130 | // authoritative so another device cannot resurrect cleared preferences. |
| 1131 | favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1132 | providerModelPreferences: Schema.NullOr(ProviderModelPreferences).pipe( |
| 1133 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1134 | ), |
| 1135 | worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1136 | storageCleanup: StorageCleanupSettings.pipe( |
| 1137 | Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), |
| 1138 | ), |
| 1139 | /** |
| 1140 | * Absolute directory new worktrees are created under, e.g. `D:\worktrees` |
| 1141 | * or `~/worktrees`. Empty uses `<T3 home>/worktrees`. |
| 1142 | */ |
| 1143 | worktreesDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1144 | /** |
| 1145 | * Custom locations used before the current one. Server-maintained so |
| 1146 | * worktrees left there stay eligible for cleanup and review diffs. |
| 1147 | */ |
| 1148 | previousWorktreesDirectories: Schema.Array(TrimmedString).pipe( |
| 1149 | Schema.withDecodingDefault(Effect.succeed([])), |
| 1150 | ), |
| 1151 | responseStreamingMode: ResponseStreamingMode.pipe( |
| 1152 | Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), |
| 1153 | ), |
| 1154 | enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 1155 | // Retain the update-era key. On by default so restarts resume the work they |
| 1156 | // interrupted; recovery stops after three automatic continuations in a row. |
| 1157 | continueThreadsAfterServerUpdate: Schema.Boolean.pipe( |
| 1158 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 1159 | ), |
| 1160 | /** |
| 1161 | * Whether agents may drive the in-app preview browser. Turning this off |
| 1162 | * withholds the MCP credential, so the `t3-code` server (and with it every |
| 1163 | * `preview_*` tool) is never attached to a provider session, and the prompt |
| 1164 | * text describing those tools is dropped along with them. The user's own |
| 1165 | * browser panel is unaffected — this gates agent access only. |
| 1166 | * |
| 1167 | * Server-authoritative rather than client-local: tool injection and prompt |
| 1168 | * construction both happen on the server, and the answer must not differ |
| 1169 | * between a desktop window and a phone attached to the same server. |
| 1170 | */ |
| 1171 | enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 1172 | projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( |
| 1173 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1174 | ), |
| 1175 | defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1176 | defaultProjectScripts: Schema.Array(ProjectScript).pipe( |
| 1177 | Schema.withDecodingDefault(Effect.succeed([])), |
| 1178 | ), |
| 1179 | projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( |
| 1180 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1181 | ), |
| 1182 | projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( |
| 1183 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1184 | ), |
| 1185 | defaultModelSelection: Schema.NullOr(ModelSelection).pipe( |
| 1186 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1187 | ), |
| 1188 | defaultRuntimeMode: RuntimeMode.pipe( |
| 1189 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), |
| 1190 | ), |
| 1191 | /** |
| 1192 | * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, |
| 1193 | * plus `providerInstances`. The source of truth for project |
| 1194 | * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides` |
| 1195 | * and `projectScriptOverrides` are derived views kept for one release so |
| 1196 | * older clients keep reading them. |
| 1197 | */ |
| 1198 | projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( |
| 1199 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1200 | ), |
| 1201 | /** |
| 1202 | * Whether the legacy per-project fields have been folded into |
| 1203 | * `projectSettingsOverrides`. The fold runs once so a later reset in the |
| 1204 | * settings UI is not undone by the next server start. |
| 1205 | */ |
| 1206 | projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1207 | /** |
| 1208 | * Whether agents may drive simulators and emulators. Gates the `device_*` |
| 1209 | * MCP tools and the preconfigured `agent-device` CLI the same way |
| 1210 | * `enableAgentBrowserAccess` gates the browser: server-authoritative, applied |
| 1211 | * when the provider session is prepared. The user's own Device panel is |
| 1212 | * unaffected. |
| 1213 | */ |
| 1214 | enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1215 | /** |
| 1216 | * Whether this server may install and run T3's device helper processes. |
| 1217 | * Kept separate from agent access so enabling the user's Device panel does |
| 1218 | * not also grant providers control of simulators and emulators. |
| 1219 | */ |
| 1220 | enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1221 | /** Whether the server-local Device panel setup flow has been completed. */ |
| 1222 | deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1223 | deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), |
| 1224 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( |
| 1225 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), |
| 1226 | ), |
| 1227 | snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1228 | autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1229 | sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe( |
| 1230 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 1231 | ), |
| 1232 | sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 1233 | backgroundActivity: BackgroundActivitySettings, |
| 1234 | // Legacy flat fields retained for old settings files and old clients. New |
| 1235 | // consumers should resolve `backgroundActivity` instead. |
| 1236 | automaticGitFetchInterval: Schema.DurationFromMillis.pipe( |
| 1237 | Schema.withDecodingDefault( |
| 1238 | Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), |
| 1239 | ), |
| 1240 | ), |
| 1241 | providerHealthRefreshInterval: Schema.DurationFromMillis.pipe( |
| 1242 | Schema.withDecodingDefault( |
| 1243 | Effect.succeed(Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL)), |
| 1244 | ), |
| 1245 | ), |
| 1246 | backgroundActivityProfile: BackgroundActivityProfile.pipe( |
| 1247 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), |
| 1248 | ), |
| 1249 | defaultTheme: DefaultThemePreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1250 | /** |
| 1251 | * When the environment's theme was last set, so clients can tell a re-set |
| 1252 | * of the same value from one they already applied: `t3 theme set` must act |
| 1253 | * even when it names the theme it named before. Empty on environments |
| 1254 | * provisioned by builds that predate it, where clients fall back to |
| 1255 | * applying once per value. |
| 1256 | */ |
| 1257 | defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( |
| 1258 | Schema.withDecodingDefault(Effect.succeed("")), |
| 1259 | ), |
| 1260 | /** |
| 1261 | * The icon clients draw for this environment. Null means "use what the |
| 1262 | * server detected" (`environment.platform.machine`), falling back to a |
| 1263 | * generic server. Lives on the server, not the client, so every device |
| 1264 | * sees the same machine. A kind picked on a newer server decodes as null |
| 1265 | * here rather than failing the whole settings snapshot for an older client. |
| 1266 | */ |
| 1267 | environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( |
| 1268 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1269 | ), |
| 1270 | /** |
| 1271 | * Null means inherit: the repository's t3.json, then "local". The old |
| 1272 | * default "local" was never persisted (defaults are stripped on write), so |
| 1273 | * it now decodes as inherit, which resolves the same way because the old |
| 1274 | * chain also let t3.json outrank the environment. Null stays off the wire |
| 1275 | * so older clients, which require a literal here, keep decoding. |
| 1276 | */ |
| 1277 | defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), |
| 1278 | newWorktreesStartFromOrigin: Schema.Boolean.pipe( |
| 1279 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 1280 | ), |
| 1281 | /** |
| 1282 | * Null defers to the repository's t3.json, then to recursive. A value |
| 1283 | * picked on a newer server decodes as null here rather than failing the |
| 1284 | * whole settings snapshot for an older client. |
| 1285 | */ |
| 1286 | worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( |
| 1287 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1288 | ), |
| 1289 | addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1290 | textGenerationModelSelection: ModelSelection.pipe( |
| 1291 | Schema.withDecodingDefault( |
| 1292 | Effect.succeed({ |
| 1293 | instanceId: ProviderInstanceId.make("codex"), |
| 1294 | model: DEFAULT_TEXT_GENERATION_MODEL, |
| 1295 | options: [ |
| 1296 | { |
| 1297 | id: "reasoningEffort", |
| 1298 | value: DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 1299 | }, |
| 1300 | ], |
| 1301 | }), |
| 1302 | ), |
| 1303 | ), |
| 1304 | branchNamingMode: BranchNamingMode.pipe( |
| 1305 | Schema.withDecodingDefault(Effect.succeed("static" as const)), |
| 1306 | ), |
| 1307 | branchNamePrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3"))), |
| 1308 | branchNameInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1309 | removeAgentCreditsOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1310 | sourceControlWritingStyle: SourceControlWritingStyleSettings.pipe( |
| 1311 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1312 | ), |
| 1313 | sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe( |
| 1314 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1315 | ), |
| 1316 | /** |
| 1317 | * Beta: review guides on this server. Off hides the Guide tab in every client and refuses to |
| 1318 | * write guides, for people and agents alike. |
| 1319 | */ |
| 1320 | reviewGuidesEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1321 | /** |
| 1322 | * The model that reads a pull request to write its review guide. It investigates a checkout |
| 1323 | * over many turns, so it can want a stronger model than one-shot writing. `null` uses the |
| 1324 | * source control writer model. |
| 1325 | */ |
| 1326 | reviewGuideModelSelection: Schema.NullOr(ModelSelection).pipe( |
| 1327 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1328 | ), |
| 1329 | /** |
| 1330 | * The maintainer's taste for review guides: what to dwell on, skip, or order first. Shapes the |
| 1331 | * guide's emphasis only; the output format and coverage rules stay fixed. |
| 1332 | */ |
| 1333 | reviewGuideInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 1334 | /** |
| 1335 | * The merge method pull requests start with; `null` reuses the method |
| 1336 | * last chosen on this device. Server-side so a project can override it |
| 1337 | * like any other project setting. |
| 1338 | */ |
| 1339 | pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe( |
| 1340 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1341 | ), |
| 1342 | |
| 1343 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 1344 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob |
| 1345 | // is `Schema.Unknown` at this layer so envelopes with unknown drivers |
| 1346 | // (forks, downgrades, in-flight PR branches) round-trip without loss. |
| 1347 | // See providerInstance.ts for the forward/backward compatibility invariant. |
| 1348 | providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( |
| 1349 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1350 | ), |
| 1351 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 1352 | /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */ |
| 1353 | sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe( |
| 1354 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1355 | ), |
| 1356 | // Keyed by a user-chosen id so a source keeps its rows across edits. Entries |
| 1357 | // this build cannot decode round-trip untouched, as provider instances do. |
| 1358 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( |
| 1359 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1360 | ), |
| 1361 | /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */ |
| 1362 | cursorKeychainUsageEnabled: Schema.Boolean.pipe( |
| 1363 | Schema.withDecodingDefault(Effect.succeed(false)), |
| 1364 | ), |
| 1365 | /** Exact model IDs, applied to past and future usage on this environment. */ |
| 1366 | usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( |
| 1367 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1368 | ), |
| 1369 | /** |
| 1370 | * Exact model ID to the model its usage counts as, such as a preview slug to |
| 1371 | * its released name. The mapped model is priced and reported as its target. |
| 1372 | */ |
| 1373 | usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( |
| 1374 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1375 | ), |
| 1376 | }); |
| 1377 | export type ServerSettings = typeof ServerSettings.Type; |
| 1378 | |
| 1379 | export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); |
| 1380 | |
| 1381 | /** |
| 1382 | * Read the legacy `enabled` flag embedded in a provider instance config |
| 1383 | * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single |
| 1384 | * flag going forward; this reader exists for old settings files that still |
| 1385 | * carry the flag in-config. |
| 1386 | */ |
| 1387 | export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { |
| 1388 | if (config === null || typeof config !== "object" || Array.isArray(config)) { |
| 1389 | return undefined; |
| 1390 | } |
| 1391 | const enabled = (config as { readonly enabled?: unknown }).enabled; |
| 1392 | return typeof enabled === "boolean" ? enabled : undefined; |
| 1393 | }; |
| 1394 | |
| 1395 | /** |
| 1396 | * Built-in drivers that stay off until the user turns them on. Matches the |
| 1397 | * `enabled` decoding default of each driver's settings schema. |
| 1398 | */ |
| 1399 | const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set( |
| 1400 | ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => |
| 1401 | ProviderDriverKind.make(driver), |
| 1402 | ), |
| 1403 | ); |
| 1404 | |
| 1405 | /** Built-in drivers whose default instance runs before the user configures it. */ |
| 1406 | const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet<ProviderInstanceId> = new Set( |
| 1407 | ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)), |
| 1408 | ); |
| 1409 | |
| 1410 | /** |
| 1411 | * Whether the built-in default instance at `instanceId` is enabled while |
| 1412 | * settings have no `providerInstances` entry for it. Only Codex and Claude |
| 1413 | * start on; any other id without an entry has no running instance. |
| 1414 | */ |
| 1415 | export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean => |
| 1416 | DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId); |
| 1417 | |
| 1418 | /** |
| 1419 | * Default enabled state for a driver when neither the envelope nor the config |
| 1420 | * blob carries a flag. Unknown (fork) drivers default to enabled. |
| 1421 | */ |
| 1422 | const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => |
| 1423 | !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver); |
| 1424 | |
| 1425 | /** |
| 1426 | * Resolve whether a configured provider instance is enabled. An explicit |
| 1427 | * false on either the envelope or the in-config flag wins (most |
| 1428 | * restrictive), so a user's disable is never silently undone by the other |
| 1429 | * flag. Otherwise: envelope, then config, then the driver's default. |
| 1430 | */ |
| 1431 | export const resolveProviderInstanceEnabled = ( |
| 1432 | instance: Pick<ProviderInstanceConfig, "driver" | "enabled" | "config">, |
| 1433 | ): boolean => { |
| 1434 | const configEnabled = providerInstanceConfigEnabledFlag(instance.config); |
| 1435 | if (instance.enabled === false || configEnabled === false) { |
| 1436 | return false; |
| 1437 | } |
| 1438 | return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); |
| 1439 | }; |
| 1440 | |
| 1441 | /** |
| 1442 | * Effective enablement for a provider instance within a project: the |
| 1443 | * project's `providerInstances[id].enabled` override when set, otherwise the |
| 1444 | * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null` |
| 1445 | * (no project in scope) always resolves to the machine-wide value. |
| 1446 | */ |
| 1447 | export const resolveProjectProviderInstanceEnabled = ( |
| 1448 | settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">, |
| 1449 | projectId: ProjectId | null, |
| 1450 | instanceId: ProviderInstanceId, |
| 1451 | ): boolean => { |
| 1452 | const override = |
| 1453 | projectId === null |
| 1454 | ? undefined |
| 1455 | : settings.projectSettingsOverrides[projectId]?.providerInstances?.[instanceId]?.enabled; |
| 1456 | if (override !== undefined) return override; |
| 1457 | const instanceConfig = Object.hasOwn(settings.providerInstances, instanceId) |
| 1458 | ? settings.providerInstances[instanceId] |
| 1459 | : undefined; |
| 1460 | return instanceConfig !== undefined |
| 1461 | ? resolveProviderInstanceEnabled(instanceConfig) |
| 1462 | : isUnconfiguredDefaultInstanceEnabled(instanceId); |
| 1463 | }; |
| 1464 | |
| 1465 | export const ServerSettingsOperation = Schema.Literals([ |
| 1466 | "normalize", |
| 1467 | "check-exists", |
| 1468 | "create-provider-instance", |
| 1469 | "read-file", |
| 1470 | "read-provider-history", |
| 1471 | "read-project-settings", |
| 1472 | "read-secret", |
| 1473 | "remove-secret", |
| 1474 | "remove-stale-secret", |
| 1475 | "write-secret", |
| 1476 | "write-file", |
| 1477 | "prepare-directory", |
| 1478 | ]); |
| 1479 | export type ServerSettingsOperation = typeof ServerSettingsOperation.Type; |
| 1480 | |
| 1481 | export class ServerSettingsError extends Schema.TaggedError<ServerSettingsError>()( |
| 1482 | "ServerSettingsError", |
| 1483 | { |
| 1484 | settingsPath: Schema.String, |
| 1485 | operation: ServerSettingsOperation, |
| 1486 | providerInstanceId: Schema.optional(Schema.String), |
| 1487 | environmentVariable: Schema.optional(Schema.String), |
| 1488 | // Validation failures (e.g. a create colliding with an existing |
| 1489 | // instance) originate without an upstream defect. |
| 1490 | cause: Schema.optional(Schema.Defect()), |
| 1491 | }, |
| 1492 | ) { |
| 1493 | override get message(): string { |
| 1494 | const provider = |
| 1495 | this.providerInstanceId === undefined ? "" : ` for provider ${this.providerInstanceId}`; |
| 1496 | const variable = |
| 1497 | this.environmentVariable === undefined |
| 1498 | ? "" |
| 1499 | : ` and environment variable ${this.environmentVariable}`; |
| 1500 | return `Server settings ${this.operation} failed${provider}${variable} at ${this.settingsPath}.`; |
| 1501 | } |
| 1502 | } |
| 1503 | |
| 1504 | // ── Unified type ───────────────────────────────────────────────────── |
| 1505 | |
| 1506 | export type UnifiedSettings = Omit<ServerSettings, keyof ModelPreferences> & ClientSettings; |
| 1507 | export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { |
| 1508 | ...DEFAULT_SERVER_SETTINGS, |
| 1509 | ...DEFAULT_CLIENT_SETTINGS, |
| 1510 | }; |
| 1511 | |
| 1512 | // ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── |
| 1513 | |
| 1514 | const ModelSelectionPatch = Schema.Struct({ |
| 1515 | instanceId: Schema.optionalKey(ProviderInstanceId), |
| 1516 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 1517 | options: Schema.optionalKey(ProviderOptionSelections), |
| 1518 | }); |
| 1519 | |
| 1520 | export const ServerSettingsPatch = Schema.Struct({ |
| 1521 | setModelFavorites: Schema.optionalKey( |
| 1522 | Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })), |
| 1523 | ), |
| 1524 | setModelsHidden: Schema.optionalKey( |
| 1525 | Schema.Array(Schema.Struct({ ...ModelFavorite.fields, hidden: Schema.Boolean })), |
| 1526 | ), |
| 1527 | setProviderModelOrder: Schema.optionalKey( |
| 1528 | Schema.Struct({ provider: ProviderInstanceId, modelOrder: Schema.Array(TrimmedString) }), |
| 1529 | ), |
| 1530 | favorites: Schema.optionalKey(ModelFavorites), |
| 1531 | providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), |
| 1532 | // Applied under the settings service's write lock, only while still unset. |
| 1533 | migrateModelPreferences: Schema.optionalKey( |
| 1534 | Schema.Struct({ |
| 1535 | favorites: Schema.optionalKey(ModelFavorites), |
| 1536 | providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), |
| 1537 | }), |
| 1538 | ), |
| 1539 | worktreeCleanup: Schema.optionalKey( |
| 1540 | Schema.NullOr( |
| 1541 | Schema.Union([ |
| 1542 | Schema.Struct({ mode: Schema.Literal("off") }), |
| 1543 | Schema.Struct({ |
| 1544 | mode: Schema.Literal("custom"), |
| 1545 | rules: Schema.Struct({ |
| 1546 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), |
| 1547 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1548 | worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays), |
| 1549 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1550 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
| 1551 | worktreeUnchanged: Schema.optionalKey(Schema.Boolean), |
| 1552 | }), |
| 1553 | }), |
| 1554 | ]), |
| 1555 | ), |
| 1556 | ), |
| 1557 | storageCleanup: Schema.optionalKey( |
| 1558 | Schema.Struct({ |
| 1559 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), |
| 1560 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1561 | worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays), |
| 1562 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1563 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
| 1564 | worktreeUnchanged: Schema.optionalKey(Schema.Boolean), |
| 1565 | browserArtifactsAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1566 | logsAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1567 | }), |
| 1568 | ), |
| 1569 | worktreesDirectory: Schema.optionalKey(TrimmedString), |
| 1570 | // Server settings |
| 1571 | responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), |
| 1572 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
| 1573 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1574 | enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), |
| 1575 | projectAgentBrowserAccessOverrides: Schema.optionalKey( |
| 1576 | Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), |
| 1577 | ), |
| 1578 | defaultAutoPull: Schema.optionalKey(Schema.Boolean), |
| 1579 | defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), |
| 1580 | projectScriptOverrides: Schema.optionalKey( |
| 1581 | Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), |
| 1582 | ), |
| 1583 | projectAutoPullOverrides: Schema.optionalKey( |
| 1584 | Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), |
| 1585 | ), |
| 1586 | defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1587 | defaultRuntimeMode: Schema.optionalKey(RuntimeMode), |
| 1588 | /** |
| 1589 | * Per-project entry replacement: each entry replaces that project's whole |
| 1590 | * override set and `null` removes it. Clearing one override means resending |
| 1591 | * the entry without that key. Per-key null cannot express "clear" for the |
| 1592 | * keys whose value type is itself nullable, and clients always hold the |
| 1593 | * current entry from the last settings snapshot. |
| 1594 | * |
| 1595 | * `providerInstances` is the one exception: an entry that omits the key |
| 1596 | * keeps the stored value instead of clearing it, so a client built before |
| 1597 | * that field existed cannot wipe it by editing another key. Clearing every |
| 1598 | * provider override for a project means sending `providerInstances: {}`. |
| 1599 | */ |
| 1600 | projectSettingsOverrides: Schema.optionalKey( |
| 1601 | Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), |
| 1602 | ), |
| 1603 | enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), |
| 1604 | enableDeviceSupport: Schema.optionalKey(Schema.Boolean), |
| 1605 | deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), |
| 1606 | deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), |
| 1607 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 1608 | sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), |
| 1609 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1610 | autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), |
| 1611 | snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), |
| 1612 | backgroundActivity: Schema.optionalKey( |
| 1613 | Schema.Struct({ |
| 1614 | schemaVersion: Schema.optionalKey(Schema.Literal(1)), |
| 1615 | profile: Schema.optionalKey(BackgroundActivityProfileSelection), |
| 1616 | baseProfile: Schema.optionalKey(BackgroundActivityProfile), |
| 1617 | overrides: Schema.optionalKey(BackgroundActivityOverrides), |
| 1618 | }), |
| 1619 | ), |
| 1620 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 1621 | providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), |
| 1622 | backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), |
| 1623 | environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), |
| 1624 | defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), |
| 1625 | newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), |
| 1626 | worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), |
| 1627 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
| 1628 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), |
| 1629 | branchNamingMode: Schema.optionalKey(BranchNamingMode), |
| 1630 | branchNamePrefix: Schema.optionalKey(TrimmedString), |
| 1631 | branchNameInstructions: Schema.optionalKey(TrimmedString), |
| 1632 | removeAgentCreditsOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1633 | sourceControlWritingStyle: Schema.optionalKey( |
| 1634 | Schema.Struct({ |
| 1635 | mode: Schema.optionalKey(SourceControlWritingStyleMode), |
| 1636 | customInstructions: Schema.optionalKey(TrimmedString), |
| 1637 | followChangeRequestTemplates: Schema.optionalKey(Schema.Boolean), |
| 1638 | }), |
| 1639 | ), |
| 1640 | sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1641 | reviewGuidesEnabled: Schema.optionalKey(Schema.Boolean), |
| 1642 | reviewGuideModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), |
| 1643 | reviewGuideInstructions: Schema.optionalKey(TrimmedString), |
| 1644 | pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), |
| 1645 | observability: Schema.optionalKey( |
| 1646 | Schema.Struct({ |
| 1647 | otlpTracesUrl: Schema.optionalKey(TrimmedString), |
| 1648 | otlpMetricsUrl: Schema.optionalKey(TrimmedString), |
| 1649 | otlpLogsUrl: Schema.optionalKey(TrimmedString), |
| 1650 | }), |
| 1651 | ), |
| 1652 | /** |
| 1653 | * Merges per host: a patched field replaces the saved one, so a client sends a per-server-host |
| 1654 | * map whole, with the redaction marker for each saved secret it keeps. An empty or omitted |
| 1655 | * secret is removed from the secret store. |
| 1656 | */ |
| 1657 | sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), |
| 1658 | // Whole-map replacement for the new instance config. Patching individual |
| 1659 | // entries is intentionally out of scope: the map is small, and partial |
| 1660 | // patches risk leaving driver-specific config in a half-merged state. |
| 1661 | // The web UI sends a fully-formed map every time it edits this field. |
| 1662 | providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), |
| 1663 | // Per-entry, unlike `providerInstances`: a client only ever adds or removes |
| 1664 | // one source, and sending the whole map races another edit that has not |
| 1665 | // echoed back yet. `null` removes; the server merges into its current map. |
| 1666 | usageLimitSources: Schema.optionalKey( |
| 1667 | Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), |
| 1668 | ), |
| 1669 | cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), |
| 1670 | /** Each entry replaces one model's rates; `null` restores automatic pricing. */ |
| 1671 | usagePriceOverrides: Schema.optionalKey( |
| 1672 | Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), |
| 1673 | ), |
| 1674 | /** Each entry replaces one model's mapping; `null` removes it. */ |
| 1675 | usageModelAliases: Schema.optionalKey( |
| 1676 | Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), |
| 1677 | ), |
| 1678 | }); |
| 1679 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1680 | |
| 1681 | /** A mixed settings patch must be authorized for every configuration domain it changes. */ |
| 1682 | export function requiredScopesForServerSettingsPatch( |
| 1683 | patch: ServerSettingsPatch, |
| 1684 | ): ReadonlyArray<AuthEnvironmentScope> { |
| 1685 | let changesProviders = false; |
| 1686 | let changesSettings = false; |
| 1687 | for (const [key, value] of Object.entries(patch)) { |
| 1688 | if (value === undefined) continue; |
| 1689 | if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { |
| 1690 | changesProviders = true; |
| 1691 | } else { |
| 1692 | changesSettings = true; |
| 1693 | } |
| 1694 | } |
| 1695 | return [ |
| 1696 | ...(changesSettings || !changesProviders ? [AuthSettingsWriteScope] : []), |
| 1697 | ...(changesProviders ? [AuthProvidersManageScope] : []), |
| 1698 | ]; |
| 1699 | } |
| 1700 | |
| 1701 | /** |
| 1702 | * Project scripts run on the host when a worktree is created or a thread |
| 1703 | * settles, so a project mutation that carries them needs the same grant as |
| 1704 | * saving project scripts through settings, on top of `orchestration:operate`. |
| 1705 | */ |
| 1706 | export function requiredScopesForProjectMutation( |
| 1707 | mutation: ProjectMutation, |
| 1708 | ): ReadonlyArray<AuthEnvironmentScope> { |
| 1709 | if (mutation.type === "project.delete" || mutation.scripts === undefined) { |
| 1710 | return [AuthOrchestrationOperateScope]; |
| 1711 | } |
| 1712 | return [ |
| 1713 | AuthOrchestrationOperateScope, |
| 1714 | ...requiredScopesForServerSettingsPatch({ defaultProjectScripts: mutation.scripts }), |
| 1715 | ]; |
| 1716 | } |
| 1717 | |
| 1718 | export const ClientSettingsPatch = Schema.Struct({ |
| 1719 | notificationMode: Schema.optionalKey(NotificationMode), |
| 1720 | inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1721 | diffColorScheme: Schema.optionalKey(DiffColorScheme), |
| 1722 | chatWidth: Schema.optionalKey(ChatWidth), |
| 1723 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), |
| 1724 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), |
| 1725 | appearanceContrast: Schema.optionalKey(AppearanceContrast), |
| 1726 | panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), |
| 1727 | browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), |
| 1728 | browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), |
| 1729 | browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), |
| 1730 | browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), |
| 1731 | browserRecordingShowKeyPresses: Schema.optionalKey(Schema.Boolean), |
| 1732 | browserRecordingShowMousePresses: Schema.optionalKey(Schema.Boolean), |
| 1733 | browserLinkTarget: Schema.optionalKey(BrowserLinkTarget), |
| 1734 | browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), |
| 1735 | browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)), |
| 1736 | browserDefaultProfileId: Schema.optionalKey(BrowserProfileId), |
| 1737 | confirmQuit: Schema.optionalKey(QuitConfirmationMode), |
| 1738 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 1739 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 1740 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), |
| 1741 | diffFilesCollapsed: Schema.optionalKey(Schema.Boolean), |
| 1742 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 1743 | diffLayout: Schema.optionalKey(DiffLayout), |
| 1744 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 1745 | glassOpacity: Schema.optionalKey(GlassOpacity), |
| 1746 | onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 1747 | fontSizeInterface: Schema.optionalKey(InterfaceFontSize), |
| 1748 | fontSizePrompt: Schema.optionalKey(PromptFontSize), |
| 1749 | fontSizeCode: Schema.optionalKey(CodeFontSize), |
| 1750 | fontSizeTerminal: Schema.optionalKey(TerminalFontSize), |
| 1751 | fontFamilyCode: Schema.optionalKey(FontFamilyPreference), |
| 1752 | fontFamilyComposer: Schema.optionalKey(FontFamilyPreference), |
| 1753 | fontFamilySans: Schema.optionalKey(FontFamilyPreference), |
| 1754 | fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), |
| 1755 | fontSmoothing: Schema.optionalKey(Schema.Boolean), |
| 1756 | persistComposerContextStrip: Schema.optionalKey(Schema.Boolean), |
| 1757 | favorites: Schema.optionalKey(ModelFavorites), |
| 1758 | providerModelPreferences: Schema.optionalKey(ProviderModelPreferences), |
| 1759 | pullRequestMergeMethodOverrides: Schema.optionalKey( |
| 1760 | Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), |
| 1761 | ), |
| 1762 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1763 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1764 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |
| 1765 | composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), |
| 1766 | sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), |
| 1767 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), |
| 1768 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1769 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |
| 1770 | legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), |
| 1771 | sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), |
| 1772 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), |
| 1773 | sidebarProjectGroupingOverrides: Schema.optionalKey( |
| 1774 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), |
| 1775 | ), |
| 1776 | sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), |
| 1777 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), |
| 1778 | sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), |
| 1779 | timestampFormat: Schema.optionalKey(TimestampFormat), |
| 1780 | snapShotEnabled: Schema.optionalKey(Schema.Boolean), |
| 1781 | snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), |
| 1782 | snapShotShortcut: Schema.optionalKey(SnapShotShortcut), |
| 1783 | snapShotPlaySound: Schema.optionalKey(Schema.Boolean), |
| 1784 | snapShotSound: Schema.optionalKey(SnapShotSound), |
| 1785 | snapShotFlash: Schema.optionalKey(Schema.Boolean), |
| 1786 | snapShotAnimations: Schema.optionalKey(Schema.Boolean), |
| 1787 | wordWrap: Schema.optionalKey(Schema.Boolean), |
| 1788 | }); |
| 1789 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; |