import { execFileNoThrow } from './utils/execFile'; import { logger } from './utils/logger'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities'; import { expandTilde, detectNodeVersionManagerBinPaths, buildExpandedEnv } from '../shared/pathUtils'; // Re-export AgentCapabilities for convenience export { AgentCapabilities } from './agent-capabilities'; // Configuration option types for agent-specific settings export interface AgentConfigOption { key: string; // Storage key type: 'checkbox' | 'text' | 'number' | 'select'; label: string; // UI label description: string; // Help text default: any; // Default value options?: string[]; // For select type argBuilder?: (value: any) => string[]; // Converts config value to CLI args } export interface AgentConfig { id: string; name: string; binaryName: string; command: string; args: string[]; // Base args always included (excludes batch mode prefix) available: boolean; path?: string; customPath?: string; // User-specified custom path (shown in UI even if not available) requiresPty?: boolean; // Whether this agent needs a pseudo-terminal configOptions?: AgentConfigOption[]; // Agent-specific configuration hidden?: boolean; // If true, agent is hidden from UI (internal use only) capabilities: AgentCapabilities; // Agent feature capabilities // Argument builders for dynamic CLI construction // These are optional - agents that don't have them use hardcoded behavior batchModePrefix?: string[]; // Args added before base args for batch mode (e.g., ['run'] for OpenCode) batchModeArgs?: string[]; // Args only applied in batch mode (e.g., ['--skip-git-repo-check'] for Codex exec) jsonOutputArgs?: string[]; // Args for JSON output format (e.g., ['--format', 'json']) resumeArgs?: (sessionId: string) => string[]; // Function to build resume args readOnlyArgs?: string[]; // Args for read-only/plan mode (e.g., ['--agent', 'plan']) modelArgs?: (modelId: string) => string[]; // Function to build model selection args (e.g., ['--model', modelId]) yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox']) workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir]) imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex) promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it) defaultEnvVars?: Record; // Default environment variables for this agent (merged with user customEnvVars) } export const AGENT_DEFINITIONS: Omit[] = [ { id: 'terminal', name: 'Terminal', // Use platform-appropriate default shell binaryName: process.platform === 'win32' ? 'powershell.exe' : 'bash', command: process.platform === 'win32' ? 'powershell.exe' : 'bash', args: [], requiresPty: true, hidden: true, // Internal agent, not shown in UI }, { id: 'claude-code', name: 'Claude Code', binaryName: 'claude', command: 'claude', // YOLO mode (--dangerously-skip-permissions) is always enabled - Maestro requires it args: [ '--print', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', ], resumeArgs: (sessionId: string) => ['--resume', sessionId], // Resume with session ID readOnlyArgs: ['--permission-mode', 'plan'], // Read-only/plan mode }, { id: 'codex', name: 'Codex', binaryName: 'codex', command: 'codex', // Base args for interactive mode (no flags that are exec-only) args: [], // Codex CLI argument builders // Batch mode: codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check [--sandbox read-only] [-C dir] [resume ] -- "prompt" // Sandbox modes: // - Default (YOLO): --dangerously-bypass-approvals-and-sandbox (full system access, required by Maestro) // - Read-only: --sandbox read-only (can only read files, overrides YOLO) batchModePrefix: ['exec'], // Codex uses 'exec' subcommand for batch mode batchModeArgs: ['--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'], // Args only valid on 'exec' subcommand jsonOutputArgs: ['--json'], // JSON output format (must come before resume subcommand) resumeArgs: (sessionId: string) => ['resume', sessionId], // Resume with session/thread ID readOnlyArgs: ['--sandbox', 'read-only'], // Read-only/plan mode yoloModeArgs: ['--dangerously-bypass-approvals-and-sandbox'], // Full access mode workingDirArgs: (dir: string) => ['-C', dir], // Set working directory imageArgs: (imagePath: string) => ['-i', imagePath], // Image attachment: codex exec -i /path/to/image.png // Agent-specific configuration options shown in UI configOptions: [ { key: 'contextWindow', type: 'number', label: 'Context Window Size', description: 'Maximum context window size in tokens. Required for context usage display. Common values: 400000 (GPT-5.2), 128000 (GPT-4o).', default: 400000, // Default for GPT-5.2 models }, ], }, { id: 'gemini-cli', name: 'Gemini CLI', binaryName: 'gemini', command: 'gemini', args: [], }, { id: 'qwen3-coder', name: 'Qwen3 Coder', binaryName: 'qwen3-coder', command: 'qwen3-coder', args: [], }, { id: 'opencode', name: 'OpenCode', binaryName: 'opencode', command: 'opencode', args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand) // OpenCode CLI argument builders // Batch mode: opencode run --format json [--model provider/model] [--session ] [--agent plan] "prompt" // YOLO mode (auto-approve all permissions) is enabled via OPENCODE_CONFIG_CONTENT env var. // This prevents OpenCode from prompting for permission on external_directory access, which would hang in batch mode. batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode jsonOutputArgs: ['--format', 'json'], // JSON output format resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b') imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt" noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args // Default env vars: enable YOLO mode (allow all permissions including external_directory) // Users can override by setting customEnvVars in agent config defaultEnvVars: { OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow","external_directory":"allow"}}', }, // Agent-specific configuration options shown in UI configOptions: [ { key: 'model', type: 'text', label: 'Model', description: 'Model to use (e.g., "ollama/qwen3:8b", "anthropic/claude-sonnet-4-20250514"). Leave empty for default.', default: '', // Empty string means use OpenCode's default model argBuilder: (value: string) => { // Only add --model arg if a model is specified if (value && value.trim()) { return ['--model', value.trim()]; } return []; }, }, { key: 'contextWindow', type: 'number', label: 'Context Window Size', description: 'Maximum context window size in tokens. Required for context usage display. Varies by model (e.g., 400000 for Claude/GPT-5.2, 128000 for GPT-4o).', default: 128000, // Default for common models (GPT-4, etc.) }, ], }, { id: 'factory-droid', name: 'Factory Droid', binaryName: 'droid', command: 'droid', args: [], // Base args for interactive mode (none) requiresPty: false, // Batch mode uses child process // Batch mode: droid exec [options] "prompt" batchModePrefix: ['exec'], // Always skip permissions in batch mode (like Claude Code's --dangerously-skip-permissions) // Maestro requires full access to work properly batchModeArgs: ['--skip-permissions-unsafe'], // JSON output for parsing jsonOutputArgs: ['-o', 'stream-json'], // Session resume: -s (requires a prompt) resumeArgs: (sessionId: string) => ['-s', sessionId], // Read-only mode is DEFAULT in droid exec (no flag needed) readOnlyArgs: [], // YOLO mode (same as batchModeArgs, kept for explicit yoloMode requests) yoloModeArgs: ['--skip-permissions-unsafe'], // Model selection is handled by configOptions.model.argBuilder below // Don't define modelArgs here to avoid duplicate -m flags // Working directory workingDirArgs: (dir: string) => ['--cwd', dir], // File/image input imageArgs: (imagePath: string) => ['-f', imagePath], // Prompt is positional argument (no separator needed) noPromptSeparator: true, // Default env vars - don't set NO_COLOR as it conflicts with FORCE_COLOR defaultEnvVars: {}, // UI config options // Model IDs from droid CLI (exact IDs required) // NOTE: autonomyLevel is NOT configurable - Maestro always uses --skip-permissions-unsafe // which conflicts with --auto. This matches Claude Code's behavior. configOptions: [ { key: 'model', type: 'select', label: 'Model', description: 'Model to use for Factory Droid', // Model IDs from `droid exec --help` (2026-01-22) options: [ '', // Empty = use droid's default (claude-opus-4-5-20251101) // OpenAI models 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.2', // Claude models 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001', // Google models 'gemini-3-pro-preview', ], default: '', // Empty = use droid's default (claude-opus-4-5-20251101) argBuilder: (value: string) => (value && value.trim() ? ['-m', value.trim()] : []), }, { key: 'reasoningEffort', type: 'select', label: 'Reasoning Effort', description: 'How much the model should reason before responding', options: ['', 'low', 'medium', 'high'], default: '', // Empty = use droid's default reasoning argBuilder: (value: string) => (value && value.trim() ? ['-r', value.trim()] : []), }, { key: 'contextWindow', type: 'number', label: 'Context Window Size', description: 'Maximum context window in tokens (for UI display)', default: 200000, }, ], }, ]; export class AgentDetector { private cachedAgents: AgentConfig[] | null = null; private detectionInProgress: Promise | null = null; private customPaths: Record = {}; // Cache for model discovery results: agentId -> { models, timestamp } private modelCache: Map = new Map(); // Cache TTL: 5 minutes (model lists don't change frequently) private readonly MODEL_CACHE_TTL_MS = 5 * 60 * 1000; /** * Set custom paths for agents (from user configuration) */ setCustomPaths(paths: Record): void { this.customPaths = paths; // Clear cache when custom paths change this.cachedAgents = null; } /** * Get the current custom paths */ getCustomPaths(): Record { return { ...this.customPaths }; } /** * Detect which agents are available on the system * Uses promise deduplication to prevent parallel detection when multiple calls arrive simultaneously */ async detectAgents(): Promise { if (this.cachedAgents) { return this.cachedAgents; } // If detection is already in progress, return the same promise to avoid parallel runs if (this.detectionInProgress) { return this.detectionInProgress; } // Start detection and track the promise this.detectionInProgress = this.doDetectAgents(); try { return await this.detectionInProgress; } finally { this.detectionInProgress = null; } } /** * Internal method that performs the actual agent detection */ private async doDetectAgents(): Promise { const agents: AgentConfig[] = []; const expandedEnv = this.getExpandedEnv(); logger.info(`Agent detection starting. PATH: ${expandedEnv.PATH}`, 'AgentDetector'); for (const agentDef of AGENT_DEFINITIONS) { const customPath = this.customPaths[agentDef.id]; let detection: { exists: boolean; path?: string }; // If user has specified a custom path, check that first if (customPath) { detection = await this.checkCustomPath(customPath); if (detection.exists) { logger.info( `Agent "${agentDef.name}" found at custom path: ${detection.path}`, 'AgentDetector' ); } else { logger.warn( `Agent "${agentDef.name}" custom path not valid: ${customPath}`, 'AgentDetector' ); // Fall back to PATH detection detection = await this.checkBinaryExists(agentDef.binaryName); if (detection.exists) { logger.info( `Agent "${agentDef.name}" found in PATH at: ${detection.path}`, 'AgentDetector' ); } } } else { detection = await this.checkBinaryExists(agentDef.binaryName); if (detection.exists) { logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, 'AgentDetector'); } else if (agentDef.binaryName !== 'bash') { // Don't log bash as missing since it's always present, log others as warnings logger.warn( `Agent "${agentDef.name}" (binary: ${agentDef.binaryName}) not found. ` + `Searched in PATH: ${expandedEnv.PATH}`, 'AgentDetector' ); } } agents.push({ ...agentDef, available: detection.exists, path: detection.path, customPath: customPath || undefined, capabilities: getAgentCapabilities(agentDef.id), }); } const availableAgents = agents.filter((a) => a.available); const isWindows = process.platform === 'win32'; // On Windows, log detailed path info to help debug shell execution issues if (isWindows) { logger.info(`Agent detection complete (Windows)`, 'AgentDetector', { platform: process.platform, agents: availableAgents.map((a) => ({ id: a.id, name: a.name, path: a.path, pathExtension: a.path ? path.extname(a.path) : 'none', // .exe = direct execution, .cmd = requires shell willUseShell: a.path ? a.path.toLowerCase().endsWith('.cmd') || a.path.toLowerCase().endsWith('.bat') || !path.extname(a.path) : true, })), }); } else { logger.info( `Agent detection complete. Available: ${availableAgents.map((a) => a.name).join(', ') || 'none'}`, 'AgentDetector' ); } this.cachedAgents = agents; return agents; } /** * Check if a custom path points to a valid executable * On Windows, also tries .cmd and .exe extensions if the path doesn't exist as-is */ private async checkCustomPath(customPath: string): Promise<{ exists: boolean; path?: string }> { const isWindows = process.platform === 'win32'; // Expand tilde to home directory (Node.js fs doesn't understand ~) const expandedPath = expandTilde(customPath); // Helper to check if a specific path exists and is a file const checkPath = async (pathToCheck: string): Promise => { try { const stats = await fs.promises.stat(pathToCheck); return stats.isFile(); } catch { return false; } }; try { // First, try the exact path provided (with tilde expanded) if (await checkPath(expandedPath)) { // Check if file is executable (on Unix systems) if (!isWindows) { try { await fs.promises.access(expandedPath, fs.constants.X_OK); } catch { logger.warn(`Custom path exists but is not executable: ${customPath}`, 'AgentDetector'); return { exists: false }; } } // Return the expanded path so it can be used directly return { exists: true, path: expandedPath }; } // On Windows, if the exact path doesn't exist, try with .cmd and .exe extensions if (isWindows) { const lowerPath = expandedPath.toLowerCase(); // Only try extensions if the path doesn't already have one if (!lowerPath.endsWith('.cmd') && !lowerPath.endsWith('.exe')) { // Try .exe first (preferred), then .cmd const exePath = expandedPath + '.exe'; if (await checkPath(exePath)) { logger.debug(`Custom path resolved with .exe extension`, 'AgentDetector', { original: customPath, resolved: exePath, }); return { exists: true, path: exePath }; } const cmdPath = expandedPath + '.cmd'; if (await checkPath(cmdPath)) { logger.debug(`Custom path resolved with .cmd extension`, 'AgentDetector', { original: customPath, resolved: cmdPath, }); return { exists: true, path: cmdPath }; } } } return { exists: false }; } catch { return { exists: false }; } } /** * Build an expanded PATH that includes common binary installation locations. * This is necessary because packaged Electron apps don't inherit shell environment. */ private getExpandedEnv(): NodeJS.ProcessEnv { return buildExpandedEnv(); } /** * On Windows, directly probe known installation paths for a binary. * This is more reliable than `where` command which may fail in packaged Electron apps. * Returns the first existing path found, preferring .exe over .cmd. */ private async probeWindowsPaths(binaryName: string): Promise { const home = os.homedir(); const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'); const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; // Define known installation paths for each binary, in priority order // Prefer .exe (standalone installers) over .cmd (npm wrappers) const knownPaths: Record = { claude: [ // PowerShell installer (primary method) - installs claude.exe path.join(home, '.local', 'bin', 'claude.exe'), // Winget installation path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'claude.exe'), path.join(programFiles, 'WinGet', 'Links', 'claude.exe'), // npm global installation - creates .cmd wrapper path.join(appData, 'npm', 'claude.cmd'), path.join(localAppData, 'npm', 'claude.cmd'), // WindowsApps (Microsoft Store style) path.join(localAppData, 'Microsoft', 'WindowsApps', 'claude.exe'), ], codex: [ // npm global installation (primary method for Codex) path.join(appData, 'npm', 'codex.cmd'), path.join(localAppData, 'npm', 'codex.cmd'), // Possible standalone in future path.join(home, '.local', 'bin', 'codex.exe'), ], opencode: [ // Scoop installation (recommended for OpenCode) path.join(home, 'scoop', 'shims', 'opencode.exe'), path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'), // Chocolatey installation path.join( process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin', 'opencode.exe' ), // Go install path.join(home, 'go', 'bin', 'opencode.exe'), // npm (has known issues on Windows, but check anyway) path.join(appData, 'npm', 'opencode.cmd'), ], gemini: [ // npm global installation path.join(appData, 'npm', 'gemini.cmd'), path.join(localAppData, 'npm', 'gemini.cmd'), ], droid: [ // Factory Droid installation paths path.join(home, '.factory', 'bin', 'droid.exe'), path.join(localAppData, 'Factory', 'droid.exe'), path.join(appData, 'Factory', 'droid.exe'), path.join(home, '.local', 'bin', 'droid.exe'), // npm global installation path.join(appData, 'npm', 'droid.cmd'), path.join(localAppData, 'npm', 'droid.cmd'), ], }; const pathsToCheck = knownPaths[binaryName] || []; for (const probePath of pathsToCheck) { try { await fs.promises.access(probePath, fs.constants.F_OK); logger.debug(`Direct probe found ${binaryName}`, 'AgentDetector', { path: probePath }); return probePath; } catch { // Path doesn't exist, continue to next } } return null; } /** * On macOS/Linux, directly probe known installation paths for a binary. * This is necessary because packaged Electron apps don't inherit shell aliases, * and 'which' may fail to find binaries in non-standard locations. * Returns the first existing executable path found. */ private async probeUnixPaths(binaryName: string): Promise { const home = os.homedir(); // Get dynamic paths from Node version managers (nvm, fnm, volta, etc.) const versionManagerPaths = detectNodeVersionManagerBinPaths(); // Define known installation paths for each binary, in priority order const knownPaths: Record = { claude: [ // Claude Code default installation location (irm https://claude.ai/install.ps1 equivalent on macOS) path.join(home, '.claude', 'local', 'claude'), // User local bin (pip, manual installs) path.join(home, '.local', 'bin', 'claude'), // Homebrew on Apple Silicon '/opt/homebrew/bin/claude', // Homebrew on Intel Mac '/usr/local/bin/claude', // npm global with custom prefix path.join(home, '.npm-global', 'bin', 'claude'), // User bin directory path.join(home, 'bin', 'claude'), // Add paths from Node version managers (nvm, fnm, volta, etc.) ...versionManagerPaths.map((p) => path.join(p, 'claude')), ], codex: [ // User local bin path.join(home, '.local', 'bin', 'codex'), // Homebrew paths '/opt/homebrew/bin/codex', '/usr/local/bin/codex', // npm global path.join(home, '.npm-global', 'bin', 'codex'), // Add paths from Node version managers (nvm, fnm, volta, etc.) ...versionManagerPaths.map((p) => path.join(p, 'codex')), ], opencode: [ // OpenCode installer default location path.join(home, '.opencode', 'bin', 'opencode'), // Go install location path.join(home, 'go', 'bin', 'opencode'), // User local bin path.join(home, '.local', 'bin', 'opencode'), // Homebrew paths '/opt/homebrew/bin/opencode', '/usr/local/bin/opencode', // Add paths from Node version managers (nvm, fnm, volta, etc.) ...versionManagerPaths.map((p) => path.join(p, 'opencode')), ], gemini: [ // npm global paths path.join(home, '.npm-global', 'bin', 'gemini'), '/opt/homebrew/bin/gemini', '/usr/local/bin/gemini', // Add paths from Node version managers (nvm, fnm, volta, etc.) ...versionManagerPaths.map((p) => path.join(p, 'gemini')), ], droid: [ // Factory Droid installation paths path.join(home, '.factory', 'bin', 'droid'), path.join(home, '.local', 'bin', 'droid'), '/opt/homebrew/bin/droid', '/usr/local/bin/droid', // Add paths from Node version managers (in case installed via npm) ...versionManagerPaths.map((p) => path.join(p, 'droid')), ], }; const pathsToCheck = knownPaths[binaryName] || []; for (const probePath of pathsToCheck) { try { // Check both existence and executability await fs.promises.access(probePath, fs.constants.F_OK | fs.constants.X_OK); logger.debug(`Direct probe found ${binaryName}`, 'AgentDetector', { path: probePath }); return probePath; } catch { // Path doesn't exist or isn't executable, continue to next } } return null; } /** * Check if a binary exists in PATH * On Windows, this also handles .cmd and .exe extensions properly */ private async checkBinaryExists(binaryName: string): Promise<{ exists: boolean; path?: string }> { const isWindows = process.platform === 'win32'; // First try direct file probing of known installation paths // This is more reliable than which/where in packaged Electron apps if (isWindows) { const probedPath = await this.probeWindowsPaths(binaryName); if (probedPath) { return { exists: true, path: probedPath }; } logger.debug(`Direct probe failed for ${binaryName}, falling back to where`, 'AgentDetector'); } else { // macOS/Linux: probe known paths first const probedPath = await this.probeUnixPaths(binaryName); if (probedPath) { return { exists: true, path: probedPath }; } logger.debug(`Direct probe failed for ${binaryName}, falling back to which`, 'AgentDetector'); } try { // Use 'which' on Unix-like systems, 'where' on Windows const command = isWindows ? 'where' : 'which'; // Use expanded PATH to find binaries in common installation locations // This is critical for packaged Electron apps which don't inherit shell env const env = this.getExpandedEnv(); const result = await execFileNoThrow(command, [binaryName], undefined, env); if (result.exitCode === 0 && result.stdout.trim()) { // Get all matches (Windows 'where' can return multiple) // Handle both Unix (\n) and Windows (\r\n) line endings const matches = result.stdout .trim() .split(/\r?\n/) .map((p) => p.trim()) .filter((p) => p); if (process.platform === 'win32' && matches.length > 0) { // On Windows, prefer .exe over .cmd over extensionless // This helps with proper execution handling const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe')); const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd')); // Return the best match: .exe > .cmd > first result let bestMatch = exeMatch || cmdMatch || matches[0]; // If the first match doesn't have an extension, check if .cmd or .exe version exists // This handles cases where 'where' returns a path without extension if ( !bestMatch.toLowerCase().endsWith('.exe') && !bestMatch.toLowerCase().endsWith('.cmd') ) { const cmdPath = bestMatch + '.cmd'; const exePath = bestMatch + '.exe'; // Check if the .exe or .cmd version exists try { await fs.promises.access(exePath, fs.constants.F_OK); bestMatch = exePath; logger.debug(`Found .exe version of ${binaryName}`, 'AgentDetector', { path: exePath, }); } catch { try { await fs.promises.access(cmdPath, fs.constants.F_OK); bestMatch = cmdPath; logger.debug(`Found .cmd version of ${binaryName}`, 'AgentDetector', { path: cmdPath, }); } catch { // Neither .exe nor .cmd exists, use the original path } } } logger.debug(`Windows binary detection for ${binaryName}`, 'AgentDetector', { allMatches: matches, selectedMatch: bestMatch, isCmd: bestMatch.toLowerCase().endsWith('.cmd'), isExe: bestMatch.toLowerCase().endsWith('.exe'), }); return { exists: true, path: bestMatch, }; } return { exists: true, path: matches[0], // First match for Unix }; } return { exists: false }; } catch { return { exists: false }; } } /** * Get a specific agent by ID */ async getAgent(agentId: string): Promise { const agents = await this.detectAgents(); return agents.find((a) => a.id === agentId) || null; } /** * Clear the cache (useful if PATH changes) */ clearCache(): void { this.cachedAgents = null; } /** * Clear the model cache for a specific agent or all agents */ clearModelCache(agentId?: string): void { if (agentId) { this.modelCache.delete(agentId); } else { this.modelCache.clear(); } } /** * Discover available models for an agent that supports model selection. * Returns cached results if available and not expired. * * @param agentId - The agent identifier (e.g., 'opencode') * @param forceRefresh - If true, bypass cache and fetch fresh model list * @returns Array of model names, or empty array if agent doesn't support model discovery */ async discoverModels(agentId: string, forceRefresh = false): Promise { const agent = await this.getAgent(agentId); if (!agent || !agent.available) { logger.warn(`Cannot discover models: agent ${agentId} not available`, 'AgentDetector'); return []; } // Check if agent supports model selection if (!agent.capabilities.supportsModelSelection) { logger.debug(`Agent ${agentId} does not support model selection`, 'AgentDetector'); return []; } // Check cache unless force refresh if (!forceRefresh) { const cached = this.modelCache.get(agentId); if (cached && Date.now() - cached.timestamp < this.MODEL_CACHE_TTL_MS) { logger.debug(`Returning cached models for ${agentId}`, 'AgentDetector'); return cached.models; } } // Run agent-specific model discovery command const models = await this.runModelDiscovery(agentId, agent); // Cache the results this.modelCache.set(agentId, { models, timestamp: Date.now() }); return models; } /** * Run the agent-specific model discovery command. * Each agent may have a different way to list available models. */ private async runModelDiscovery(agentId: string, agent: AgentConfig): Promise { const env = this.getExpandedEnv(); const command = agent.path || agent.command; // Agent-specific model discovery commands switch (agentId) { case 'opencode': { // OpenCode: `opencode models` returns one model per line const result = await execFileNoThrow(command, ['models'], undefined, env); if (result.exitCode !== 0) { logger.warn( `Model discovery failed for ${agentId}: exit code ${result.exitCode}`, 'AgentDetector', { stderr: result.stderr } ); return []; } // Parse output: one model per line (e.g., "opencode/gpt-5-nano", "ollama/gpt-oss:latest") const models = result.stdout .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); logger.info(`Discovered ${models.length} models for ${agentId}`, 'AgentDetector', { models, }); return models; } default: // For agents without model discovery implemented, return empty array logger.debug(`No model discovery implemented for ${agentId}`, 'AgentDetector'); return []; } } }