1import { SshDeviceHostConfigs } from "./device.ts";
2import {
3 AuthOrchestrationOperateScope,
4 AuthSettingsWriteScope,
5 AuthProvidersManageScope,
6 type AuthEnvironmentScope,
7} from "./auth.ts";
8import * as Effect from "effect/Effect";
9import * as Duration from "effect/Duration";
10import * as Schema from "effect/Schema";
11import * as SchemaTransformation from "effect/SchemaTransformation";
12import {
13 ForwardCompatibleNullable,
14 ForwardCompatibleOptional,
15 OmittedWhenNull,
16 ProjectId,
17 ThreadId,
18 TrimmedNonEmptyString,
19 TrimmedString,
20} from "./baseSchemas.ts";
21import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
22import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts";
23import { KeybindingShortcut } from "./keybindings.ts";
24import {
25 CustomModelSetting,
26 DEFAULT_TEXT_GENERATION_MODEL,
27 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
28 ProviderOptionSelections,
29} from "./model.ts";
30import { ModelSelection } from "./modelSelection.ts";
31import { ProjectScript, type ProjectMutation } from "./project.ts";
32import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts";
33import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
34import {
35 DEFAULT_PREVIEW_APPEARANCE,
36 DEFAULT_PREVIEW_ZOOM_FACTOR,
37 FILL_PREVIEW_VIEWPORT,
38 PreviewAppearancePreference,
39 PreviewViewportSetting,
40 PreviewZoomFactor,
41} from "./preview.ts";
42import {
43 ProviderInstanceConfig,
44 ProviderInstanceId,
45 ProviderDriverKind,
46} from "./providerInstance.ts";
47import { PullRequestMergeMethod } from "./pullRequest.ts";
48import { SharedAccountId } from "./sharedAccounts.ts";
49
50// ── Client Settings (local-only) ───────────────────────────────
51
52export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
53export type TimestampFormat = typeof TimestampFormat.Type;
54const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
55
56export const DiffLayout = Schema.Literals(["stacked", "split"]);
57export type DiffLayout = typeof DiffLayout.Type;
58const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked";
59
60export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
61export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
62export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
63
64export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]);
65export 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.
68const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
69
70export const SidebarProjectGroupingMode = Schema.Literals([
71 "repository",
72 "repository_path",
73 "separate",
74]);
75export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
76const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
77export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1;
78export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15;
79export const SidebarThreadPreviewCount = Schema.Int.check(
80 Schema.isBetween({
81 minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
82 maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
83 }),
84);
85export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type;
86const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6;
87export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1;
88export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90;
89export 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);
95export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
96const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3;
97export const MIN_GLASS_OPACITY = 40;
98export const MAX_GLASS_OPACITY = 100;
99export const GlassOpacity = Schema.Int.check(
100 Schema.isBetween({
101 minimum: MIN_GLASS_OPACITY,
102 maximum: MAX_GLASS_OPACITY,
103 }),
104);
105export type GlassOpacity = typeof GlassOpacity.Type;
106const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;
107
108export const MIN_APPEARANCE_CONTRAST = 50;
109export const MAX_APPEARANCE_CONTRAST = 200;
110export const AppearanceContrast = Schema.Int.check(
111 Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }),
112);
113export type AppearanceContrast = typeof AppearanceContrast.Type;
114const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
115export const MIN_PANEL_ANIMATION_DURATION_MS = 0;
116export const MAX_PANEL_ANIMATION_DURATION_MS = 400;
117export const PanelAnimationDurationMs = Schema.Int.check(
118 Schema.isBetween({
119 minimum: MIN_PANEL_ANIMATION_DURATION_MS,
120 maximum: MAX_PANEL_ANIMATION_DURATION_MS,
121 }),
122);
123export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type;
124const 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 */
130export const MIN_INTERFACE_FONT_SIZE = 12;
131export const MAX_INTERFACE_FONT_SIZE = 20;
132export const InterfaceFontSize = Schema.Int.check(
133 Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }),
134);
135export type InterfaceFontSize = typeof InterfaceFontSize.Type;
136export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16;
137
138export const MIN_PROMPT_FONT_SIZE = 12;
139export const MAX_PROMPT_FONT_SIZE = 20;
140export const PromptFontSize = Schema.Int.check(
141 Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }),
142);
143export type PromptFontSize = typeof PromptFontSize.Type;
144export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14;
145
146export const MIN_CODE_FONT_SIZE = 10;
147export const MAX_CODE_FONT_SIZE = 18;
148export const CodeFontSize = Schema.Int.check(
149 Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }),
150);
151export type CodeFontSize = typeof CodeFontSize.Type;
152export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13;
153
154export const MIN_TERMINAL_FONT_SIZE = 8;
155export const MAX_TERMINAL_FONT_SIZE = 20;
156export const TerminalFontSize = Schema.Int.check(
157 Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }),
158);
159export type TerminalFontSize = typeof TerminalFontSize.Type;
160const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12;
161
162export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]);
163export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type;
164export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork";
165
166export 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);
177export type SnapShotKeyChord = typeof SnapShotKeyChord.Type;
178export const SNAP_SHOT_MODIFIERS = ["shift", "meta", "control", "alt"] as const;
179export const SnapShotModifier = Schema.Literals(SNAP_SHOT_MODIFIERS);
180export type SnapShotModifier = typeof SnapShotModifier.Type;
181export 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]);
186export type SnapShotShortcut = typeof SnapShotShortcut.Type;
187export const SnapShotSound = Schema.Literals(["soft-pop", "camera-shutter"]);
188export type SnapShotSound = typeof SnapShotSound.Type;
189const DEFAULT_SNAP_SHOT_SOUND: SnapShotSound = "soft-pop";
190
191export type SnapShotModifierPairShortcut = Extract<SnapShotShortcut, { readonly kind: string }>;
192
193export function isModifierPairShortcut(
194 shortcut: SnapShotShortcut,
195): shortcut is SnapShotModifierPairShortcut {
196 return "kind" in shortcut;
197}
198
199export function snapShotShortcutModifierPair(
200 shortcut: SnapShotModifierPairShortcut,
201): SnapShotModifier {
202 return shortcut.kind === "both-shift-keys" ? "shift" : shortcut.modifier;
203}
204
205const APPLE_MODIFIER_LABELS: Record<SnapShotModifier, string> = {
206 shift: "Shift",
207 meta: "Command",
208 control: "Control",
209 alt: "Option",
210};
211const OTHER_MODIFIER_LABELS: Record<SnapShotModifier, string> = {
212 shift: "Shift",
213 meta: "Super",
214 control: "Ctrl",
215 alt: "Alt",
216};
217
218export function snapShotModifierPairLabel(modifier: SnapShotModifier, apple: boolean): string {
219 const label = (apple ? APPLE_MODIFIER_LABELS : OTHER_MODIFIER_LABELS)[modifier];
220 return `${label} + ${label}`;
221}
222const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = {
223 kind: "both-shift-keys",
224};
225
226export const NotificationMode = Schema.Literals([
227 "off",
228 "notifications",
229 "sound",
230 "notifications-and-sound",
231]);
232export type NotificationMode = typeof NotificationMode.Type;
233
234export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]);
235export type QuitConfirmationMode = typeof QuitConfirmationMode.Type;
236const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold";
237
238const 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
248const 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 */
254export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200));
255export 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 */
264export 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.
268export 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 */
277export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT;
278export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
279export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const;
280export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES);
281export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type;
282export 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 */
288export const BrowserLinkTarget = Schema.Literals(["system", "app"]);
289export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
290export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
291
292export const LoadBalancingWeights = Schema.Record(
293 TrimmedNonEmptyString,
294 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })),
295);
296
297export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);
298
299/** Maximum width of the chat timeline and composer on wide screens. */
300export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]);
301export type ChatWidth = typeof ChatWidth.Type;
302
303/** Preferences shared by clients connected to the same environment. */
304export 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});
309export const ModelFavorites = Schema.Array(ModelFavorite);
310export 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);
317export type ModelPreferences = {
318 readonly favorites: typeof ModelFavorites.Type;
319 readonly providerModelPreferences: typeof ProviderModelPreferences.Type;
320};
321
322export 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});
516export type ClientSettings = typeof ClientSettingsSchema.Type;
517
518export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({});
519
520// ── Server Settings (server-authoritative) ────────────────────
521
522const 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. */
528export const UsageModelPriceOverride = Schema.Struct({
529 inputCostPerMillionTokens: UsageModelTokenPrice,
530 outputCostPerMillionTokens: UsageModelTokenPrice,
531 cacheReadCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice),
532 cacheWriteCostPerMillionTokens: Schema.optionalKey(UsageModelTokenPrice),
533});
534export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type;
535
536/** A binary path setting that decodes empty input to the provider's default executable. */
537export 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
549export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select";
550
551export interface ProviderSettingsFormOption {
552 readonly value: string;
553 readonly label: string;
554}
555
556export 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. */
566export 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
574export interface ProviderSettingsFormSchemaAnnotation {
575 readonly order?: readonly string[] | undefined;
576}
577
578declare 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. */
590export function secretSetting<S extends Schema.Top>(schema: S): S["Rebuild"] {
591 return schema.annotateKey({ settingsSecret: true });
592}
593
594export function isSecretSetting(schema: Schema.Top): boolean {
595 const annotations = Schema.resolveAnnotationsKey(schema) ?? Schema.resolveAnnotations(schema);
596 return annotations?.settingsSecret === true;
597}
598
599export 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`. */
605export 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
619export 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);
677export 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.
682const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/;
683
684export 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);
747export 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 */
754export 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>;
760export const AntigravityAuthMethod = Schema.Literals(
761 ANTIGRAVITY_AUTH_METHODS.map((method) => method.value),
762);
763export type AntigravityAuthMethod = typeof AntigravityAuthMethod.Type;
764
765export 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);
829export 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 */
837export 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});
844export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
845
846/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */
847export 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 */
858export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown);
859export type SourceControlHostSettings = typeof SourceControlHostSettings.Type;
860
861export 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});
866export type ObservabilitySettings = typeof ObservabilitySettings.Type;
867
868export const SourceControlWritingStyleMode = Schema.Literals([
869 "repo_conventions",
870 "conventional_commits",
871 "custom",
872]);
873export type SourceControlWritingStyleMode = typeof SourceControlWritingStyleMode.Type;
874
875export 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});
884export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;
885
886export const BranchNamingMode = Schema.Literals(["static", "semantic", "custom"]);
887export type BranchNamingMode = typeof BranchNamingMode.Type;
888
889export interface BranchNamingOptions {
890 mode: BranchNamingMode;
891 prefix: string;
892 instructions: string;
893}
894
895export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
896export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5);
897
898export const BackgroundActivityProfile = Schema.Literals([
899 "balanced",
900 "performance",
901 "battery-saver",
902]);
903export type BackgroundActivityProfile = typeof BackgroundActivityProfile.Type;
904export const DEFAULT_BACKGROUND_ACTIVITY_PROFILE: BackgroundActivityProfile = "balanced";
905
906export const BackgroundActivityProfileSelection = Schema.Literals([
907 "balanced",
908 "performance",
909 "battery-saver",
910 "custom",
911]);
912export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfileSelection.Type;
913
914export 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});
925export type BackgroundActivityOverrides = typeof BackgroundActivityOverrides.Type;
926
927export 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({})));
935export 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 */
942export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph"]);
943export 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 */
951const StorageRetentionDays = Schema.NullOr(
952 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })),
953);
954const SettledWorktreeRetentionDays = Schema.NullOr(
955 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3650 })),
956);
957
958export const WorktreeKeepWhen = Schema.Literals([
959 "any-local-files",
960 "uncommitted-changes",
961 "tracked-changes",
962]);
963export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type;
964
965export 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});
977export type WorktreeCleanupRules = typeof WorktreeCleanupRules.Type;
978
979export 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);
985export type WorktreeCleanup = typeof WorktreeCleanup.Type;
986
987export 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;
1014export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
1015
1016export const ProjectProviderInstanceOverride = Schema.Struct({
1017 enabled: Schema.optionalKey(Schema.Boolean),
1018});
1019export 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 */
1035export 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>);
1065export 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 */
1073export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean {
1074 return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key);
1075}
1076const 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
1088export 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});
1098export type StorageCleanupReportEntry = typeof StorageCleanupReportEntry.Type;
1099export 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});
1108export type StorageCleanupReport = typeof StorageCleanupReport.Type;
1109
1110export 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});
1126export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
1127
1128export 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});
1377export type ServerSettings = typeof ServerSettings.Type;
1378
1379export 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 */
1387export 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 */
1399const 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. */
1406const 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 */
1415export 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 */
1422const 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 */
1431export 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 */
1447export 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
1465export 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]);
1479export type ServerSettingsOperation = typeof ServerSettingsOperation.Type;
1480
1481export 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
1506export type UnifiedSettings = Omit<ServerSettings, keyof ModelPreferences> & ClientSettings;
1507export 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
1514const ModelSelectionPatch = Schema.Struct({
1515 instanceId: Schema.optionalKey(ProviderInstanceId),
1516 model: Schema.optionalKey(TrimmedNonEmptyString),
1517 options: Schema.optionalKey(ProviderOptionSelections),
1518});
1519
1520export 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});
1679export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
1680
1681/** A mixed settings patch must be authorized for every configuration domain it changes. */
1682export 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 */
1706export 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
1718export 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});
1789export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;