Files
Maestro/src/main/agent-detector.ts
chr1syy 0c9a0dedbf Windows Enhancement and Fixes for 0.15.0-RC (#264)
* refactor: consolidate PATH building logic into shared utilities

- Add buildExpandedPath() and buildExpandedEnv() to shared/pathUtils.ts
- Refactor 5 files to use shared PATH functions, eliminating ~170 lines of duplication
- Fix Windows .NET SDK PATH issue by including dotnet installation paths
- Ensure consistent cross-platform PATH handling across CLI agents, detectors, and process managers

Files changed:
- src/shared/pathUtils.ts (added 2 functions)
- src/cli/services/agent-spawner.ts (refactored 3 functions)
- src/main/utils/cliDetection.ts (refactored 1 function)
- src/main/agent-detector.ts (refactored 1 method)
- src/main/process-manager/utils/envBuilder.ts (refactored 1 function)

* refactor: consolidate PATH building logic into shared utilities

- Add buildExpandedPath() and buildExpandedEnv() to shared/pathUtils.ts
- Refactor 5 files to use shared PATH functions, eliminating ~170 lines of duplication
- Fix Windows .NET SDK PATH issue by including dotnet installation paths
- Ensure consistent cross-platform PATH handling across CLI agents, detectors, and process managers

Files changed:
- src/shared/pathUtils.ts (added 2 functions)
- src/cli/services/agent-spawner.ts (refactored 3 functions)
- src/main/utils/cliDetection.ts (refactored 1 function)
- src/main/agent-detector.ts (refactored 1 method)
- src/main/process-manager/utils/envBuilder.ts (refactored 1 function)

* fix(windows): enable PATH access for agent processes

Remove faulty basename rewriting that prevented shell execution from working properly on Windows. Full executable paths are now passed directly to cmd.exe, allowing agents to access PATH and run commands like node -v and dotnet -h.

- Modified process.ts to keep full paths when using shell execution
- Updated ChildProcessSpawner.ts to avoid basename rewriting
- Fixes ENOENT errors when agents spawn on Windows

Resolves issue where agents couldn't execute PATH-based commands.

fix: add missing lint-staged configuration

Add lint-staged configuration to package.json to run prettier and eslint on staged TypeScript files before commits.

* fix(windows): resolve SSH path detection with CRLF line endings

Fix SSH command spawning failure on Windows by properly handling CRLF line endings from the 'where' command. The issue was that result.stdout.trim().split('\n')[0] left trailing \r characters in detected paths, causing ENOENT errors when spawning SSH processes.

Updated detectSshPath() to use split(/\r?\n/) for cross-platform line ending handling
Applied same fix to detect cloudflared and gh paths for consistency
Ensures SSH binary paths are clean of trailing whitespace/carriage returns
Resolves "spawn C:\Windows\System32\OpenSSH\ssh.exe\r ENOENT" errors when using SSH remote agents on Windows.

* fix: resolve SSH remote execution issues with stream-json and slash commands

- Fix SSH remote execution failing with stream-json input by detecting
  --input-format stream-json and sending prompts via stdin instead of
  command line arguments, preventing shell interpretation of markdown
  content (fixes GitHub issue #262)

- Add sendPromptViaStdin flag to ProcessConfig interface for explicit
  stream-json mode detection

- Implement proper image support in buildStreamJsonMessage for Claude
  Code stream-json format, parsing data URLs and including images as
  base64 content in the message

- Add file existence check in discoverSlashCommands to prevent ENOENT
  errors when agent paths are invalid

- Simplify ChildProcessSpawner stdin handling to ensure images are
  always included in stream-json messages sent via stdin

- Update stream-json message format to use proper {"type": "user_message",
  "content": [...]} structure with text and image content arrays
2026-01-31 17:38:15 -05:00

860 lines
30 KiB
TypeScript

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<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
}
export const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'>[] = [
{
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 <id>] -- "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 <id>] [--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 <id> (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<AgentConfig[]> | null = null;
private customPaths: Record<string, string> = {};
// Cache for model discovery results: agentId -> { models, timestamp }
private modelCache: Map<string, { models: string[]; timestamp: number }> = 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<string, string>): void {
this.customPaths = paths;
// Clear cache when custom paths change
this.cachedAgents = null;
}
/**
* Get the current custom paths
*/
getCustomPaths(): Record<string, string> {
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<AgentConfig[]> {
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<AgentConfig[]> {
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<boolean> => {
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<string | null> {
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<string, string[]> = {
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<string | null> {
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<string, string[]> = {
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<AgentConfig | null> {
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<string[]> {
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<string[]> {
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 [];
}
}
}