Skip to content

Commit 4e3bfb2

Browse files
authored
feat: model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes (#62)
* fix: Added Apple Silicon Acceleration for Vexor Semantic Search * feat: implement spec/model-selection-settings * feat: implement spec/model-selection-settings * fix: remove auto-stash from worktree sync, fix CWD shell issue, require clean working tree * chore: untrack plan file (gitignored) * feat: set default models to Sonnet 4.6, add prettier and golangci-lint installer support * fix: model selection settings — injection paths, UI, build, and test isolation - Fix command injection writing to wrong path (~/.claude/pilot/commands/ instead of ~/.claude/commands/) - Inject model into global ~/.claude/settings.json so Console settings always override installer three-way merge - Add model_config.py, settings_injector.py, tips.py to Cython MODULE_ORDER - Fix multi-line import strip regex eating next line's indentation - Restore accidentally deleted statusline/tips.py - Fix TS2556 spread argument error in settings-routes test - Default model changed to Opus 4.6 - Reorganize Settings UI: General + Spec Flow sections, default column, sticky save bar, collapsible pricing, 1M subscription note - Remove ModelRoutingInfo from Usage page, increase chart height - Add Settings screenshot to website console carousel - Mock get_max_context_tokens in tests for environment isolation - Update research-tools rule for WebFetch/WebSearch blocking
1 parent a5f50cc commit 4e3bfb2

62 files changed

Lines changed: 2341 additions & 333 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,17 @@ Discuss → Plan → Approve → Implement → Verify → Done
210210

211211
Pilot uses the right model for each phase — Opus where reasoning quality matters most, Sonnet where speed and cost matter:
212212

213-
| Phase | Model | Why |
214-
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
215-
| **Planning** | Opus | Exploring your codebase, designing architecture, and writing the spec requires deep reasoning. A good plan is the foundation of everything. |
216-
| **Plan Verification** | Opus | Catching gaps, missing edge cases, and requirement mismatches before implementation saves expensive rework. |
217-
| **Implementation** | Sonnet | With a solid plan, writing code is straightforward. Sonnet is fast, cost-effective, and produces high-quality code when guided by a clear spec. |
218-
| **Code Verification** | Opus | Independent code review against the plan requires the same reasoning depth as planning — catching subtle bugs, logic errors, and spec deviations. |
213+
| Phase | Default | Why |
214+
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
215+
| **Planning** | Opus | Exploring your codebase, designing architecture, and writing the spec requires deep reasoning. A good plan is the foundation of everything. |
216+
| **Plan Verification** | Opus | Catching gaps, missing edge cases, and requirement mismatches before implementation saves expensive rework. |
217+
| **Implementation** | Sonnet | With a solid plan, writing code is straightforward. Sonnet is fast, cost-effective, and produces high-quality code when guided by a clear spec. |
218+
| **Code Verification** | Opus | Independent code review against the plan requires the same reasoning depth as planning — catching subtle bugs, logic errors, and spec deviations. |
219219

220220
**The insight:** Implementation is the easy part when the plan is good and verification is thorough. Pilot invests reasoning power where it has the highest impact — planning and verification — and uses fast execution where a clear spec makes quality predictable.
221221

222+
**Configurable:** All model assignments are configurable per-component via the Pilot Console (`localhost:41777/#/settings`). Choose between Sonnet 4.6, Sonnet 4.6 1M, Opus 4.6, and Opus 4.6 1M for the main session and each command. Sub-agents always use the base model (no 1M). **Note:** 1M context models require a compatible Anthropic subscription — not available to all users.
223+
222224
### Quick Mode
223225

224226
Just chat. No plan file, no approval gate. All quality hooks and TDD enforcement still apply.

console/src/services/worker-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { UsageRoutes } from "./worker/http/routes/UsageRoutes.js";
6868
import { LicenseRoutes } from "./worker/http/routes/LicenseRoutes.js";
6969
import { VaultRoutes } from "./worker/http/routes/VaultRoutes.js";
7070
import { VexorRoutes } from "./worker/http/routes/VexorRoutes.js";
71+
import { SettingsRoutes } from "./worker/http/routes/SettingsRoutes.js";
7172
import { MetricsService } from "./worker/MetricsService.js";
7273
import { startRetentionScheduler, stopRetentionScheduler } from "./worker/RetentionScheduler.js";
7374

@@ -267,6 +268,7 @@ export class WorkerService {
267268
this.server.registerRoutes(new UsageRoutes());
268269
this.server.registerRoutes(new LicenseRoutes());
269270
this.server.registerRoutes(new VaultRoutes());
271+
this.server.registerRoutes(new SettingsRoutes());
270272

271273
startRetentionScheduler(this.dbManager);
272274
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* SettingsRoutes
3+
*
4+
* API endpoints for reading and writing model preferences from ~/.pilot/config.json.
5+
*
6+
* GET /api/settings - Returns current model config with defaults merged in
7+
* PUT /api/settings - Partial update of model preferences (merge, not replace)
8+
*/
9+
10+
import express, { type Request, type Response } from "express";
11+
import * as fs from "fs";
12+
import * as os from "os";
13+
import * as path from "path";
14+
import { BaseRouteHandler } from "../BaseRouteHandler.js";
15+
import { logger } from "../../../../utils/logger.js";
16+
17+
export const MODEL_CHOICES_FULL: readonly string[] = ["sonnet", "sonnet[1m]", "opus", "opus[1m]"];
18+
export const MODEL_CHOICES_AGENT: readonly string[] = ["sonnet", "opus"];
19+
20+
export interface ModelSettings {
21+
model: string;
22+
commands: Record<string, string>;
23+
agents: Record<string, string>;
24+
}
25+
26+
export const DEFAULT_SETTINGS: ModelSettings = {
27+
model: "opus",
28+
commands: {
29+
spec: "sonnet",
30+
"spec-plan": "opus",
31+
"spec-implement": "sonnet",
32+
"spec-verify": "opus",
33+
vault: "sonnet",
34+
sync: "sonnet",
35+
learn: "sonnet",
36+
},
37+
agents: {
38+
"plan-challenger": "sonnet",
39+
"plan-verifier": "sonnet",
40+
"spec-reviewer-compliance": "sonnet",
41+
"spec-reviewer-quality": "opus",
42+
},
43+
};
44+
45+
export class SettingsRoutes extends BaseRouteHandler {
46+
private readonly configPath: string;
47+
48+
constructor(configPath?: string) {
49+
super();
50+
this.configPath = configPath ?? path.join(os.homedir(), ".pilot", "config.json");
51+
}
52+
53+
setupRoutes(app: express.Application): void {
54+
app.get("/api/settings", this.wrapHandler(this.handleGet.bind(this)));
55+
app.put("/api/settings", this.wrapHandler(this.handlePut.bind(this)));
56+
}
57+
58+
private readConfig(): Record<string, unknown> {
59+
try {
60+
const raw = fs.readFileSync(this.configPath, "utf-8");
61+
return JSON.parse(raw) as Record<string, unknown>;
62+
} catch {
63+
return {};
64+
}
65+
}
66+
67+
private mergeWithDefaults(raw: Record<string, unknown>): ModelSettings {
68+
const mainModel =
69+
typeof raw.model === "string" && MODEL_CHOICES_FULL.includes(raw.model)
70+
? raw.model
71+
: DEFAULT_SETTINGS.model;
72+
73+
const rawCommands = raw.commands;
74+
const mergedCommands: Record<string, string> = { ...DEFAULT_SETTINGS.commands };
75+
if (rawCommands && typeof rawCommands === "object" && !Array.isArray(rawCommands)) {
76+
for (const [k, v] of Object.entries(rawCommands as Record<string, unknown>)) {
77+
if (typeof v === "string" && MODEL_CHOICES_FULL.includes(v)) {
78+
mergedCommands[k] = v;
79+
}
80+
}
81+
}
82+
83+
const rawAgents = raw.agents;
84+
const mergedAgents: Record<string, string> = { ...DEFAULT_SETTINGS.agents };
85+
if (rawAgents && typeof rawAgents === "object" && !Array.isArray(rawAgents)) {
86+
for (const [k, v] of Object.entries(rawAgents as Record<string, unknown>)) {
87+
if (typeof v === "string" && MODEL_CHOICES_AGENT.includes(v)) {
88+
mergedAgents[k] = v;
89+
}
90+
}
91+
}
92+
93+
return { model: mainModel, commands: mergedCommands, agents: mergedAgents };
94+
}
95+
96+
private validateSettings(body: Record<string, unknown>): string | null {
97+
if (body.model !== undefined) {
98+
if (typeof body.model !== "string" || !MODEL_CHOICES_FULL.includes(body.model)) {
99+
return `Invalid model '${body.model}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
100+
}
101+
}
102+
103+
if (body.commands !== undefined) {
104+
if (typeof body.commands !== "object" || Array.isArray(body.commands)) {
105+
return "commands must be an object";
106+
}
107+
for (const [cmd, model] of Object.entries(body.commands as Record<string, unknown>)) {
108+
if (typeof model !== "string" || !MODEL_CHOICES_FULL.includes(model)) {
109+
return `Invalid model '${model}' for command '${cmd}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
110+
}
111+
}
112+
}
113+
114+
if (body.agents !== undefined) {
115+
if (typeof body.agents !== "object" || Array.isArray(body.agents)) {
116+
return "agents must be an object";
117+
}
118+
for (const [agent, model] of Object.entries(body.agents as Record<string, unknown>)) {
119+
if (typeof model !== "string" || !MODEL_CHOICES_AGENT.includes(model)) {
120+
return `Invalid model '${model}' for agent '${agent}'; agents can only use: ${MODEL_CHOICES_AGENT.join(", ")} (no 1M context)`;
121+
}
122+
}
123+
}
124+
125+
return null;
126+
}
127+
128+
private writeConfigAtomic(data: Record<string, unknown>): void {
129+
const dir = path.dirname(this.configPath);
130+
fs.mkdirSync(dir, { recursive: true });
131+
const tmpPath = this.configPath + ".tmp";
132+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
133+
fs.renameSync(tmpPath, this.configPath);
134+
}
135+
136+
async handleGet(_req: Request, res: Response): Promise<void> {
137+
const raw = this.readConfig();
138+
const settings = this.mergeWithDefaults(raw);
139+
res.json(settings);
140+
}
141+
142+
async handlePut(req: Request, res: Response): Promise<void> {
143+
const body = req.body as Record<string, unknown>;
144+
145+
const error = this.validateSettings(body);
146+
if (error) {
147+
this.badRequest(res, error);
148+
return;
149+
}
150+
151+
const existing = this.readConfig();
152+
153+
if (body.model !== undefined) {
154+
existing.model = body.model;
155+
}
156+
if (body.commands !== undefined) {
157+
const existingCommands = (existing.commands as Record<string, unknown>) ?? {};
158+
existing.commands = { ...existingCommands, ...(body.commands as Record<string, unknown>) };
159+
}
160+
if (body.agents !== undefined) {
161+
const existingAgents = (existing.agents as Record<string, unknown>) ?? {};
162+
existing.agents = { ...existingAgents, ...(body.agents as Record<string, unknown>) };
163+
}
164+
165+
try {
166+
this.writeConfigAtomic(existing);
167+
} catch (err) {
168+
logger.error("HTTP", "Failed to write settings config", {}, err as Error);
169+
res.status(500).json({ error: "Failed to save settings" });
170+
return;
171+
}
172+
173+
const updated = this.mergeWithDefaults(existing);
174+
res.json(updated);
175+
}
176+
}

console/src/ui/viewer/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useCallback } from 'react';
22
import { DashboardLayout } from './layouts';
33
import { Router, useRouter } from './router';
4-
import { DashboardView, MemoriesView, SessionsView, SpecView, UsageView, VaultView } from './views';
4+
import { DashboardView, MemoriesView, SessionsView, SettingsView, SpecView, UsageView, VaultView } from './views';
55
import { LogsDrawer } from './components/LogsModal';
66
import { CommandPalette } from './components/CommandPalette';
77
import { LicenseGate } from './components/LicenseGate';
@@ -19,6 +19,7 @@ const routes = [
1919
{ path: '/sessions', component: SessionsView },
2020
{ path: '/usage', component: UsageView },
2121
{ path: '/vault', component: VaultView },
22+
{ path: '/settings', component: SettingsView },
2223
];
2324

2425
const SIDEBAR_COLLAPSED_KEY = 'pilot-memory-sidebar-collapsed';
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useState, useCallback, useEffect } from 'react';
2+
3+
export const MODEL_CHOICES_FULL = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const;
4+
export const MODEL_CHOICES_AGENT = ['sonnet', 'opus'] as const;
5+
6+
export type ModelFull = (typeof MODEL_CHOICES_FULL)[number];
7+
export type ModelAgent = (typeof MODEL_CHOICES_AGENT)[number];
8+
9+
export const MODEL_DISPLAY_NAMES: Record<string, string> = {
10+
sonnet: 'Sonnet 4.6',
11+
'sonnet[1m]': 'Sonnet 4.6 1M',
12+
opus: 'Opus 4.6',
13+
'opus[1m]': 'Opus 4.6 1M',
14+
};
15+
16+
export interface ModelSettings {
17+
model: string;
18+
commands: Record<string, string>;
19+
agents: Record<string, string>;
20+
}
21+
22+
export const DEFAULT_SETTINGS: ModelSettings = {
23+
model: 'opus',
24+
commands: {
25+
spec: 'sonnet',
26+
'spec-plan': 'opus',
27+
'spec-implement': 'sonnet',
28+
'spec-verify': 'opus',
29+
vault: 'sonnet',
30+
sync: 'sonnet',
31+
learn: 'sonnet',
32+
},
33+
agents: {
34+
'plan-challenger': 'sonnet',
35+
'plan-verifier': 'sonnet',
36+
'spec-reviewer-compliance': 'sonnet',
37+
'spec-reviewer-quality': 'opus',
38+
},
39+
};
40+
41+
export interface UseSettingsResult {
42+
settings: ModelSettings;
43+
isLoading: boolean;
44+
error: string | null;
45+
isDirty: boolean;
46+
saved: boolean;
47+
updateModel: (model: string) => void;
48+
updateCommand: (command: string, model: string) => void;
49+
updateAgent: (agent: string, model: string) => void;
50+
save: () => Promise<void>;
51+
}
52+
53+
export function useSettings(): UseSettingsResult {
54+
const [settings, setSettings] = useState<ModelSettings>(DEFAULT_SETTINGS);
55+
const [isLoading, setIsLoading] = useState(true);
56+
const [error, setError] = useState<string | null>(null);
57+
const [isDirty, setIsDirty] = useState(false);
58+
const [saved, setSaved] = useState(false);
59+
60+
useEffect(() => {
61+
fetch('/api/settings')
62+
.then((r) => {
63+
if (!r.ok) throw new Error(`API error: ${r.status}`);
64+
return r.json();
65+
})
66+
.then((data: ModelSettings) => {
67+
setSettings(data);
68+
setIsLoading(false);
69+
})
70+
.catch((err: Error) => {
71+
setError(err.message || 'Failed to load settings');
72+
setIsLoading(false);
73+
});
74+
}, []);
75+
76+
const updateModel = useCallback((model: string) => {
77+
setSettings((prev) => ({ ...prev, model }));
78+
setIsDirty(true);
79+
setSaved(false);
80+
}, []);
81+
82+
const updateCommand = useCallback((command: string, model: string) => {
83+
setSettings((prev) => ({
84+
...prev,
85+
commands: { ...prev.commands, [command]: model },
86+
}));
87+
setIsDirty(true);
88+
setSaved(false);
89+
}, []);
90+
91+
const updateAgent = useCallback((agent: string, model: string) => {
92+
setSettings((prev) => ({
93+
...prev,
94+
agents: { ...prev.agents, [agent]: model },
95+
}));
96+
setIsDirty(true);
97+
setSaved(false);
98+
}, []);
99+
100+
const save = useCallback(async () => {
101+
await fetch('/api/settings', {
102+
method: 'PUT',
103+
headers: { 'Content-Type': 'application/json' },
104+
body: JSON.stringify(settings),
105+
}).then((r) => {
106+
if (!r.ok) throw new Error(`Save failed: ${r.status}`);
107+
return r.json();
108+
}).then((data: ModelSettings) => {
109+
setSettings(data);
110+
setIsDirty(false);
111+
setSaved(true);
112+
});
113+
}, [settings]);
114+
115+
return { settings, isLoading, error, isDirty, saved, updateModel, updateCommand, updateAgent, save };
116+
}

console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const navItems = [
1313
{ icon: 'lucide:history', label: 'Sessions', href: '#/sessions' },
1414
{ icon: 'lucide:bar-chart-3', label: 'Usage', href: '#/usage' },
1515
{ icon: 'lucide:archive', label: 'Vault', href: '#/vault' },
16+
{ icon: 'lucide:settings', label: 'Settings', href: '#/settings' },
1617
];
1718

1819
export function SidebarNav({ currentPath, collapsed = false }: SidebarNavProps) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { MODEL_DISPLAY_NAMES } from '../../hooks/useSettings.js';
3+
4+
interface ModelSelectProps {
5+
value: string;
6+
choices: readonly string[];
7+
onChange: (model: string) => void;
8+
disabled?: boolean;
9+
id?: string;
10+
}
11+
12+
export function ModelSelect({ value, choices, onChange, disabled = false, id }: ModelSelectProps) {
13+
return (
14+
<select
15+
id={id}
16+
className="select select-sm select-bordered w-full max-w-xs"
17+
value={value}
18+
onChange={(e) => onChange(e.target.value)}
19+
disabled={disabled}
20+
>
21+
{choices.map((model) => (
22+
<option key={model} value={model}>
23+
{MODEL_DISPLAY_NAMES[model] ?? model}
24+
</option>
25+
))}
26+
</select>
27+
);
28+
}

0 commit comments

Comments
 (0)