Skip to content

feat: model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes#62

Merged
maxritter merged 7 commits intomainfrom
dev
Feb 18, 2026
Merged

feat: model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes#62
maxritter merged 7 commits intomainfrom
dev

Conversation

@maxritter
Copy link
Owner

@maxritter maxritter commented Feb 18, 2026

Summary

  • Model Selection Settings — New Settings page in the Pilot Console (localhost:41777/#/settings) lets users configure the model for every component: main session (Quick Mode), each /spec command phase, and each sub-agent. Preferences are stored in ~/.pilot/config.json and automatically injected into the installed plugin files on every Pilot startup. Defaults to Sonnet 4.6 for all components.
  • Dynamic context window — Context percentage calculation and compaction threshold are now model-aware instead of hardcoded at 200K tokens.
  • Apple Silicon acceleration for Vexor — Semantic search now leverages Metal GPU acceleration on Apple Silicon for significantly faster vector operations.
  • Worktree sync reliability — Removed auto-stash from worktree sync (was causing stash-restore conflicts). Sync now requires a clean working tree and fails clearly if not. Fixed the shell CWD issue after worktree cleanup — cleanup and cd are now combined in a single shell invocation.
  • Installer — Added prettier and golangci-lint installer support.
  • Rules fixes — Fixed vexor search command syntax in rules (was missing the search subcommand), clarified Bash tool timeout parameter.

Changes

Console

  • New SettingsRoutes.tsGET/PUT /api/settings with validation and atomic writes
  • New Settings view with per-command and per-agent model dropdowns
  • ModelRoutingInfo in Usage tab now shows dynamic model names from user config
  • Settings nav item added to sidebar

Launcher

  • model_config.py — Config schema, read/write utilities, validation
  • settings_injector.py — Injects model preferences into ~/.claude/pilot/ on startup
  • config.py — Dynamic get_max_context_tokens() and get_compaction_threshold_pct()
  • worktree.py — Removed auto-stash from sync_worktree, removed os.chdir from cleanup_worktree

Plugin

  • pilot/settings.json — Default model set to sonnet (Sonnet 4.6)
  • All command front matter model fields updated to sonnet/opus defaults

Test plan

  • Python test suite: uv run pytest -q
  • Console test suite: cd console && bun test
  • Open localhost:41777/#/settings — verify Settings page loads with dropdowns
  • Change a model in Settings, save, restart Pilot — verify ~/.claude/pilot/settings.json updated
  • Verify Vexor semantic search works on Apple Silicon

Summary by CodeRabbit

  • New Features

    • Settings UI in the Console to view/edit model assignments per phase, command, and sub-agent, with a persistent save workflow and sidebar entry.
    • Settings API to get/update model configuration.
    • macOS Apple Silicon (arm64) installer path with MLX-enabled local embeddings support.
  • Documentation

    • Updated model routing docs and README with configurability notes and a Quick Mode example.
  • Tests

    • Added unit and integration tests covering settings, UI routing, and installer flows.

@vercel
Copy link

vercel bot commented Feb 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
claude-pilot Ignored Ignored Feb 18, 2026 9:21am

Request Review

@claude
Copy link

claude bot commented Feb 18, 2026

Claude finished @maxritter's task in 1m 58s —— View job


Code Review — PR #62

  • Gather context and read key files
  • Review console changes (SettingsRoutes, useSettings, Settings views)
  • Review installer changes
  • Review pilot plugin changes (settings.json, rules, hooks)
  • Compile final review

Overall Assessment

Solid, well-structured PR. The model selection feature is carefully designed — defaults merge cleanly, validation is strict, and atomic writes prevent partial updates. Test coverage is good. A few issues and observations below.


Findings

1. useSettings.tssave() swallows errors silently

console/src/ui/viewer/hooks/useSettings.ts:100-113

The save callback uses .then() chaining, which means errors (non-2xx responses or network failures) are thrown and bubble out to the caller. This is correct in isolation — SettingsView.handleSave wraps it in a try/catch — but the mixing of await/.then() inside a single async function is harder to reason about and easy to mis-use in future callers.

// Current (mixed style):
const save = useCallback(async () => {
  await fetch().then(r => {  }).then(data => {  });
}, [settings]);

A fully async/await style would be clearer and more consistent with the rest of the codebase. Minor but worth aligning. Fix this →


2. SettingsRoutes.tsvalidateSettings allows unknown command/agent keys

console/src/services/worker/http/routes/SettingsRoutes.ts:103-123

The PUT handler validates that the model value for each supplied command or agent key is a valid choice, but it does not reject unknown keys (e.g. "commands": {"nonexistent-command": "opus"}). Those keys are written to config.json and will be persisted. This could silently accumulate garbage in the config file over time (e.g. from API misuse or future command renames).

Consider adding a check that command/agent keys must be in the known set from DEFAULT_SETTINGS. Fix this →


3. SettingsRoutes.ts — no body parsing guard

console/src/services/worker/http/routes/SettingsRoutes.ts:143

const body = req.body as Record<string, unknown>;

If the express JSON body parser middleware is not mounted (or the request has wrong Content-Type), req.body will be undefined. Casting it directly to Record<string, unknown> will cause validateSettings to throw a TypeError: Cannot convert undefined when iterating keys, resulting in an unhandled 500 instead of a clean 400. A simple if (!body || typeof body !== 'object') guard would improve robustness. Fix this →


4. useSettings.ts / SettingsView — no error handling on failed GET

console/src/ui/viewer/hooks/useSettings.ts:60-74

The fetch error path sets error state and shows an alert, but the settings state remains at DEFAULT_SETTINGS. If the Settings page is opened while the worker is not running, the user sees an error banner but the dropdowns are still populated with defaults. They can then click "Save" — which will also fail, since the worker is down, but the error message will be different (Save failed: 500 from a CORS or connection-refused error). This is acceptable UX but could be clearer (perhaps disable the Save button when the initial load failed). Worth noting but low priority.


5. ModelRoutingInfo.tsx — duplicate useSettings() call

console/src/ui/viewer/views/Usage/ModelRoutingInfo.tsx:5

ModelRoutingInfo calls useSettings() independently. If the Usage view and the Settings view are both mounted simultaneously (e.g. via tab prefetching), this results in two concurrent GET /api/settings fetches. Since the hook has no shared cache or context, this is a minor inefficiency but not a bug. Consider a SettingsContext if fetch deduplication ever matters.


6. installer/steps/dependencies.pyinstall_vexor skips config for OpenAI path

installer/steps/dependencies.py:382-387

if not command_exists("vexor"):
    if not _run_bash_with_retry("uv tool install 'vexor[local]'"):
        return False
_configure_vexor_local()
return _setup_vexor_local_model(ui)

In the non-MLX local path, _configure_vexor_local() is called whether or not vexor was freshly installed. But in the OpenAI (non-local) path (lines 383-387), when vexor is already installed, _configure_vexor_defaults() is called but when it's not installed, the config is also set but vexor itself is returned True without installing — the tool is not present. This seems intentional (the OpenAI path assumes users bring their own vexor), but the comment could be clearer that no-local-model vexor is expected to already be installed.


7. _util.py — per-turn config file read at every hook call

pilot/hooks/_util.py:29-43

_read_model_from_config() reads and parses ~/.pilot/config.json on every invocation of context-monitor (a PostToolUse hook). For high-frequency hook calls this could add measurable I/O. A module-level cache (invalidated after a fixed interval or on mtime change) would be a nice optimization. Not a correctness issue.


8. pilot/rules/cli-tools.mdvexor search is now correct

The PR description mentions that the search subcommand was missing — the diff confirms the rules were updated from vexor "<QUERY>" to the correctly documented form. This is a good fix. The search command syntax is now consistent between cli-tools.md, research-tools.md, and tool_redirect.py.


9. console/tests/settings-routes.test.ts — atomic write test is weak

console/tests/settings-routes.test.ts:246-263

const hasTmp = writtenPaths.some(p => p.includes('.tmp') || p !== configPath);

The condition p !== configPath is always true for any path that isn't exactly configPath, which means this test would pass even if the write was non-atomic (e.g. writing to a backup file). A stronger assertion would be:

const hasTmp = writtenPaths.some(p => p.endsWith('.tmp'));
expect(hasTmp).toBe(true);

Fix this →


Positives

  • Atomic writes in SettingsRoutes.writeConfigAtomic (tmp → rename) are the right pattern for preventing partial config corruption.
  • Defaults merge in mergeWithDefaults correctly handles partial configs — unknown models fall back to defaults, partial commands/agents are filled from defaults.
  • Non-destructive PUT preserves other config keys (auto_update, etc.) — the existing object is patched in place rather than replaced.
  • 1M restriction for agents is enforced symmetrically on both client (MODEL_CHOICES_AGENT) and server (MODEL_CHOICES_AGENT validation in validateSettings).
  • Model-aware context window in _util.py is clean — the standalone _read_model_from_config() avoids any launcher import dependency from hooks.
  • Apple Silicon fallback chain in _install_vexor_mlx is well-structured: MLX fork → CPU local → fail.
  • Test coverage across SettingsRoutes, useSettings hook, _util.py model helpers, and ModelRoutingInfo is solid.

Summary

The core model selection feature is well implemented. The main actionable items are:

  1. Add unknown-key validation to the PUT handler (correctness risk if API is misused)
  2. Add a req.body null guard in handlePut
  3. Fix the weak atomic-write test assertion

@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a full settings feature: backend SettingsRoutes (GET/PUT) persisting to ~/.pilot/config.json, a frontend SettingsView with hook and ModelSelect, sidebar route, tests, documentation updates, and installer enhancements for macOS arm64 MLX support and related tooling installs.

Changes

Cohort / File(s) Summary
Backend Settings API
console/src/services/worker/http/routes/SettingsRoutes.ts, console/src/services/worker-service.ts
New SettingsRoutes exposing GET /api/settings and PUT /api/settings with validation, defaults merging, and atomic config writes; registered in worker service.
Frontend: Settings UI & Hook
console/src/ui/viewer/views/Settings/..., console/src/ui/viewer/hooks/useSettings.ts, console/src/ui/viewer/App.tsx, console/src/ui/viewer/views/index.ts
New SettingsView, ModelSelect component, useSettings hook (fetch/save, dirty/saved state, model/command/agent updates), route at /settings, and re-export of SettingsView.
Navigation / Usage Integration
console/src/ui/viewer/layouts/Sidebar/SidebarNav.tsx, console/src/ui/viewer/views/Usage/ModelRoutingInfo.tsx, console/src/ui/viewer/views/Usage/index.tsx
Sidebar adds Settings nav item; ModelRoutingInfo consumes useSettings for dynamic display and links to Settings; removed ModelRoutingInfo from Usage view.
Tests: Settings + UI + Installer
console/tests/*, installer/tests/unit/steps/*
New/updated tests covering SettingsRoutes (GET/PUT/validation/atomic writes), useSettings hook, App routing, SidebarNav, ModelRoutingInfo, views index exports, and expanded installer dependency/MLX tests.
Docs & README
README.md, docs/site/src/content/blog/model-selection.md, docs/site/src/components/WhatsInside.tsx
Documented configurability note, Quick Mode example, Pilot Console guidance; added Settings slide to showcase.
Installer: Platform & Dependencies
installer/platform_utils.py, installer/steps/dependencies.py, installer/steps/settings_merge.py
Added is_macos_arm64(); MLX-based Vexor install flow and helpers for macOS arm64; prettier and golangci-lint install flows; adjusted three-way merge to drop unchanged baseline keys in certain cases.
Minor UI formatting changes
console/src/ui/viewer/views/Usage/DailyCostChart.tsx, .../MonthlyCostChart.tsx, .../Usage/index.tsx
Formatting and layout tweaks (quote style, container heights, JSX wrapping) without behavioral changes.
Tests: small adjustments
console/tests/infrastructure/process-manager.test.ts
Tweaked test values/assertions for PID-related tests.

Sequence Diagrams

sequenceDiagram
    participant Browser as Browser (SettingsView)
    participant Hook as useSettings Hook
    participant Server as Express (SettingsRoutes)
    participant FS as File System (~/.pilot/config.json)

    Browser->>Hook: user changes model selection
    Hook->>Hook: update local state (isDirty=true)
    Browser->>Hook: user clicks Save
    Hook->>Server: PUT /api/settings { ... }
    activate Server
    Server->>FS: read current config
    Server->>Server: validate & merge with defaults
    Server->>FS: write temp file then atomic rename
    deactivate Server
    Server-->>Hook: 200 OK (updated settings)
    Hook->>Browser: update state (isDirty=false, saved=true)
Loading
sequenceDiagram
    participant App as App (mount)
    participant Hook as useSettings Hook
    participant Server as Express (SettingsRoutes)
    participant FS as File System (~/.pilot/config.json)

    App->>Hook: mount SettingsView
    Hook->>Server: GET /api/settings
    activate Server
    Server->>FS: read config (or use defaults)
    Server->>Server: merge & return settings
    deactivate Server
    Server-->>Hook: 200 OK {settings}
    Hook->>App: provide settings + handlers
    App->>App: render SettingsView with values
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

released

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title comprehensively and specifically describes three major features added: model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes, which aligns with the core changes across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (15)
console/tests/hooks/useSettings.test.ts (1)

70-78: Reading the source file to check for string literals is a test antipattern — replace with a behavioral test.

This couples the test to the source file path and content rather than verifiable behavior. A refactored fetch or rename breaks the test without breaking functionality, and it can't catch runtime issues.

For a lightweight behavioral check without a DOM, mock globalThis.fetch and verify the hook calls the right URL and method:

it('save() calls PUT /api/settings', async () => {
  const putPayload: unknown[] = [];
  globalThis.fetch = async (url: RequestInfo, init?: RequestInit) => {
    if (init?.method === 'PUT') putPayload.push(JSON.parse(init.body as string));
    return { ok: true, json: async () => ({}) } as Response;
  };
  // renderHook or direct invocation ...
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/tests/hooks/useSettings.test.ts` around lines 70 - 78, The test
currently reads the source file contents to assert string literals; change it to
a behavioral test that mocks globalThis.fetch and verifies the hook's behavior
instead. In the test for useSettings, mock fetch to capture requests, render the
hook (useSettings) via renderHook or mount, call the hook's save() function, and
assert that fetch was called with method 'PUT' and URL '/api/settings' and that
the hook's returned state properties (isLoading, isDirty, saved or similar)
update appropriately; reference the useSettings hook and its save method/state
to locate where to change the test.
console/src/ui/viewer/hooks/useSettings.ts (3)

22-39: DEFAULT_SETTINGS is duplicated verbatim in SettingsRoutes.ts — divergence risk.

The client-side defaults (used for pre-fetch rendering) and server-side defaults (used for the GET response when no config exists) must stay in sync. If they diverge, users see one set of defaults before the fetch completes and a different set afterward.

Consider extracting shared constants to a single source (e.g., a shared module or generating client defaults from the server response's schema).

🤖 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 22 - 39,
DEFAULT_SETTINGS in useSettings.ts is duplicated from SettingsRoutes.ts which
risks divergence; consolidate the canonical defaults into a single shared export
and have both the client hook (DEFAULT_SETTINGS / ModelSettings usage in
useSettings.ts) and the server route (SettingsRoutes.ts) import that constant
instead of redefining it. Create a shared module (e.g., settingsDefaults or
settingsSchema) that exports the ModelSettings-typed DEFAULT_SETTINGS and import
it from both places, or alternatively derive the client-side defaults from the
server response/schema at build-time; update references in useSettings.ts
(DEFAULT_SETTINGS) and SettingsRoutes.ts to import from the new shared module.
Ensure the shared export remains the single source of truth and update any tests
or types that reference the old local constants.

60-74: Missing cleanup in useEffect — add an AbortController to cancel in-flight fetch on unmount.

♻️ Proposed fix
   useEffect(() => {
-    fetch('/api/settings')
+    const controller = new AbortController();
+    fetch('/api/settings', { signal: controller.signal })
       .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) => {
+        if (err.name === 'AbortError') return;
         setError(err.message || 'Failed to load settings');
         setIsLoading(false);
       });
+    return () => controller.abort();
   }, []);
🤖 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 60 - 74, The
useEffect currently starts a fetch('/api/settings') that may continue after
unmount; update it to create an AbortController, pass controller.signal to
fetch, and in the cleanup return call controller.abort() to cancel the request;
ensure you handle AbortError in the catch so setError/setIsLoading are not
incorrectly called when the fetch is aborted, keeping usage of setSettings,
setIsLoading, and setError unchanged otherwise.

9-14: MODEL_DISPLAY_NAMES typed as Record<string, string> loses exhaustiveness — consider typing as Record<ModelFull, string>.

If a model is added to MODEL_CHOICES_FULL, TypeScript won't enforce adding a display name entry.

♻️ Proposed refactor
-export const MODEL_DISPLAY_NAMES: Record<string, string> = {
+export const MODEL_DISPLAY_NAMES: Record<ModelFull, string> = {

This requires moving the ModelFull type declaration above MODEL_DISPLAY_NAMES (currently ModelFull is at line 6, already before line 9, so the order is fine).

🤖 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 9 - 14, Change
MODEL_DISPLAY_NAMES to be typed with the specific union type ModelFull instead
of Record<string, string> so TypeScript enforces exhaustiveness (e.g., use
Record<ModelFull, string> for the MODEL_DISPLAY_NAMES declaration), ensure the
ModelFull type is declared above the MODEL_DISPLAY_NAMES constant, and update
any places that rely on the broader string key type to accept the narrower
ModelFull type.
installer/steps/settings_merge.py (1)

40-43: Consider inverting the condition to eliminate the pass branch.

The pass/else inversion is correct but somewhat unusual. A direct condition is clearer:

♻️ Proposed refactor
-            if baseline is not None and in_baseline and current[key] == baseline[key]:
-                pass
-            else:
-                result[key] = current[key]
+            if baseline is None or not in_baseline or current[key] != baseline[key]:
+                result[key] = current[key]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@installer/steps/settings_merge.py` around lines 40 - 43, Invert the
conditional in the settings_merge logic to remove the no-op pass branch: instead
of "if baseline is not None and in_baseline and current[key] == baseline[key]:
pass else: result[key] = current[key]" change the test so the branch that
assigns into result runs directly when the value differs or baseline is absent.
Locate the block using variables baseline, in_baseline, current, key and update
the condition to something like "if baseline is None or not in_baseline or
current[key] != baseline[key]" and then assign result[key] = current[key];
remove the pass branch entirely.
installer/steps/dependencies.py (1)

477-491: install_golangci_lint uses master branch of install script — consider pinning.

The install command fetches from golangci-lint/master/install.sh at head, which could change without notice. Pinning to a tagged release (e.g., v2.1.6/install.sh) would improve reproducibility.

This is also a minor security consideration: piping an unpinned remote script to sh.

As per coding guidelines, "Idempotency (can be run multiple times safely)" — currently idempotent via command_exists check. Cross-platform: correctly gated on command_exists("go").

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

In `@installer/steps/dependencies.py` around lines 477 - 491, The
install_golangci_lint function currently downloads the install script from the
master branch; change install_cmd to fetch a pinned release tag instead (e.g.,
use the known release path like vX.Y.Z/install.sh) so the script is reproducible
and more secure, and keep the existing command_exists("golangci-lint") and
command_exists("go") guards; update the install_cmd string construction (the
install_cmd local variable) to reference the chosen tag and ensure the rest of
the function still calls _run_bash_with_retry(install_cmd, timeout=120).
console/tests/ui/model-routing-info.test.ts (1)

12-38: Source-string assertions are brittle — consider supplementing with render tests.

Tests 2-5 verify implementation details by scanning source text (e.g., checking for 'useSettings', '??'). These will break on harmless refactors like renaming an import alias or switching from ?? to a ternary. They're fine as a quick migration guard, but longer-term, a render-based test that mounts ModelRoutingInfo with mocked settings would be more resilient.

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

In `@console/tests/ui/model-routing-info.test.ts` around lines 12 - 38, Replace
brittle source-text assertions in model-routing-info.test.ts by mounting the
ModelRoutingInfo component and asserting rendered output instead of scanning
source strings: import and render the ModelRoutingInfo component (symbol:
ModelRoutingInfo) with React Testing Library, mock the useSettings hook or
provide the settings context to simulate different settings, then assert the DOM
contains the settings link ('#/settings'), dynamic model display names (from
MODEL_DISPLAY_NAMES) and fallback behavior (DEFAULT_SETTINGS or visible '??'
equivalent) under those mocked scenarios; remove the tests that call
readFileSync and look for 'useSettings', 'MODEL_DISPLAY_NAMES',
'DEFAULT_SETTINGS', 'Sonnet 4.5', '"Opus 4.6"', and '??' strings and replace
them with render-based assertions that query text/roles in the rendered output.
installer/tests/unit/steps/test_dependencies.py (1)

842-853: Mock logic is overly obscure but functionally correct.

mock_cmd.side_effect = lambda cmd: cmd != "golangci-lint" and False evaluates to False for every input, which is equivalent to return_value=False. The test correctly verifies that install_golangci_lint returns False when neither golangci-lint nor go is in PATH.

Simpler equivalent
-        mock_cmd.side_effect = lambda cmd: cmd != "golangci-lint" and False
+        mock_cmd.return_value = False
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@installer/tests/unit/steps/test_dependencies.py` around lines 842 - 853, The
mock for command_exists in test_install_golangci_lint_skips_without_go is
needlessly obscure; replace the side_effect on mock_cmd with a clear explicit
return indicating neither binary is present (e.g., set mock_cmd.return_value =
False) so the test more obviously simulates absence of both "golangci-lint" and
"go" when calling install_golangci_lint; keep the rest of the test (patching
_run_bash_with_retry and asserting result is False and
mock_run.assert_not_called()) unchanged.
console/src/services/worker/http/routes/SettingsRoutes.ts (2)

58-65: Synchronous file reads on the request thread.

readFileSync blocks the event loop. For a low-traffic settings endpoint this is tolerable, but be aware it'll stall all concurrent requests while reading. If the file grows or sits on a slow mount, latency increases.

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

In `@console/src/services/worker/http/routes/SettingsRoutes.ts` around lines 58 -
65, The readConfig function currently uses fs.readFileSync which blocks the
event loop; change readConfig to an async function that uses
fs.promises.readFile (or util.promisify) to read this.configPath and JSON.parse
the result, returning Promise<Record<string, unknown>> and catching errors to
return {} on failure; then update all call sites to await readConfig (or handle
the returned Promise) so no synchronous file I/O occurs on the request thread.

17-43: Extract model choices and settings into a shared module to avoid duplication.

MODEL_CHOICES_FULL, MODEL_CHOICES_AGENT, ModelSettings, and DEFAULT_SETTINGS are defined identically in both console/src/services/worker/http/routes/SettingsRoutes.ts and console/src/ui/viewer/hooks/useSettings.ts. This duplication violates DRY—changes to these values must be made in both places, and the separate test files mean drift could go unnoticed.

Consider creating a shared constants file (e.g., console/src/shared/settings.ts) imported by both server and client, or have the client derive model choices from the GET /api/settings response.

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

In `@console/src/services/worker/http/routes/SettingsRoutes.ts` around lines 17 -
43, Duplicate definitions of MODEL_CHOICES_FULL, MODEL_CHOICES_AGENT,
ModelSettings, and DEFAULT_SETTINGS exist in both SettingsRoutes.ts and
useSettings.ts; extract these into a single shared module (e.g., a new settings
constants module) and import them from both places, or alternatively expose
model choices via the GET /api/settings response and have useSettings.ts derive
its values from that API; update references to the symbols MODEL_CHOICES_FULL,
MODEL_CHOICES_AGENT, ModelSettings, and DEFAULT_SETTINGS in both files to import
from the new shared module (or consume the API response) and remove the local
duplicated definitions.
console/tests/ui/views-index.test.ts (1)

7-20: Consider sharing the module import across tests.

Both it blocks independently await import(...) the same module. While Bun caches it, a shared let mod hoisted to describe scope is cleaner and avoids re-importing in each test.

♻️ Suggested refactor
 describe('views/index exports', () => {
+  let mod: Record<string, unknown>;
+  beforeAll(async () => {
+    mod = await import('../../src/ui/viewer/views/index.js');
+  });
+
   it('exports SettingsView', async () => {
-    const mod = await import('../../src/ui/viewer/views/index.js');
     expect(typeof mod.SettingsView).toBe('function');
   });

   it('exports all existing views', async () => {
-    const mod = await import('../../src/ui/viewer/views/index.js');
     expect(typeof mod.DashboardView).toBe('function');
     // ...
   });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/tests/ui/views-index.test.ts` around lines 7 - 20, Both tests
duplicate the same dynamic import; hoist a single module variable (e.g., let
mod) to the describe scope and populate it once in a beforeAll() by awaiting
import('../../src/ui/viewer/views/index.js'), then update both it blocks to read
the shared mod variable (leaving the same assertions for SettingsView,
DashboardView, MemoriesView, SessionsView, SpecView, UsageView, VaultView) so
the module is imported only once and tests remain identical.
console/tests/settings-routes.test.ts (2)

152-273: Missing test: PUT error path when writeConfigAtomic fails (HTTP 500 branch).

The handlePut implementation catches writeConfigAtomic errors and responds with res.status(500).json({ error: ... }), but there is no test exercising this code path. It's straightforward to cover by spying on fs.writeFileSync to throw.

Do you want me to generate the test case for the 500 error path?

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

In `@console/tests/settings-routes.test.ts` around lines 152 - 273, Add a unit
test that triggers the 500 error branch by making (routes as any).handlePut
throw when attempting to write the config: spyOn fs.writeFileSync (used by
writeConfigAtomic) and mock it to throw an Error, then call handlePut with a
valid req (e.g., body: { model: 'opus' }) and assert that the mock response
(makeMockRes) has statusCode 500 and that m.body contains an error property;
reference the handlePut function and the fs.writeFileSync spy to locate where to
inject the failure.

97-101: DEFAULT_SETTINGS 1M check omits agents.

Only commands values are iterated for the [1m] check. DEFAULT_SETTINGS.agents values should be verified too, since agents are restricted to MODEL_CHOICES_AGENT (no 1M variants) and an accidental future default change could silently pass.

♻️ Proposed addition
   it('should have no 1M models in defaults', () => {
     for (const model of Object.values(DEFAULT_SETTINGS.commands)) {
       expect(model).not.toContain('[1m]');
     }
+    for (const model of Object.values(DEFAULT_SETTINGS.agents)) {
+      expect(model).not.toContain('[1m]');
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/tests/settings-routes.test.ts` around lines 97 - 101, The test
"should have no 1M models in defaults" currently only iterates
Object.values(DEFAULT_SETTINGS.commands); extend it to also iterate
Object.values(DEFAULT_SETTINGS.agents) and assert each agent value does not
contain '[1m]'. Reference DEFAULT_SETTINGS, commands, agents, and
MODEL_CHOICES_AGENT in the change so the test covers both defaults categories
(commands and agents) to prevent accidental 1M model defaults slipping in.
console/tests/ui/SidebarNav.test.ts (1)

12-18: Same source-inspection brittleness as App.test.ts.

Grepping raw TSX source for '#/settings', 'Settings', and 'lucide:settings' is fragile for the same reasons: refactoring nav items into a data array or changing quote style would break these assertions without breaking production behaviour. This is acceptable as a lightweight smoke test, but worth considering a data-driven check against the nav config array if/when one is introduced.

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

In `@console/tests/ui/SidebarNav.test.ts` around lines 12 - 18, The current test
in SidebarNav.test.ts reads raw TSX source of SidebarNav.tsx and greps for
literal strings which is brittle; change it to import the SidebarNav component
(or the navItems array if one exists) and perform a data-driven or rendered
check instead: render SidebarNav (or inspect navItems) and assert there is an
item with href '#/settings' or text 'Settings' and/or icon identifier
'lucide:settings' (reference symbols: test name "source includes Settings nav
item", the SidebarNav component in SidebarNav.tsx, and any exported navItems
array). This removes brittle source-string matching while keeping the intent of
verifying the Settings nav entry.
console/tests/ui/App.test.ts (1)

12-17: Source-inspection test is brittle — consider a more robust approach.

The test reads the raw .tsx source file and greps for string literals. This will silently give a false negative if the route path is extracted to a constant (const SETTINGS_ROUTE = '/settings'), or a false positive if the strings appear in a comment. Minimal improvement: check for a structural pattern (e.g., the compiled .js output) or, if a JSDOM-capable environment is available, render and assert on the router tree.

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

In `@console/tests/ui/App.test.ts` around lines 12 - 17, The current test in the
App.test spec reads the raw .tsx source and greps for string literals, which is
brittle; update the test to verify the router structure instead: import the App
component (or its compiled JS output) and either render it with a JSDOM-aware
test renderer (e.g., `@testing-library/react`) and assert the presence of the
"/settings" route or the SettingsView route component in the rendered router
tree, or import the runtime constant (e.g., SETTINGS_ROUTE) and assert it
matches the expected value; target the App component and
SettingsView/SETTINGS_ROUTE symbols so the test stays correct if routes are
factored into constants or comments change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@console/src/ui/viewer/hooks/useSettings.ts`:
- Around line 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.

In `@console/src/ui/viewer/views/Settings/index.tsx`:
- Around line 182-184: The context-display cell uses (settings.commands[cmd] ??
'') while the ModelSelect uses (settings.commands[cmd] ??
DEFAULT_SETTINGS.commands[cmd]); update the context cell to use the same
fallback so it reflects defaults correctly — i.e., read the model string via
(settings.commands[cmd] ?? DEFAULT_SETTINGS.commands[cmd]) when computing the
includes('[1m]') check; locate the ModelSelect and the <td className="text-xs
text-base-content/40"> context cell in Settings/index.tsx and make their source
of truth consistent (both using DEFAULT_SETTINGS.commands[cmd] as the fallback).

In `@console/src/ui/viewer/views/Settings/ModelSelect.tsx`:
- Around line 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.

In `@console/src/ui/viewer/views/Usage/ModelRoutingInfo.tsx`:
- Around line 62-64: The paragraph contains a hardcoded sentence about routing
defaults; update it to reflect live configuration by deriving the text from the
computed cfg (e.g., use cfg.planning, cfg.verification, cfg.implementation or
iterate cfg.phases) instead of the static "Opus...Sonnet" string, or remove the
sentence entirely; locate the JSX where mainModel is used in
ModelRoutingInfo.tsx and replace the hardcoded copy with a string constructed
from cfg values so the displayed defaults update when the user changes
configuration.

In `@console/tests/infrastructure/process-manager.test.ts`:
- Around line 236-250: The test should assert setup succeeded before calling
cleanStalePidFile and then assert the PID file was removed; specifically, after
writePidFile(PidInfo) add an assertion using existsSync(PID_FILE) and/or
readPidFile() to verify the file exists and contains pid 2147483647, then call
cleanStalePidFile() and assert the file no longer exists (existsSync(PID_FILE)
is false) or readPidFile() returns undefined/null; update the assertions around
writePidFile, readPidFile, existsSync, and PID_FILE to reflect this positive
setup check and a definitive post-cleanup check.

In `@console/tests/settings-routes.test.ts`:
- Around line 260-261: The assertion is trivially true because "|| p !==
configPath" matches any path not exactly equal to configPath; update the check
on writtenPaths so it specifically looks for the temporary write path instead of
allowing any unrelated path. Replace the predicate in the hasTmp calculation
(referencing writtenPaths, configPath, hasTmp) with one that requires the path
to include the temp marker and not equal configPath (e.g., p.includes('.tmp') &&
p !== configPath) or simply p.includes('.tmp'), then assert that hasTmp is true.
- Around line 248-252: The test fails TS2556 because mockImplementation uses a
loose any[] spread against overloaded fs.writeFileSync; change the mock to use
the exact parameter tuple type: replace the current mockImplementation((p:
fs.PathOrFileDescriptor, ...args: any[]) => {...}) with
mockImplementation((...args: Parameters<typeof fs.writeFileSync>) => {
writtenPaths.push(String(args[0])); return origWriteFileSync(...args); }); this
uses Parameters<typeof fs.writeFileSync> and ensures origWriteFileSync (bound
via fs.writeFileSync.bind(fs)) is called with correctly-typed arguments while
still recording the written path.

In `@docs/site/src/content/blog/model-selection.md`:
- Line 175: Update the "Pilot Console" link text to clarify it only works when
Pilot is running locally and remove the redundant inline-code URL; specifically
change the hyperlink "[Pilot Console](http://localhost:41777/#/settings)" to
something like "Pilot Console (requires Pilot running locally)" and delete the
repeated `localhost:41777/#/settings` inline code, or replace the link with a
documentation/hosted URL if one exists; ensure the sentence still tells users
they can set models per `/spec` phase, slash command, and sub-agent and that
restarting Pilot applies changes.

In `@installer/steps/dependencies.py`:
- Around line 293-333: The _clone_vexor_fork function currently ignores failures
when updating an existing clone; change the three subprocess.run calls that
perform ["git", "fetch", "checkout", "pull"] to either use check=True or inspect
result.returncode, capture text output, and on non-zero exit log a warning
(including stdout/stderr and VEXOR_MLX_BRANCH/VEXOR_FORK_URL context) and return
None instead of silently returning vexor_dir so downstream
_install_vexor_from_local won't install from a potentially stale copy; keep the
existing safe-create/clone path intact and only alter the update branch to
surface errors to the user.

---

Nitpick comments:
In `@console/src/services/worker/http/routes/SettingsRoutes.ts`:
- Around line 58-65: The readConfig function currently uses fs.readFileSync
which blocks the event loop; change readConfig to an async function that uses
fs.promises.readFile (or util.promisify) to read this.configPath and JSON.parse
the result, returning Promise<Record<string, unknown>> and catching errors to
return {} on failure; then update all call sites to await readConfig (or handle
the returned Promise) so no synchronous file I/O occurs on the request thread.
- Around line 17-43: Duplicate definitions of MODEL_CHOICES_FULL,
MODEL_CHOICES_AGENT, ModelSettings, and DEFAULT_SETTINGS exist in both
SettingsRoutes.ts and useSettings.ts; extract these into a single shared module
(e.g., a new settings constants module) and import them from both places, or
alternatively expose model choices via the GET /api/settings response and have
useSettings.ts derive its values from that API; update references to the symbols
MODEL_CHOICES_FULL, MODEL_CHOICES_AGENT, ModelSettings, and DEFAULT_SETTINGS in
both files to import from the new shared module (or consume the API response)
and remove the local duplicated definitions.

In `@console/src/ui/viewer/hooks/useSettings.ts`:
- Around line 22-39: DEFAULT_SETTINGS in useSettings.ts is duplicated from
SettingsRoutes.ts which risks divergence; consolidate the canonical defaults
into a single shared export and have both the client hook (DEFAULT_SETTINGS /
ModelSettings usage in useSettings.ts) and the server route (SettingsRoutes.ts)
import that constant instead of redefining it. Create a shared module (e.g.,
settingsDefaults or settingsSchema) that exports the ModelSettings-typed
DEFAULT_SETTINGS and import it from both places, or alternatively derive the
client-side defaults from the server response/schema at build-time; update
references in useSettings.ts (DEFAULT_SETTINGS) and SettingsRoutes.ts to import
from the new shared module. Ensure the shared export remains the single source
of truth and update any tests or types that reference the old local constants.
- Around line 60-74: The useEffect currently starts a fetch('/api/settings')
that may continue after unmount; update it to create an AbortController, pass
controller.signal to fetch, and in the cleanup return call controller.abort() to
cancel the request; ensure you handle AbortError in the catch so
setError/setIsLoading are not incorrectly called when the fetch is aborted,
keeping usage of setSettings, setIsLoading, and setError unchanged otherwise.
- Around line 9-14: Change MODEL_DISPLAY_NAMES to be typed with the specific
union type ModelFull instead of Record<string, string> so TypeScript enforces
exhaustiveness (e.g., use Record<ModelFull, string> for the MODEL_DISPLAY_NAMES
declaration), ensure the ModelFull type is declared above the
MODEL_DISPLAY_NAMES constant, and update any places that rely on the broader
string key type to accept the narrower ModelFull type.

In `@console/tests/hooks/useSettings.test.ts`:
- Around line 70-78: The test currently reads the source file contents to assert
string literals; change it to a behavioral test that mocks globalThis.fetch and
verifies the hook's behavior instead. In the test for useSettings, mock fetch to
capture requests, render the hook (useSettings) via renderHook or mount, call
the hook's save() function, and assert that fetch was called with method 'PUT'
and URL '/api/settings' and that the hook's returned state properties
(isLoading, isDirty, saved or similar) update appropriately; reference the
useSettings hook and its save method/state to locate where to change the test.

In `@console/tests/settings-routes.test.ts`:
- Around line 152-273: Add a unit test that triggers the 500 error branch by
making (routes as any).handlePut throw when attempting to write the config:
spyOn fs.writeFileSync (used by writeConfigAtomic) and mock it to throw an
Error, then call handlePut with a valid req (e.g., body: { model: 'opus' }) and
assert that the mock response (makeMockRes) has statusCode 500 and that m.body
contains an error property; reference the handlePut function and the
fs.writeFileSync spy to locate where to inject the failure.
- Around line 97-101: The test "should have no 1M models in defaults" currently
only iterates Object.values(DEFAULT_SETTINGS.commands); extend it to also
iterate Object.values(DEFAULT_SETTINGS.agents) and assert each agent value does
not contain '[1m]'. Reference DEFAULT_SETTINGS, commands, agents, and
MODEL_CHOICES_AGENT in the change so the test covers both defaults categories
(commands and agents) to prevent accidental 1M model defaults slipping in.

In `@console/tests/ui/App.test.ts`:
- Around line 12-17: The current test in the App.test spec reads the raw .tsx
source and greps for string literals, which is brittle; update the test to
verify the router structure instead: import the App component (or its compiled
JS output) and either render it with a JSDOM-aware test renderer (e.g.,
`@testing-library/react`) and assert the presence of the "/settings" route or the
SettingsView route component in the rendered router tree, or import the runtime
constant (e.g., SETTINGS_ROUTE) and assert it matches the expected value; target
the App component and SettingsView/SETTINGS_ROUTE symbols so the test stays
correct if routes are factored into constants or comments change.

In `@console/tests/ui/model-routing-info.test.ts`:
- Around line 12-38: Replace brittle source-text assertions in
model-routing-info.test.ts by mounting the ModelRoutingInfo component and
asserting rendered output instead of scanning source strings: import and render
the ModelRoutingInfo component (symbol: ModelRoutingInfo) with React Testing
Library, mock the useSettings hook or provide the settings context to simulate
different settings, then assert the DOM contains the settings link
('#/settings'), dynamic model display names (from MODEL_DISPLAY_NAMES) and
fallback behavior (DEFAULT_SETTINGS or visible '??' equivalent) under those
mocked scenarios; remove the tests that call readFileSync and look for
'useSettings', 'MODEL_DISPLAY_NAMES', 'DEFAULT_SETTINGS', 'Sonnet 4.5', '"Opus
4.6"', and '??' strings and replace them with render-based assertions that query
text/roles in the rendered output.

In `@console/tests/ui/SidebarNav.test.ts`:
- Around line 12-18: The current test in SidebarNav.test.ts reads raw TSX source
of SidebarNav.tsx and greps for literal strings which is brittle; change it to
import the SidebarNav component (or the navItems array if one exists) and
perform a data-driven or rendered check instead: render SidebarNav (or inspect
navItems) and assert there is an item with href '#/settings' or text 'Settings'
and/or icon identifier 'lucide:settings' (reference symbols: test name "source
includes Settings nav item", the SidebarNav component in SidebarNav.tsx, and any
exported navItems array). This removes brittle source-string matching while
keeping the intent of verifying the Settings nav entry.

In `@console/tests/ui/views-index.test.ts`:
- Around line 7-20: Both tests duplicate the same dynamic import; hoist a single
module variable (e.g., let mod) to the describe scope and populate it once in a
beforeAll() by awaiting import('../../src/ui/viewer/views/index.js'), then
update both it blocks to read the shared mod variable (leaving the same
assertions for SettingsView, DashboardView, MemoriesView, SessionsView,
SpecView, UsageView, VaultView) so the module is imported only once and tests
remain identical.

In `@installer/steps/dependencies.py`:
- Around line 477-491: The install_golangci_lint function currently downloads
the install script from the master branch; change install_cmd to fetch a pinned
release tag instead (e.g., use the known release path like vX.Y.Z/install.sh) so
the script is reproducible and more secure, and keep the existing
command_exists("golangci-lint") and command_exists("go") guards; update the
install_cmd string construction (the install_cmd local variable) to reference
the chosen tag and ensure the rest of the function still calls
_run_bash_with_retry(install_cmd, timeout=120).

In `@installer/steps/settings_merge.py`:
- Around line 40-43: Invert the conditional in the settings_merge logic to
remove the no-op pass branch: instead of "if baseline is not None and
in_baseline and current[key] == baseline[key]: pass else: result[key] =
current[key]" change the test so the branch that assigns into result runs
directly when the value differs or baseline is absent. Locate the block using
variables baseline, in_baseline, current, key and update the condition to
something like "if baseline is None or not in_baseline or current[key] !=
baseline[key]" and then assign result[key] = current[key]; remove the pass
branch entirely.

In `@installer/tests/unit/steps/test_dependencies.py`:
- Around line 842-853: The mock for command_exists in
test_install_golangci_lint_skips_without_go is needlessly obscure; replace the
side_effect on mock_cmd with a clear explicit return indicating neither binary
is present (e.g., set mock_cmd.return_value = False) so the test more obviously
simulates absence of both "golangci-lint" and "go" when calling
install_golangci_lint; keep the rest of the test (patching _run_bash_with_retry
and asserting result is False and mock_run.assert_not_called()) unchanged.

Comment on lines +100 to +113
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]);
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.

Comment on lines +182 to +184
<td className="text-xs text-base-content/40">
{(settings.commands[cmd] ?? '').includes('[1m]') ? '1M' : '200K'}
</td>
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

Context column fallback is inconsistent with the value source.

Line 176 falls back to DEFAULT_SETTINGS.commands[cmd] for the <ModelSelect> value, but line 183 falls back to '' for the context display. If settings.commands[cmd] were ever undefined (e.g., a new command added to defaults but not yet in the user's saved config), the selector would show the correct default model but the context column would always show "200K" even if the default were a [1m] variant.

Currently none of the defaults use [1m], and the server merges defaults before returning, so this isn't actively broken — but the inconsistency is fragile.

Proposed fix
                     <td className="text-xs text-base-content/40">
-                      {(settings.commands[cmd] ?? '').includes('[1m]') ? '1M' : '200K'}
+                      {(settings.commands[cmd] ?? DEFAULT_SETTINGS.commands[cmd] ?? '').includes('[1m]') ? '1M' : '200K'}
                     </td>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td className="text-xs text-base-content/40">
{(settings.commands[cmd] ?? '').includes('[1m]') ? '1M' : '200K'}
</td>
<td className="text-xs text-base-content/40">
{(settings.commands[cmd] ?? DEFAULT_SETTINGS.commands[cmd] ?? '').includes('[1m]') ? '1M' : '200K'}
</td>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/ui/viewer/views/Settings/index.tsx` around lines 182 - 184, The
context-display cell uses (settings.commands[cmd] ?? '') while the ModelSelect
uses (settings.commands[cmd] ?? DEFAULT_SETTINGS.commands[cmd]); update the
context cell to use the same fallback so it reflects defaults correctly — i.e.,
read the model string via (settings.commands[cmd] ??
DEFAULT_SETTINGS.commands[cmd]) when computing the includes('[1m]') check;
locate the ModelSelect and the <td className="text-xs text-base-content/40">
context cell in Settings/index.tsx and make their source of truth consistent
(both using DEFAULT_SETTINGS.commands[cmd] as the fallback).

Comment on lines +4 to +10
interface ModelSelectProps {
value: string;
choices: readonly string[];
onChange: (model: string) => void;
disabled?: boolean;
id?: string;
}
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.

Comment on lines +62 to 64
Quick Mode uses <span className="font-mono">{mainModel}</span>.
Routing defaults use Opus for planning and verification orchestration, Sonnet for implementation.
</p>
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

Hardcoded description of defaults remains static after user configuration changes.

When a user customizes all phases to Sonnet, the paragraph still reads "Routing defaults use Opus for planning and verification orchestration, Sonnet for implementation." Consider deriving this text from the live cfg values (already computed above) or removing the prescriptive sentence since the table already communicates the current routing:

♻️ Suggested fix
-              Routing defaults use Opus for planning and verification orchestration, Sonnet for implementation.
+              {planModel === verifyModel
+                ? `${planModel} is used for planning and verification orchestration; ${implModel} for implementation.`
+                : `Planning and verification use ${planModel}/${verifyModel}; implementation uses ${implModel}.`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Quick Mode uses <span className="font-mono">{mainModel}</span>.
Routing defaults use Opus for planning and verification orchestration, Sonnet for implementation.
</p>
Quick Mode uses <span className="font-mono">{mainModel}</span>.
{planModel === verifyModel
? `${planModel} is used for planning and verification orchestration; ${implModel} for implementation.`
: `Planning and verification use ${planModel}/${verifyModel}; implementation uses ${implModel}.`}
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/ui/viewer/views/Usage/ModelRoutingInfo.tsx` around lines 62 - 64,
The paragraph contains a hardcoded sentence about routing defaults; update it to
reflect live configuration by deriving the text from the computed cfg (e.g., use
cfg.planning, cfg.verification, cfg.implementation or iterate cfg.phases)
instead of the static "Opus...Sonnet" string, or remove the sentence entirely;
locate the JSX where mainModel is used in ModelRoutingInfo.tsx and replace the
hardcoded copy with a string constructed from cfg values so the displayed
defaults update when the user changes configuration.

Comment on lines 236 to 250
it('should remove PID file if process is dead', () => {
const testInfo: PidInfo = {
pid: 999999,
pid: 2147483647,
port: 41777,
startedAt: new Date().toISOString()
};
writePidFile(testInfo);

cleanStalePidFile();

expect(existsSync(PID_FILE)).toBe(false);
if (existsSync(PID_FILE)) {
const current = readPidFile();
expect(current?.pid).not.toBe(2147483647);
}
});
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

Weakened assertion no longer positively verifies stale-file removal

The test is named "should remove PID file if process is dead," but on the success path (file correctly removed), existsSync(PID_FILE) is false, the if block is skipped, and zero assertions execute — the test passes vacuously. The assertion only fires when the file still exists after cleanup, which is the failure path. This means:

  • A broken cleanStalePidFile that silently no-ops would pass this test, as long as something else writes a new PID file concurrently (making current?.pid2147483647).
  • If writePidFile in the setup also silently fails, existsSync is false from the start and the test again passes trivially without ever exercising the function.

A more robust pattern would add a pre-assertion to confirm setup succeeded and then assert the file is gone after cleanup (accepting parallel-test ambiguity only as a documented exception, not silently):

🛡️ Proposed fix
  writePidFile(testInfo);
+
+ // Verify setup: the file must exist before we test cleanup
+ expect(existsSync(PID_FILE)).toBe(true);
+
  cleanStalePidFile();

- if (existsSync(PID_FILE)) {
-   const current = readPidFile();
-   expect(current?.pid).not.toBe(2147483647);
- }
+ // After cleanup the file should be gone, OR (if a parallel test raced in)
+ // the stored PID must be different from the dead one we wrote.
+ if (existsSync(PID_FILE)) {
+   const current = readPidFile();
+   expect(current?.pid).not.toBe(2147483647);
+ } else {
+   // Primary success path: stale file was removed
+   expect(existsSync(PID_FILE)).toBe(false);
+ }

The change at line 238 using 2147483647 (INT32_MAX) as a dead-PID sentinel is a good improvement — it's well outside valid PID ranges on all supported platforms.

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

In `@console/tests/infrastructure/process-manager.test.ts` around lines 236 - 250,
The test should assert setup succeeded before calling cleanStalePidFile and then
assert the PID file was removed; specifically, after writePidFile(PidInfo) add
an assertion using existsSync(PID_FILE) and/or readPidFile() to verify the file
exists and contains pid 2147483647, then call cleanStalePidFile() and assert the
file no longer exists (existsSync(PID_FILE) is false) or readPidFile() returns
undefined/null; update the assertions around writePidFile, readPidFile,
existsSync, and PID_FILE to reflect this positive setup check and a definitive
post-cleanup check.

Comment on lines +248 to +252
const origWriteFileSync = fs.writeFileSync.bind(fs);
const spy = spyOn(fs, 'writeFileSync').mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) => {
writtenPaths.push(String(p));
return origWriteFileSync(p, ...args);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Pipeline failure: fix TS2556 — cannot spread any[] into overloaded fs.writeFileSync.

fs.writeFileSync has multiple overloaded signatures and TypeScript cannot resolve which overload to use when the rest of the arguments is typed as any[]. This is the root cause of the reported TS2556 CI failure.

🐛 Proposed fix
-  const spy = spyOn(fs, 'writeFileSync').mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) => {
-    writtenPaths.push(String(p));
-    return origWriteFileSync(p, ...args);
-  });
+  const spy = spyOn(fs, 'writeFileSync').mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) => {
+    writtenPaths.push(String(p));
+    return (origWriteFileSync as (p: fs.PathOrFileDescriptor, ...a: any[]) => void)(p, ...args);
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const origWriteFileSync = fs.writeFileSync.bind(fs);
const spy = spyOn(fs, 'writeFileSync').mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) => {
writtenPaths.push(String(p));
return origWriteFileSync(p, ...args);
});
const origWriteFileSync = fs.writeFileSync.bind(fs);
const spy = spyOn(fs, 'writeFileSync').mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) => {
writtenPaths.push(String(p));
return (origWriteFileSync as (p: fs.PathOrFileDescriptor, ...a: any[]) => void)(p, ...args);
});
🧰 Tools
🪛 GitHub Actions: Dev Pre-release

[error] 251-251: TypeScript error TS2556: A spread argument must either have a tuple type or be passed to a rest parameter. (Occurred during bun run typecheck with 'tsc --noEmit')

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

In `@console/tests/settings-routes.test.ts` around lines 248 - 252, The test fails
TS2556 because mockImplementation uses a loose any[] spread against overloaded
fs.writeFileSync; change the mock to use the exact parameter tuple type: replace
the current mockImplementation((p: fs.PathOrFileDescriptor, ...args: any[]) =>
{...}) with mockImplementation((...args: Parameters<typeof fs.writeFileSync>) =>
{ writtenPaths.push(String(args[0])); return origWriteFileSync(...args); });
this uses Parameters<typeof fs.writeFileSync> and ensures origWriteFileSync
(bound via fs.writeFileSync.bind(fs)) is called with correctly-typed arguments
while still recording the written path.

## [Model Configuration](#model-configuration)

Claude Code gives you multiple ways to set your model, with a clear priority chain. Higher-priority settings override lower ones:
**If you use Claude Pilot:** The easiest way to configure models is through the [Pilot Console](http://localhost:41777/#/settings) (`localhost:41777/#/settings`). You can set a different model for each `/spec` phase, slash command, and sub-agent, then restart Pilot to apply. Pilot injects your preferences into all the right places automatically — no manual file editing needed.
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

Localhost link in public docs is dead for readers not running Pilot.

The hyperlink [Pilot Console](http://localhost:41777/#/settings) only resolves when Pilot is actively running on the reader's machine. Readers browsing the public docs site without Pilot running will land on a connection-refused page. Consider adding a brief parenthetical, e.g. "(requires Pilot to be running locally)", and simplifying the redundant inline-code repetition of the same URL.

✏️ Suggested wording
-**If you use Claude Pilot:** The easiest way to configure models is through the [Pilot Console](http://localhost:41777/#/settings) (`localhost:41777/#/settings`). You can set a different model for each `/spec` phase, slash command, and sub-agent, then restart Pilot to apply. Pilot injects your preferences into all the right places automatically — no manual file editing needed.
+**If you use Claude Pilot:** The easiest way to configure models is through the Pilot Console at `localhost:41777/#/settings` (requires Pilot to be running locally). You can set a different model for each `/spec` phase, slash command, and sub-agent, then restart Pilot to apply. Pilot injects your preferences into all the right places automatically — no manual file editing needed.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**If you use Claude Pilot:** The easiest way to configure models is through the [Pilot Console](http://localhost:41777/#/settings) (`localhost:41777/#/settings`). You can set a different model for each `/spec` phase, slash command, and sub-agent, then restart Pilot to apply. Pilot injects your preferences into all the right places automatically — no manual file editing needed.
**If you use Claude Pilot:** The easiest way to configure models is through the Pilot Console at `localhost:41777/#/settings` (requires Pilot to be running locally). You can set a different model for each `/spec` phase, slash command, and sub-agent, then restart Pilot to apply. Pilot injects your preferences into all the right places automatically — no manual file editing needed.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/site/src/content/blog/model-selection.md` at line 175, Update the "Pilot
Console" link text to clarify it only works when Pilot is running locally and
remove the redundant inline-code URL; specifically change the hyperlink "[Pilot
Console](http://localhost:41777/#/settings)" to something like "Pilot Console
(requires Pilot running locally)" and delete the repeated
`localhost:41777/#/settings` inline code, or replace the link with a
documentation/hosted URL if one exists; ensure the sentence still tells users
they can set models per `/spec` phase, slash command, and sub-agent and that
restarting Pilot applies changes.

Comment on lines +293 to +333
def _clone_vexor_fork() -> Path | None:
"""Clone the vexor fork with MLX support to ~/.pilot/vexor."""
vexor_dir = Path.home() / ".pilot" / "vexor"

if vexor_dir.exists():
try:
subprocess.run(
["git", "fetch", "origin", VEXOR_MLX_BRANCH],
capture_output=True,
cwd=vexor_dir,
timeout=60,
)
subprocess.run(
["git", "checkout", VEXOR_MLX_BRANCH],
capture_output=True,
cwd=vexor_dir,
timeout=30,
)
subprocess.run(
["git", "pull", "origin", VEXOR_MLX_BRANCH],
capture_output=True,
cwd=vexor_dir,
timeout=60,
)
return vexor_dir
except Exception:
return None

try:
vexor_dir.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
["git", "clone", "--branch", VEXOR_MLX_BRANCH, "--single-branch", VEXOR_FORK_URL, str(vexor_dir)],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0:
return vexor_dir
except Exception:
pass
return None
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

Silent git failures when updating an existing clone.

When ~/.pilot/vexor already exists (lines 298-319), all three subprocess.run calls (fetch, checkout, pull) lack check=True and their return codes are never inspected. If any of them fail (e.g., network down), execution continues silently and returns the possibly-stale directory at line 317.

This is likely intentional as a resilience choice (a stale clone is better than no clone), but it means the subsequent _install_vexor_from_local may install from outdated sources without any warning.

Optional: log a warning on non-zero exit
             subprocess.run(
                 ["git", "fetch", "origin", VEXOR_MLX_BRANCH],
                 capture_output=True,
                 cwd=vexor_dir,
                 timeout=60,
             )
-            subprocess.run(
+            checkout = subprocess.run(
                 ["git", "checkout", VEXOR_MLX_BRANCH],
                 capture_output=True,
                 cwd=vexor_dir,
                 timeout=30,
             )
-            subprocess.run(
+            pull = subprocess.run(
                 ["git", "pull", "origin", VEXOR_MLX_BRANCH],
                 capture_output=True,
                 cwd=vexor_dir,
                 timeout=60,
             )
+            if checkout.returncode != 0 or pull.returncode != 0:
+                # Stale clone may still work; proceed with best-effort
+                pass
             return vexor_dir

As per coding guidelines, "Proper error handling and user feedback" should be ensured for installer code.

🧰 Tools
🪛 Ruff (0.15.1)

[error] 299-299: subprocess call: check for execution of untrusted input

(S603)


[error] 300-300: Starting a process with a partial executable path

(S607)


[error] 305-305: subprocess call: check for execution of untrusted input

(S603)


[error] 306-306: Starting a process with a partial executable path

(S607)


[error] 311-311: subprocess call: check for execution of untrusted input

(S603)


[error] 312-312: Starting a process with a partial executable path

(S607)


[warning] 317-317: Consider moving this statement to an else block

(TRY300)


[warning] 318-318: Do not catch blind exception: Exception

(BLE001)


[error] 323-323: subprocess call: check for execution of untrusted input

(S603)


[error] 324-324: Starting a process with a partial executable path

(S607)


[error] 331-332: try-except-pass detected, consider logging the exception

(S110)


[warning] 331-331: Do not catch blind exception: Exception

(BLE001)

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

In `@installer/steps/dependencies.py` around lines 293 - 333, The
_clone_vexor_fork function currently ignores failures when updating an existing
clone; change the three subprocess.run calls that perform ["git", "fetch",
"checkout", "pull"] to either use check=True or inspect result.returncode,
capture text output, and on non-zero exit log a warning (including stdout/stderr
and VEXOR_MLX_BRANCH/VEXOR_FORK_URL context) and return None instead of silently
returning vexor_dir so downstream _install_vexor_from_local won't install from a
potentially stale copy; keep the existing safe-create/clone path intact and only
alter the update branch to surface errors to the user.

…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
@claude
Copy link

claude bot commented Feb 18, 2026

Claude finished @maxritter's task in 1m 6s —— View job


Incremental Code Review — PR #62

  • Read changed files
  • Check CI status (still running)
  • Compare against prior review findings
  • Post incremental review

Incremental Review (new push)

✅ Resolved from prior review

#9 — Atomic write test assertion fixed (console/tests/settings-routes.test.ts:260)
The weak || p !== configPath condition is gone. The new assertion is:

expect(writtenPaths.some(p => p.includes('.tmp'))).toBe(true);

This is correct. ✅

#5ModelRoutingInfo now uses useSettings() (console/src/ui/viewer/views/Usage/ModelRoutingInfo.tsx)
The component now derives planModel, implModel, verifyModel, etc. dynamically from the hook, with DEFAULT_SETTINGS fallbacks. The routing table is fully live. ✅

TS2556 fix — atomic write spy (console/tests/settings-routes.test.ts:249-252)
The spread type error is resolved by casting origWriteFileSync as Function and using .call(fs, p, ...args) instead of spreading into an overloaded signature. ✅


Still open (unresolved from prior review)

  • save() errors not surfaceduseSettings.ts:100-113: rejected Promise still not caught internally; error/saveError state never set on PUT failure; callers must handle the rejection externally.
  • Unknown command/agent keys acceptedSettingsRoutes.ts:103-123: validateSettings accepts arbitrary keys outside DEFAULT_SETTINGS, which accumulate silently in config.json.
  • No req.body null guardSettingsRoutes.ts:143: req.body as Record<string, unknown> is called unconditionally; if body parser is absent or Content-Type is wrong, this throws a TypeError instead of a clean 400.
  • Hardcoded description remainsModelRoutingInfo.tsx:63: "Routing defaults use Opus for planning and verification orchestration, Sonnet for implementation." is still a static string that diverges from the live cfg values when users change their configuration.
  • DEFAULT_SETTINGS duplicationSettingsRoutes.ts and useSettings.ts both define identical constants with no shared source; divergence risk remains.
  • useEffect fetch has no cleanupuseSettings.ts:60-74: no AbortController to cancel in-flight fetch on unmount.
  • DEFAULT_SETTINGS.agents not checked for 1Msettings-routes.test.ts:97-101: only commands values are iterated in the "no 1M models" test.
  • Localhost link in public docsdocs/site/src/content/blog/model-selection.md:175: [Pilot Console](http://localhost:41777/#/settings) is a dead link for readers without Pilot running; no clarifying note.
  • Missing 500-error test for handlePutsettings-routes.test.ts: no test exercises the writeConfigAtomic throw → 500 path.
  • Source-inspection test styleuseSettings.test.ts:70-78 still reads raw source to check for string literals (/api/settings, PUT, isLoading, etc.) rather than behavioral assertions.

CI workflows are still in progress — check back once the Dev Pre-release run completes to confirm no new regressions.

@maxritter maxritter merged commit 4e3bfb2 into main Feb 18, 2026
11 of 12 checks passed
@maxritter maxritter deleted the dev branch February 18, 2026 09:25
github-actions bot pushed a commit that referenced this pull request Feb 18, 2026
# [6.8.0](v6.7.7...v6.8.0) (2026-02-18)

### Features

* model selection settings, Apple Silicon Vexor acceleration, and worktree sync fixes ([#62](#62)) ([4e3bfb2](4e3bfb2))
@github-actions
Copy link
Contributor

🎉 This PR is included in version 6.8.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant