mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
* 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
860 lines
30 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
}
|