diff --git a/daemon/agents.js b/daemon/agents.js index 4095d25..2eb1659 100644 --- a/daemon/agents.js +++ b/daemon/agents.js @@ -6,21 +6,30 @@ import path from 'node:path'; const execFileP = promisify(execFile); -// Per-agent model picker: +// Per-agent model picker. // -// - `models` : selectable model presets shown in the UI. The -// first entry is treated as the default. `id` -// of `'default'` means "let the CLI pick" — the -// agent runs with no `--model` flag, so the -// user's local CLI config wins. +// - `listModels` : optional spec for fetching the model list from +// the CLI itself ({ args, parse, timeoutMs }). +// When defined we run it during agent detection +// (best-effort, with a timeout) and use the +// result. If the listing fails we fall back to +// `fallbackModels` so the UI still has something +// to show. +// - `fallbackModels` : static hint list. Used as the source of truth +// for CLIs that don't expose a listing command +// (Claude Code, Codex, Gemini CLI, Qwen Code) +// and as the fallback for the others. // - `reasoningOptions` : optional reasoning-effort presets (currently -// only Codex exposes this knob). Same `default` -// convention as models. +// only Codex exposes this knob). // - `buildArgs(prompt, imagePaths, extraAllowedDirs, options)` returns // argv for the child process. `options = { model, reasoning }` carries // whatever the user picked in the model menu — agents that don't take a // model flag ignore them. // +// Every model list is prefixed with a synthetic `'default'` entry meaning +// "let the CLI pick" — the agent runs with no `--model` flag, so the +// user's local CLI config wins. +// // `extraAllowedDirs` is a list of absolute directories the agent must be // permitted to read files from (skill seeds, design-system specs) that live // outside the project cwd. Currently only Claude Code wires this through @@ -32,17 +41,45 @@ const execFileP = promisify(execFile); // `--output-format stream-json`. Daemon parses it into typed events // (text / thinking / tool_use / tool_result / status) for the UI. // - 'plain' (default) : raw text, forwarded chunk-by-chunk. + +const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' }; + +// Parse one-id-per-line stdout from ` models` and prepend the synthetic +// default option. Used by opencode / cursor-agent. +function parseLineSeparatedModels(stdout) { + const ids = String(stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + // De-dupe while preserving order — some CLIs print near-duplicates. + const seen = new Set(); + const out = [DEFAULT_MODEL_OPTION]; + for (const id of ids) { + if (seen.has(id)) continue; + seen.add(id); + out.push({ id, label: id }); + } + return out; +} + export const AGENT_DEFS = [ { id: 'claude', name: 'Claude Code', bin: 'claude', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, - { id: 'claude-opus-4-5', label: 'Opus 4.5' }, - { id: 'claude-sonnet-4-5', label: 'Sonnet 4.5' }, - { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, + // `claude` has no list-models subcommand; the CLI accepts both short + // aliases (sonnet/opus/haiku) and the full ids, so we ship both as + // hints. Users who want a non-shipped model can paste it via the + // Settings dialog's custom-model input. + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'sonnet', label: 'Sonnet (alias)' }, + { id: 'opus', label: 'Opus (alias)' }, + { id: 'haiku', label: 'Haiku (alias)' }, + { id: 'claude-opus-4-5', label: 'claude-opus-4-5' }, + { id: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' }, + { id: 'claude-haiku-4-5', label: 'claude-haiku-4-5' }, ], buildArgs: (prompt, _imagePaths, extraAllowedDirs = [], options = {}) => { const args = [ @@ -71,8 +108,10 @@ export const AGENT_DEFS = [ name: 'Codex CLI', bin: 'codex', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, + // Codex doesn't have a `models` subcommand; ship the most common ids + // as a hint. Users can supply other ids via the custom-model input. + fallbackModels: [ + DEFAULT_MODEL_OPTION, { id: 'gpt-5-codex', label: 'gpt-5-codex' }, { id: 'gpt-5', label: 'gpt-5' }, { id: 'o3', label: 'o3' }, @@ -105,10 +144,10 @@ export const AGENT_DEFS = [ name: 'Gemini CLI', bin: 'gemini', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, - { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, - { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'gemini-2.5-pro', label: 'gemini-2.5-pro' }, + { id: 'gemini-2.5-flash', label: 'gemini-2.5-flash' }, ], buildArgs: (prompt, _imagePaths, _extra, options = {}) => { const args = []; @@ -125,13 +164,17 @@ export const AGENT_DEFS = [ name: 'OpenCode', bin: 'opencode', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, - { id: 'anthropic/claude-sonnet-4-5', label: 'Anthropic · Sonnet 4.5' }, - { id: 'anthropic/claude-opus-4-5', label: 'Anthropic · Opus 4.5' }, - { id: 'anthropic/claude-haiku-4-5', label: 'Anthropic · Haiku 4.5' }, - { id: 'openai/gpt-5', label: 'OpenAI · gpt-5' }, - { id: 'google/gemini-2.5-pro', label: 'Google · Gemini 2.5 Pro' }, + // `opencode models` prints `provider/model` per line. + listModels: { + args: ['models'], + parse: parseLineSeparatedModels, + timeoutMs: 8000, + }, + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'anthropic/claude-sonnet-4-5', label: 'anthropic/claude-sonnet-4-5' }, + { id: 'openai/gpt-5', label: 'openai/gpt-5' }, + { id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' }, ], buildArgs: (prompt, _imagePaths, _extra, options = {}) => { const args = ['run']; @@ -148,11 +191,23 @@ export const AGENT_DEFS = [ name: 'Cursor Agent', bin: 'cursor-agent', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, - { id: 'auto', label: 'Auto' }, - { id: 'claude-4-sonnet', label: 'Claude 4 Sonnet' }, - { id: 'claude-4.5-sonnet', label: 'Claude 4.5 Sonnet' }, + // `cursor-agent models` prints account-bound model ids per line. When + // the user isn't authed it prints "No models available for this + // account." — that's not a model list, so we detect it and fall back. + listModels: { + args: ['models'], + timeoutMs: 5000, + parse: (stdout) => { + const trimmed = String(stdout || '').trim(); + if (!trimmed || /no models available/i.test(trimmed)) return null; + return parseLineSeparatedModels(trimmed); + }, + }, + fallbackModels: [ + DEFAULT_MODEL_OPTION, + { id: 'auto', label: 'auto' }, + { id: 'sonnet-4', label: 'sonnet-4' }, + { id: 'sonnet-4-thinking', label: 'sonnet-4-thinking' }, { id: 'gpt-5', label: 'gpt-5' }, ], buildArgs: (prompt, _imagePaths, _extra, options = {}) => { @@ -170,8 +225,8 @@ export const AGENT_DEFS = [ name: 'Qwen Code', bin: 'qwen', versionArgs: ['--version'], - models: [ - { id: 'default', label: 'Default (CLI config)' }, + fallbackModels: [ + DEFAULT_MODEL_OPTION, { id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' }, { id: 'qwen3-coder-flash', label: 'qwen3-coder-flash' }, ], @@ -202,9 +257,36 @@ function resolveOnPath(bin) { return null; } +async function fetchModels(def, resolvedBin) { + if (!def.listModels) return def.fallbackModels; + try { + const { stdout } = await execFileP(resolvedBin, def.listModels.args, { + timeout: def.listModels.timeoutMs ?? 5000, + // Models lists from popular CLIs (e.g. opencode) easily exceed the + // default 1MB buffer once you include every openrouter model. Bump + // it so we don't truncate the listing. + maxBuffer: 8 * 1024 * 1024, + }); + const parsed = def.listModels.parse(stdout); + // Empty / null parse result means the CLI didn't actually return a + // usable list (e.g. cursor-agent's "No models available"); fall back + // to the static hint so the picker isn't stuck on Default-only. + if (!parsed || parsed.length === 0) return def.fallbackModels; + return parsed; + } catch { + return def.fallbackModels; + } +} + async function probe(def) { const resolved = resolveOnPath(def.bin); - if (!resolved) return { ...stripFns(def), available: false }; + if (!resolved) { + return { + ...stripFns(def), + models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION], + available: false, + }; + } let version = null; try { const { stdout } = await execFileP(resolved, def.versionArgs, { timeout: 3000 }); @@ -212,21 +294,75 @@ async function probe(def) { } catch { // binary exists but --version failed; still mark available } - return { ...stripFns(def), available: true, path: resolved, version }; + const models = await fetchModels(def, resolved); + return { + ...stripFns(def), + models, + available: true, + path: resolved, + version, + }; } function stripFns(def) { - // Drop the buildArgs closure but keep declarative metadata (models, - // reasoningOptions, streamFormat) so the frontend can render the picker - // without a second round-trip. - const { buildArgs, ...rest } = def; + // Drop the buildArgs / listModels closures but keep declarative metadata + // (reasoningOptions, streamFormat, name, bin, etc.). `models` is + // populated separately by `fetchModels`, so we strip the static + // `fallbackModels` slot here too. + const { buildArgs, listModels, fallbackModels, ...rest } = def; return rest; } export async function detectAgents() { - return Promise.all(AGENT_DEFS.map(probe)); + const results = await Promise.all(AGENT_DEFS.map(probe)); + // Refresh the validation cache from whatever we just surfaced to the UI + // so /api/chat can accept any model the user could have just picked, + // including ones that only showed up after a CLI re-auth. + for (const agent of results) { + rememberLiveModels(agent.id, agent.models); + } + return results; } export function getAgentDef(id) { return AGENT_DEFS.find((a) => a.id === id) || null; } + +// Daemon's /api/chat needs to validate the user's model pick against the +// list we last surfaced to the UI. We keep a per-agent cache of the most +// recent live list (refreshed every detectAgents() call) and additionally +// trust any value present in the static fallback. A model that's neither +// gets rejected so a stale or hostile value can't smuggle arbitrary flags. +const liveModelCache = new Map(); + +export function rememberLiveModels(agentId, models) { + if (!Array.isArray(models)) return; + liveModelCache.set( + agentId, + new Set(models.map((m) => m && m.id).filter((id) => typeof id === 'string')), + ); +} + +export function isKnownModel(def, modelId) { + if (!modelId) return false; + const live = liveModelCache.get(def.id); + if (live && live.has(modelId)) return true; + if (Array.isArray(def.fallbackModels)) { + return def.fallbackModels.some((m) => m.id === modelId); + } + return false; +} + +// Permit user-typed model ids that didn't appear in either the live +// listing or the static fallback (e.g. the user is on a brand-new model +// the CLI's `models` command hasn't surfaced yet). The CLI gets the value +// as a child-process arg — not a shell string — so injection isn't a +// concern, but we still reject anything that could be misread as a flag +// by a downstream CLI or that contains whitespace / control chars. +export function sanitizeCustomModel(id) { + if (typeof id !== 'string') return null; + const trimmed = id.trim(); + if (trimmed.length === 0 || trimmed.length > 200) return null; + if (!/^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/.test(trimmed)) return null; + return trimmed; +} diff --git a/daemon/server.js b/daemon/server.js index 0bf8d80..10c63e1 100644 --- a/daemon/server.js +++ b/daemon/server.js @@ -6,7 +6,12 @@ import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; -import { detectAgents, getAgentDef } from './agents.js'; +import { + detectAgents, + getAgentDef, + isKnownModel, + sanitizeCustomModel, +} from './agents.js'; import { listSkills } from './skills.js'; import { listDesignSystems, readDesignSystem } from './design-systems.js'; import { createClaudeStreamHandler } from './claude-stream.js'; @@ -782,11 +787,15 @@ export async function startServer({ port = 7456 } = {}) { (d) => fs.existsSync(d), ); // Per-agent model + reasoning the user picked in the model menu. - // Validated against the agent's declared options so a stale or hostile - // value can't smuggle arbitrary flags into the spawned argv. + // Trust the value when it matches the most recent /api/agents listing + // (live or fallback). Otherwise allow it through if it passes a + // permissive sanitizer — that's the path for user-typed custom model + // ids the CLI's listing didn't surface yet. const safeModel = - typeof model === 'string' && Array.isArray(def.models) - ? def.models.find((m) => m.id === model)?.id ?? null + typeof model === 'string' + ? isKnownModel(def, model) + ? model + : sanitizeCustomModel(model) : null; const safeReasoning = typeof reasoning === 'string' && Array.isArray(def.reasoningOptions) diff --git a/src/components/AvatarMenu.tsx b/src/components/AvatarMenu.tsx index a3a5416..3cc2c8d 100644 --- a/src/components/AvatarMenu.tsx +++ b/src/components/AvatarMenu.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { AgentIcon } from './AgentIcon'; import { Icon } from './Icon'; +import { renderModelOptions } from './modelOptions'; import type { AgentInfo, AppConfig, ExecMode } from '../types'; interface Props { @@ -197,11 +198,20 @@ export function AvatarMenu({ }) } > - {currentAgent.models.map((m) => ( - - ))} + ) : null} ) : null} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 4b04c2c..a9fba06 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -2,6 +2,11 @@ import { useEffect, useMemo, useState } from 'react'; import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n'; import type { Locale } from '../i18n'; import { AgentIcon } from './AgentIcon'; +import { + CUSTOM_MODEL_SENTINEL, + isCustomModel, + renderModelOptions, +} from './modelOptions'; import type { AgentInfo, AppConfig, ExecMode } from '../types'; interface Props { @@ -214,6 +219,11 @@ export function SettingsDialog({ const reasoningValue = choice.reasoning ?? selected.reasoningOptions?.[0]?.id ?? ''; + const customActive = + hasModels && isCustomModel(modelValue, selected.models!); + const selectValue = customActive + ? CUSTOM_MODEL_SENTINEL + : modelValue; return (
{hasModels ? ( @@ -222,17 +232,41 @@ export function SettingsDialog({ {t('settings.modelPicker')} ) : null} + {customActive ? ( + + ) : null} {hasReasoning ? (