diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx
index 49ccf378da..59359c5fbd 100644
--- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx
+++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx
@@ -1,9 +1,16 @@
'use client';
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { motion } from 'motion/react';
+import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox';
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from '@/components/ui/accordion';
import { useModelSelectorList } from '@/app/api/openrouter/hooks';
import { useOnboarding } from './OnboardingContext';
import { PRESETS } from './onboarding.domain';
@@ -171,15 +178,11 @@ const ROLES = [
function ModelRolePickers({
models,
- isCustom,
- onUpdate,
modelOptions,
isLoadingModels,
modelsError,
}: {
models: { mayor: string; refinery: string; polecat: string };
- isCustom: boolean;
- onUpdate: (models: { mayor?: string; refinery?: string; polecat?: string }) => void;
modelOptions: ModelOption[];
isLoadingModels: boolean;
modelsError: string | undefined;
@@ -202,23 +205,107 @@ function ModelRolePickers({
label=""
models={modelOptions}
value={models[key]}
- onValueChange={value => onUpdate({ ...models, [key]: value })}
+ onValueChange={() => {}}
isLoading={isLoadingModels}
error={modelsError}
placeholder="Select a model"
- disabled={!isCustom}
- className={cn(
- 'border-white/[0.08] bg-white/[0.03] text-sm text-white/85',
- !isCustom && 'opacity-70'
- )}
+ disabled
+ className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 opacity-70"
/>
))}
- {!isCustom && (
-
- Select “Custom” to change models
-
- )}
+
+ Select “Custom” to change models
+
+
+
+ );
+}
+
+function CustomModelPicker({
+ customDefault,
+ setCustomDefault,
+ customMayor,
+ setCustomMayor,
+ customRefinery,
+ setCustomRefinery,
+ customPolecat,
+ setCustomPolecat,
+ modelOptions,
+ isLoadingModels,
+}: {
+ customDefault: string;
+ setCustomDefault: (v: string) => void;
+ customMayor: string;
+ setCustomMayor: (v: string) => void;
+ customRefinery: string;
+ setCustomRefinery: (v: string) => void;
+ customPolecat: string;
+ setCustomPolecat: (v: string) => void;
+ modelOptions: ModelOption[];
+ isLoadingModels: boolean;
+}) {
+ const roleRows: [string, string, (v: string) => void][] = [
+ ['Mayor', customMayor, setCustomMayor],
+ ['Refinery', customRefinery, setCustomRefinery],
+ ['Polecat', customPolecat, setCustomPolecat],
+ ];
+
+ return (
+
+
+ {/* Primary default model */}
+
+
+
+
+
+ {/* Per-role overrides — collapsible */}
+
+
+
+ Override by role (optional)
+
+
+ {roleRows.map(([label, value, setValue]) => (
+
+ {label}
+
+ {value && (
+
+ )}
+
+ ))}
+
+
+
);
@@ -227,6 +314,11 @@ function ModelRolePickers({
export function OnboardingStepModel() {
const { state, setModelPreset, setCustomModels } = useOnboarding();
+ const [customDefault, setCustomDefault] = useState(state.customModels.defaultModel ?? '');
+ const [customMayor, setCustomMayor] = useState(state.customModels.mayor ?? '');
+ const [customRefinery, setCustomRefinery] = useState(state.customModels.refinery ?? '');
+ const [customPolecat, setCustomPolecat] = useState(state.customModels.polecat ?? '');
+
// Fetch available models for the Custom picker (no org context during onboarding)
const {
data: modelsData,
@@ -239,15 +331,8 @@ export function OnboardingStepModel() {
[modelsData]
);
- // Resolve current models for display: preset values or custom overrides
- const currentModels = useMemo(() => {
- if (state.modelPreset === 'custom') {
- return {
- mayor: state.customModels.mayor ?? 'kilo-auto/balanced',
- refinery: state.customModels.refinery ?? 'kilo-auto/balanced',
- polecat: state.customModels.polecat ?? 'kilo-auto/balanced',
- };
- }
+ // Resolve current models for display in the read-only preset role picker
+ const currentPresetModels = useMemo(() => {
const preset = PRESETS.find(p => p.key === state.modelPreset);
if (preset) return preset.models;
return {
@@ -255,7 +340,7 @@ export function OnboardingStepModel() {
refinery: 'kilo-auto/balanced',
polecat: 'kilo-auto/balanced',
};
- }, [state.modelPreset, state.customModels]);
+ }, [state.modelPreset]);
const isCustom = state.modelPreset === 'custom';
@@ -263,8 +348,25 @@ export function OnboardingStepModel() {
setModelPreset(preset);
}
- function handleCustomUpdate(models: { mayor?: string; refinery?: string; polecat?: string }) {
- setCustomModels(models);
+ // Sync custom model state up to context whenever any field changes
+ function handleSetCustomDefault(value: string) {
+ setCustomDefault(value);
+ setCustomModels({ defaultModel: value, mayor: customMayor, refinery: customRefinery, polecat: customPolecat });
+ }
+
+ function handleSetCustomMayor(value: string) {
+ setCustomMayor(value);
+ setCustomModels({ defaultModel: customDefault, mayor: value, refinery: customRefinery, polecat: customPolecat });
+ }
+
+ function handleSetCustomRefinery(value: string) {
+ setCustomRefinery(value);
+ setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: value, polecat: customPolecat });
+ }
+
+ function handleSetCustomPolecat(value: string) {
+ setCustomPolecat(value);
+ setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: customRefinery, polecat: value });
}
return (
@@ -292,15 +394,31 @@ export function OnboardingStepModel() {
handlePresetSelect('custom')} />
- {/* Always-visible model role pickers */}
-
+ {/* Preset model role pickers (read-only) — shown when a preset is active */}
+ {!isCustom && (
+
+ )}
+
+ {/* Custom model picker — shown when custom is selected */}
+ {isCustom && (
+
+ )}
);
diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts
index 107214e8c6..2d68b45f9a 100644
--- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts
+++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts
@@ -43,9 +43,11 @@ export function resolveGitUrlFromRepo(
export type ModelPreset = 'frontier' | 'balanced' | 'cost-effective' | 'free' | 'custom';
export type CustomModels = {
+ defaultModel?: string;
mayor?: string;
refinery?: string;
polecat?: string;
+ smallModel?: string;
};
export type PresetConfig = {
@@ -110,14 +112,16 @@ export const PRESETS: PresetConfig[] = [
/** Derive the config shape stored in OnboardingState from a preset. */
export function presetToConfig(preset: ModelPreset, customModels: CustomModels) {
if (preset === 'custom') {
- const mayorModel = customModels.mayor ?? 'kilo-auto/balanced';
+ const fallback = 'kilo-auto/balanced';
+ const defaultModel = customModels.defaultModel || fallback;
return {
- default_model: mayorModel,
+ default_model: defaultModel,
role_models: {
- mayor: mayorModel,
- refinery: customModels.refinery ?? 'kilo-auto/balanced',
- polecat: customModels.polecat ?? 'kilo-auto/balanced',
+ mayor: customModels.mayor || defaultModel,
+ refinery: customModels.refinery || defaultModel,
+ polecat: customModels.polecat || defaultModel,
},
+ small_model: customModels.smallModel || undefined,
};
}
@@ -133,10 +137,21 @@ export function presetToConfig(preset: ModelPreset, customModels: CustomModels)
if (refinery !== mayor) role_models.refinery = refinery;
if (polecat !== mayor) role_models.polecat = polecat;
- return {
+ const config: {
+ default_model: string;
+ role_models: Record;
+ small_model?: string;
+ } = {
default_model: mayor,
role_models,
};
+
+ // When the free preset is selected, also set small_model to the same free model
+ if (mayor === 'kilo-auto/free') {
+ config.small_model = 'kilo-auto/free';
+ }
+
+ return config;
}
// ---------------------------------------------------------------------------
diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts
index 6a1a28dc0e..f709654fa3 100644
--- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts
+++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts
@@ -194,6 +194,7 @@ describe('presetToConfig', () => {
test('returns custom config with provided models', () => {
const config = presetToConfig('custom', {
+ defaultModel: 'openai/gpt-4.1',
mayor: 'openai/gpt-4.1',
refinery: 'anthropic/claude-opus-4',
polecat: 'openai/gpt-4.1-mini',
@@ -216,9 +217,19 @@ describe('presetToConfig', () => {
});
});
- test('uses kilo-auto/balanced for partially-specified custom models', () => {
- const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' });
+ test('uses defaultModel as fallback for unset role overrides', () => {
+ const config = presetToConfig('custom', { defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1' });
expect(config.default_model).toBe('openai/gpt-4.1');
+ expect(config.role_models).toEqual({
+ mayor: 'openai/gpt-4.1',
+ refinery: 'openai/gpt-4.1',
+ polecat: 'openai/gpt-4.1',
+ });
+ });
+
+ test('uses kilo-auto/balanced for default_model when no defaultModel specified', () => {
+ const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' });
+ expect(config.default_model).toBe('kilo-auto/balanced');
expect(config.role_models).toEqual({
mayor: 'openai/gpt-4.1',
refinery: 'kilo-auto/balanced',
diff --git a/services/gastown/src/dos/town/pr-feedback.test.ts b/services/gastown/src/dos/town/pr-feedback.test.ts
index 4320437188..9f62c30c02 100644
--- a/services/gastown/src/dos/town/pr-feedback.test.ts
+++ b/services/gastown/src/dos/town/pr-feedback.test.ts
@@ -16,12 +16,12 @@ describe('TownConfigSchema refinery extensions', () => {
expect(config.refinery?.code_review).toBe(false);
});
- it('defaults auto_resolve_pr_feedback to false', () => {
+ it('defaults auto_resolve_pr_feedback to true', () => {
const config = TownConfigSchema.parse({});
- expect(config.refinery).toBeUndefined();
+ expect(config.refinery?.auto_resolve_pr_feedback).toBe(true);
const configWithRefinery = TownConfigSchema.parse({ refinery: {} });
- expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(false);
+ expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(true);
});
it('defaults auto_merge_delay_minutes to null', () => {
diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts
index 73b7bf604b..60c1ecf67e 100644
--- a/services/gastown/src/types.ts
+++ b/services/gastown/src/types.ts
@@ -264,7 +264,7 @@ export const TownConfigSchema = z.object({
* - 'direct': Refinery pushes directly to main (no PR)
* - 'pr': Refinery creates a GitHub PR / GitLab MR for human review
*/
- merge_strategy: MergeStrategy.default('direct'),
+ merge_strategy: MergeStrategy.default('pr'),
/** Refinery configuration */
refinery: z
@@ -279,19 +279,19 @@ export const TownConfigSchema = z.object({
/** Controls how the refinery communicates review findings:
* - 'rework': creates internal rework beads via gt_request_changes (default)
* - 'comments': posts GitHub review comments on the PR (requires merge_strategy: 'pr') */
- review_mode: z.enum(['rework', 'comments']).default('rework'),
+ review_mode: z.enum(['rework', 'comments']).default('comments'),
/** When enabled, a polecat is automatically dispatched to address
* unresolved review comments and failing CI checks on open PRs. */
- auto_resolve_pr_feedback: z.boolean().default(false),
+ auto_resolve_pr_feedback: z.boolean().default(true),
/** When enabled, a polecat is automatically dispatched to rebase and
* resolve merge conflicts on open PRs. */
auto_resolve_merge_conflicts: z.boolean().default(true).optional(),
/** After all CI checks pass and all review threads are resolved,
* automatically merge the PR after this many minutes.
* 0 = immediate, null = disabled (require manual merge). */
- auto_merge_delay_minutes: z.number().int().min(0).nullable().default(null),
+ auto_merge_delay_minutes: z.number().int().min(0).nullable().default(5),
})
- .optional(),
+ .default({}),
/** Alarm interval when agents are active (seconds) */
alarm_interval_active: z.number().int().min(5).max(600).optional(),
@@ -307,7 +307,7 @@ export const TownConfigSchema = z.object({
.optional(),
/** When true, all convoys are created as staged by default (agents not dispatched until started). */
- staged_convoys_default: z.boolean().default(false),
+ staged_convoys_default: z.boolean().default(true),
/** Default merge mode for new convoys.
* - 'review-then-land': beads merge into a convoy feature branch, then a single landing PR is created (default)