Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,17 @@ Discuss → Plan → Approve → Implement → Verify → Done

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

| Phase | Model | Why |
| --------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Planning** | Opus | Exploring your codebase, designing architecture, and writing the spec requires deep reasoning. A good plan is the foundation of everything. |
| **Plan Verification** | Opus | Catching gaps, missing edge cases, and requirement mismatches before implementation saves expensive rework. |
| **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. |
| **Code Verification** | Opus | Independent code review against the plan requires the same reasoning depth as planning — catching subtle bugs, logic errors, and spec deviations. |
| Phase | Default | Why |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Planning** | Opus | Exploring your codebase, designing architecture, and writing the spec requires deep reasoning. A good plan is the foundation of everything. |
| **Plan Verification** | Opus | Catching gaps, missing edge cases, and requirement mismatches before implementation saves expensive rework. |
| **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. |
| **Code Verification** | Opus | Independent code review against the plan requires the same reasoning depth as planning — catching subtle bugs, logic errors, and spec deviations. |

**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.

**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.

### Quick Mode

Just chat. No plan file, no approval gate. All quality hooks and TDD enforcement still apply.
Expand Down
2 changes: 2 additions & 0 deletions console/src/services/worker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { UsageRoutes } from "./worker/http/routes/UsageRoutes.js";
import { LicenseRoutes } from "./worker/http/routes/LicenseRoutes.js";
import { VaultRoutes } from "./worker/http/routes/VaultRoutes.js";
import { VexorRoutes } from "./worker/http/routes/VexorRoutes.js";
import { SettingsRoutes } from "./worker/http/routes/SettingsRoutes.js";
import { MetricsService } from "./worker/MetricsService.js";
import { startRetentionScheduler, stopRetentionScheduler } from "./worker/RetentionScheduler.js";

Expand Down Expand Up @@ -267,6 +268,7 @@ export class WorkerService {
this.server.registerRoutes(new UsageRoutes());
this.server.registerRoutes(new LicenseRoutes());
this.server.registerRoutes(new VaultRoutes());
this.server.registerRoutes(new SettingsRoutes());

startRetentionScheduler(this.dbManager);
}
Expand Down
176 changes: 176 additions & 0 deletions console/src/services/worker/http/routes/SettingsRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* SettingsRoutes
*
* API endpoints for reading and writing model preferences from ~/.pilot/config.json.
*
* GET /api/settings - Returns current model config with defaults merged in
* PUT /api/settings - Partial update of model preferences (merge, not replace)
*/

import express, { type Request, type Response } from "express";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { BaseRouteHandler } from "../BaseRouteHandler.js";
import { logger } from "../../../../utils/logger.js";

export const MODEL_CHOICES_FULL: readonly string[] = ["sonnet", "sonnet[1m]", "opus", "opus[1m]"];
export const MODEL_CHOICES_AGENT: readonly string[] = ["sonnet", "opus"];

export interface ModelSettings {
model: string;
commands: Record<string, string>;
agents: Record<string, string>;
}

export const DEFAULT_SETTINGS: ModelSettings = {
model: "opus",
commands: {
spec: "sonnet",
"spec-plan": "opus",
"spec-implement": "sonnet",
"spec-verify": "opus",
vault: "sonnet",
sync: "sonnet",
learn: "sonnet",
},
agents: {
"plan-challenger": "sonnet",
"plan-verifier": "sonnet",
"spec-reviewer-compliance": "sonnet",
"spec-reviewer-quality": "opus",
},
};

export class SettingsRoutes extends BaseRouteHandler {
private readonly configPath: string;

constructor(configPath?: string) {
super();
this.configPath = configPath ?? path.join(os.homedir(), ".pilot", "config.json");
}

setupRoutes(app: express.Application): void {
app.get("/api/settings", this.wrapHandler(this.handleGet.bind(this)));
app.put("/api/settings", this.wrapHandler(this.handlePut.bind(this)));
}

private readConfig(): Record<string, unknown> {
try {
const raw = fs.readFileSync(this.configPath, "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}

private mergeWithDefaults(raw: Record<string, unknown>): ModelSettings {
const mainModel =
typeof raw.model === "string" && MODEL_CHOICES_FULL.includes(raw.model)
? raw.model
: DEFAULT_SETTINGS.model;

const rawCommands = raw.commands;
const mergedCommands: Record<string, string> = { ...DEFAULT_SETTINGS.commands };
if (rawCommands && typeof rawCommands === "object" && !Array.isArray(rawCommands)) {
for (const [k, v] of Object.entries(rawCommands as Record<string, unknown>)) {
if (typeof v === "string" && MODEL_CHOICES_FULL.includes(v)) {
mergedCommands[k] = v;
}
}
}

const rawAgents = raw.agents;
const mergedAgents: Record<string, string> = { ...DEFAULT_SETTINGS.agents };
if (rawAgents && typeof rawAgents === "object" && !Array.isArray(rawAgents)) {
for (const [k, v] of Object.entries(rawAgents as Record<string, unknown>)) {
if (typeof v === "string" && MODEL_CHOICES_AGENT.includes(v)) {
mergedAgents[k] = v;
}
}
}

return { model: mainModel, commands: mergedCommands, agents: mergedAgents };
}

private validateSettings(body: Record<string, unknown>): string | null {
if (body.model !== undefined) {
if (typeof body.model !== "string" || !MODEL_CHOICES_FULL.includes(body.model)) {
return `Invalid model '${body.model}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
}
}

if (body.commands !== undefined) {
if (typeof body.commands !== "object" || Array.isArray(body.commands)) {
return "commands must be an object";
}
for (const [cmd, model] of Object.entries(body.commands as Record<string, unknown>)) {
if (typeof model !== "string" || !MODEL_CHOICES_FULL.includes(model)) {
return `Invalid model '${model}' for command '${cmd}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
}
}
}

if (body.agents !== undefined) {
if (typeof body.agents !== "object" || Array.isArray(body.agents)) {
return "agents must be an object";
}
for (const [agent, model] of Object.entries(body.agents as Record<string, unknown>)) {
if (typeof model !== "string" || !MODEL_CHOICES_AGENT.includes(model)) {
return `Invalid model '${model}' for agent '${agent}'; agents can only use: ${MODEL_CHOICES_AGENT.join(", ")} (no 1M context)`;
}
}
}

return null;
}

private writeConfigAtomic(data: Record<string, unknown>): void {
const dir = path.dirname(this.configPath);
fs.mkdirSync(dir, { recursive: true });
const tmpPath = this.configPath + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
fs.renameSync(tmpPath, this.configPath);
}

async handleGet(_req: Request, res: Response): Promise<void> {
const raw = this.readConfig();
const settings = this.mergeWithDefaults(raw);
res.json(settings);
}

async handlePut(req: Request, res: Response): Promise<void> {
const body = req.body as Record<string, unknown>;

const error = this.validateSettings(body);
if (error) {
this.badRequest(res, error);
return;
}

const existing = this.readConfig();

if (body.model !== undefined) {
existing.model = body.model;
}
if (body.commands !== undefined) {
const existingCommands = (existing.commands as Record<string, unknown>) ?? {};
existing.commands = { ...existingCommands, ...(body.commands as Record<string, unknown>) };
}
if (body.agents !== undefined) {
const existingAgents = (existing.agents as Record<string, unknown>) ?? {};
existing.agents = { ...existingAgents, ...(body.agents as Record<string, unknown>) };
}

try {
this.writeConfigAtomic(existing);
} catch (err) {
logger.error("HTTP", "Failed to write settings config", {}, err as Error);
res.status(500).json({ error: "Failed to save settings" });
return;
}

const updated = this.mergeWithDefaults(existing);
res.json(updated);
}
}
3 changes: 2 additions & 1 deletion console/src/ui/viewer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { DashboardLayout } from './layouts';
import { Router, useRouter } from './router';
import { DashboardView, MemoriesView, SessionsView, SpecView, UsageView, VaultView } from './views';
import { DashboardView, MemoriesView, SessionsView, SettingsView, SpecView, UsageView, VaultView } from './views';
import { LogsDrawer } from './components/LogsModal';
import { CommandPalette } from './components/CommandPalette';
import { LicenseGate } from './components/LicenseGate';
Expand All @@ -19,6 +19,7 @@ const routes = [
{ path: '/sessions', component: SessionsView },
{ path: '/usage', component: UsageView },
{ path: '/vault', component: VaultView },
{ path: '/settings', component: SettingsView },
];

const SIDEBAR_COLLAPSED_KEY = 'pilot-memory-sidebar-collapsed';
Expand Down
116 changes: 116 additions & 0 deletions console/src/ui/viewer/hooks/useSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useState, useCallback, useEffect } from 'react';

export const MODEL_CHOICES_FULL = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const;
export const MODEL_CHOICES_AGENT = ['sonnet', 'opus'] as const;

export type ModelFull = (typeof MODEL_CHOICES_FULL)[number];
export type ModelAgent = (typeof MODEL_CHOICES_AGENT)[number];

export const MODEL_DISPLAY_NAMES: Record<string, string> = {
sonnet: 'Sonnet 4.6',
'sonnet[1m]': 'Sonnet 4.6 1M',
opus: 'Opus 4.6',
'opus[1m]': 'Opus 4.6 1M',
};

export interface ModelSettings {
model: string;
commands: Record<string, string>;
agents: Record<string, string>;
}

export const DEFAULT_SETTINGS: ModelSettings = {
model: 'opus',
commands: {
spec: 'sonnet',
'spec-plan': 'opus',
'spec-implement': 'sonnet',
'spec-verify': 'opus',
vault: 'sonnet',
sync: 'sonnet',
learn: 'sonnet',
},
agents: {
'plan-challenger': 'sonnet',
'plan-verifier': 'sonnet',
'spec-reviewer-compliance': 'sonnet',
'spec-reviewer-quality': 'opus',
},
};

export interface UseSettingsResult {
settings: ModelSettings;
isLoading: boolean;
error: string | null;
isDirty: boolean;
saved: boolean;
updateModel: (model: string) => void;
updateCommand: (command: string, model: string) => void;
updateAgent: (agent: string, model: string) => void;
save: () => Promise<void>;
}

export function useSettings(): UseSettingsResult {
const [settings, setSettings] = useState<ModelSettings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [saved, setSaved] = useState(false);

useEffect(() => {
fetch('/api/settings')
.then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status}`);
return r.json();
})
.then((data: ModelSettings) => {
setSettings(data);
setIsLoading(false);
})
.catch((err: Error) => {
setError(err.message || 'Failed to load settings');
setIsLoading(false);
});
}, []);

const updateModel = useCallback((model: string) => {
setSettings((prev) => ({ ...prev, model }));
setIsDirty(true);
setSaved(false);
}, []);

const updateCommand = useCallback((command: string, model: string) => {
setSettings((prev) => ({
...prev,
commands: { ...prev.commands, [command]: model },
}));
setIsDirty(true);
setSaved(false);
}, []);

const updateAgent = useCallback((agent: string, model: string) => {
setSettings((prev) => ({
...prev,
agents: { ...prev.agents, [agent]: model },
}));
setIsDirty(true);
setSaved(false);
}, []);

const save = useCallback(async () => {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
}).then((r) => {
if (!r.ok) throw new Error(`Save failed: ${r.status}`);
return r.json();
}).then((data: ModelSettings) => {
setSettings(data);
setIsDirty(false);
setSaved(true);
});
}, [settings]);
Comment on lines +100 to +113
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

save() errors are not surfaced through the hook's error state — callers must catch the rejected Promise.

On a failed PUT (network error or !r.ok), the error propagates as a rejected Promise but setError() is never called. A call-site like <button onClick={() => save()}> will produce an unhandled rejection with no UI feedback. Either expose a saveError state, or catch internally and update error:

♻️ Proposed fix (expose saveError or update error on failure)
+  const [saveError, setSaveError] = useState<string | null>(null);

   const save = useCallback(async () => {
-    await fetch('/api/settings', {
-      method: 'PUT',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify(settings),
-    }).then((r) => {
-      if (!r.ok) throw new Error(`Save failed: ${r.status}`);
-      return r.json();
-    }).then((data: ModelSettings) => {
-      setSettings(data);
-      setIsDirty(false);
-      setSaved(true);
-    });
+    setSaveError(null);
+    try {
+      const r = await fetch('/api/settings', {
+        method: 'PUT',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(settings),
+      });
+      if (!r.ok) throw new Error(`Save failed: ${r.status}`);
+      const data: ModelSettings = await r.json();
+      setSettings(data);
+      setIsDirty(false);
+      setSaved(true);
+    } catch (err) {
+      setSaveError((err as Error).message ?? 'Save failed');
+    }
   }, [settings]);

-  return { settings, isLoading, error, isDirty, saved, updateModel, updateCommand, updateAgent, save };
+  return { settings, isLoading, error, saveError, isDirty, saved, updateModel, updateCommand, updateAgent, save };

Remember to add saveError: string | null to UseSettingsResult.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/ui/viewer/hooks/useSettings.ts` around lines 100 - 113, The save
function (save in useSettings hook) currently lets errors reject without
updating hook state; update save to catch failures and set an error state
(either reuse setError or add a new saveError state) so callers get UI feedback
instead of unhandled rejections, and ensure the hook's return type
(UseSettingsResult) includes saveError: string | null; specifically wrap the
fetch/then chain in try/catch (or append .catch) to call setError or
setSaveError with a descriptive message and still manage setIsDirty/setSaved
only on success.


return { settings, isLoading, error, isDirty, saved, updateModel, updateCommand, updateAgent, save };
}
1 change: 1 addition & 0 deletions console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navItems = [
{ icon: 'lucide:history', label: 'Sessions', href: '#/sessions' },
{ icon: 'lucide:bar-chart-3', label: 'Usage', href: '#/usage' },
{ icon: 'lucide:archive', label: 'Vault', href: '#/vault' },
{ icon: 'lucide:settings', label: 'Settings', href: '#/settings' },
];

export function SidebarNav({ currentPath, collapsed = false }: SidebarNavProps) {
Expand Down
28 changes: 28 additions & 0 deletions console/src/ui/viewer/views/Settings/ModelSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { MODEL_DISPLAY_NAMES } from '../../hooks/useSettings.js';

interface ModelSelectProps {
value: string;
choices: readonly string[];
onChange: (model: string) => void;
disabled?: boolean;
id?: string;
}
Comment on lines +4 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add aria-label prop as a fallback when no id/label pair is guaranteed.

id is optional, and the <select> has no aria-label. If a caller omits id (or renders the component without a matching <label htmlFor>), screen readers have no way to announce the field's purpose.

♻️ Proposed fix
 interface ModelSelectProps {
   value: string;
   choices: readonly string[];
   onChange: (model: string) => void;
   disabled?: boolean;
   id?: string;
+  ariaLabel?: string;
 }

-export function ModelSelect({ value, choices, onChange, disabled = false, id }: ModelSelectProps) {
+export function ModelSelect({ value, choices, onChange, disabled = false, id, ariaLabel }: ModelSelectProps) {
   return (
     <select
       id={id}
+      aria-label={ariaLabel}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/ui/viewer/views/Settings/ModelSelect.tsx` around lines 4 - 10,
The ModelSelect component lacks an accessible name when no id/label pair is
provided; update ModelSelectProps to accept an optional ariaLabel?: string,
update the ModelSelect functional component to pass aria-label={ariaLabel} to
the rendered <select> (only when id is not present or always pass it), and
ensure the onChange/value/disabled behavior remains unchanged; reference the
ModelSelectProps interface and the ModelSelect component's <select> element to
locate where to add the ariaLabel prop and attribute.


export function ModelSelect({ value, choices, onChange, disabled = false, id }: ModelSelectProps) {
return (
<select
id={id}
className="select select-sm select-bordered w-full max-w-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{choices.map((model) => (
<option key={model} value={model}>
{MODEL_DISPLAY_NAMES[model] ?? model}
</option>
))}
</select>
);
}
Loading