Merge pull request #306 from chr1syy/fix-windows-probing

Windows enhancements and fixes
This commit is contained in:
Pedram Amini
2026-02-05 15:36:05 -06:00
committed by GitHub
9 changed files with 88 additions and 22 deletions

View File

@@ -63,6 +63,21 @@ describe('path-prober', () => {
} }
}); });
it('should include nvm4w and npm paths on Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
const env = getExpandedEnv();
// Check for nvm4w paths (OpenCode commonly installed here)
expect(env.PATH).toContain('C:\\nvm4w\\nodejs');
// Check for npm global paths
expect(env.PATH).toMatch(/AppData[\\\/](npm|Roaming[\\\/]npm)/);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should preserve existing PATH entries', () => { it('should preserve existing PATH entries', () => {
const originalPath = process.env.PATH; const originalPath = process.env.PATH;
const testPath = '/test/custom/path'; const testPath = '/test/custom/path';

View File

@@ -84,6 +84,11 @@ export function getExpandedEnv(): NodeJS.ProcessEnv {
// Node.js // Node.js
path.join(programFiles, 'nodejs'), path.join(programFiles, 'nodejs'),
path.join(localAppData, 'Programs', 'node'), path.join(localAppData, 'Programs', 'node'),
// Node Version Manager for Windows (nvm4w) - OpenCode commonly installed here
'C:\\nvm4w\\nodejs',
path.join(home, 'nvm4w', 'nodejs'),
// Volta - Node version manager for Windows/macOS/Linux (installs shims to .volta/bin)
path.join(home, '.volta', 'bin'),
// Scoop package manager (OpenCode, other tools) // Scoop package manager (OpenCode, other tools)
path.join(home, 'scoop', 'shims'), path.join(home, 'scoop', 'shims'),
path.join(home, 'scoop', 'apps', 'opencode', 'current'), path.join(home, 'scoop', 'apps', 'opencode', 'current'),
@@ -252,6 +257,9 @@ function getWindowsKnownPaths(binaryName: string): string[] {
// Scoop installation (recommended for OpenCode) // Scoop installation (recommended for OpenCode)
path.join(home, 'scoop', 'shims', 'opencode.exe'), path.join(home, 'scoop', 'shims', 'opencode.exe'),
path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'), path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'),
// Volta - Node version manager (OpenCode commonly installed via Volta)
path.join(home, '.volta', 'bin', 'opencode'),
path.join(home, '.volta', 'bin', 'opencode.cmd'),
// Chocolatey installation // Chocolatey installation
path.join( path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
@@ -471,13 +479,16 @@ export async function checkBinaryExists(binaryName: string): Promise<BinaryDetec
.filter((p) => p); .filter((p) => p);
if (process.platform === 'win32' && matches.length > 0) { if (process.platform === 'win32' && matches.length > 0) {
// On Windows, prefer .exe over .cmd over extensionless // On Windows, prefer .exe > extensionless (shell scripts) > .cmd
// This helps with proper execution handling // This helps avoid cmd.exe limitations and supports PowerShell/bash scripts
const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe')); const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe'));
const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd')); const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd'));
const extensionlessMatch = matches.find(
(p) => !p.toLowerCase().endsWith('.exe') && !p.toLowerCase().endsWith('.cmd')
);
// Return the best match: .exe > .cmd > first result // Return the best match: .exe > extensionless shell scripts > .cmd > first result
let bestMatch = exeMatch || cmdMatch || matches[0]; let bestMatch = exeMatch || extensionlessMatch || cmdMatch || matches[0];
// If the first match doesn't have an extension, check if .cmd or .exe version exists // 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 // This handles cases where 'where' returns a path without extension

View File

@@ -452,6 +452,7 @@ ${message}`;
}); });
const configResolution = applyAgentConfigOverrides(agent, baseArgs, { const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
agentConfigValues, agentConfigValues,
sessionCustomModel: chat.moderatorConfig?.customModel,
sessionCustomArgs: chat.moderatorConfig?.customArgs, sessionCustomArgs: chat.moderatorConfig?.customArgs,
sessionCustomEnvVars: chat.moderatorConfig?.customEnvVars, sessionCustomEnvVars: chat.moderatorConfig?.customEnvVars,
}); });
@@ -849,7 +850,9 @@ export async function routeModeratorResponse(
// Apply SSH wrapping if configured for this session // Apply SSH wrapping if configured for this session
if (sshStore && matchingSession?.sshRemoteConfig) { if (sshStore && matchingSession?.sshRemoteConfig) {
console.log(`[GroupChat:Debug] Applying SSH wrapping for participant ${participantName}...`); console.log(
`[GroupChat:Debug] Applying SSH wrapping for participant ${participantName}...`
);
const sshWrapped = await wrapSpawnWithSsh( const sshWrapped = await wrapSpawnWithSsh(
{ {
command: spawnCommand, command: spawnCommand,
@@ -1149,6 +1152,7 @@ Review the agent responses above. Either:
}); });
const configResolution = applyAgentConfigOverrides(agent, baseArgs, { const configResolution = applyAgentConfigOverrides(agent, baseArgs, {
agentConfigValues, agentConfigValues,
sessionCustomModel: chat.moderatorConfig?.customModel,
sessionCustomArgs: chat.moderatorConfig?.customArgs, sessionCustomArgs: chat.moderatorConfig?.customArgs,
sessionCustomEnvVars: chat.moderatorConfig?.customEnvVars, sessionCustomEnvVars: chat.moderatorConfig?.customEnvVars,
}); });

View File

@@ -292,7 +292,8 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
) as Record<string, string>; ) as Record<string, string>;
// Determine an explicit shell to use when forcing shell execution on Windows. // Determine an explicit shell to use when forcing shell execution on Windows.
// Prefer a user-configured custom shell path, otherwise fall back to COMSPEC/cmd.exe. // Prefer a user-configured custom shell path, then PowerShell, then ComSpec/cmd.exe.
// PowerShell is preferred over cmd.exe for better script handling and to avoid cmd.exe limits.
const customShellPath = settingsStore.get('customShellPath', '') as string; const customShellPath = settingsStore.get('customShellPath', '') as string;
if (customShellPath && customShellPath.trim()) { if (customShellPath && customShellPath.trim()) {
shellToUse = customShellPath.trim(); shellToUse = customShellPath.trim();
@@ -300,8 +301,20 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
customShellPath: shellToUse, customShellPath: shellToUse,
}); });
} else if (!shellToUse) { } else if (!shellToUse) {
// Use COMSPEC if available, otherwise default to cmd.exe // Try PowerShell if available (common on modern Windows)
shellToUse = process.env.ComSpec || 'cmd.exe'; // If not, fall back to ComSpec/cmd.exe
// PowerShell handles shell scripts better and avoids cmd.exe command line length limits
const powerShellPath = process.env.PSHOME
? `${process.env.PSHOME}\\powershell.exe`
: 'powershell';
shellToUse = powerShellPath;
logger.debug(
'Using PowerShell for agent execution on Windows (shell script support)',
LOG_CONTEXT,
{
shellPath: shellToUse,
}
);
} }
logger.info(`Forcing shell execution for agent on Windows for PATH access`, LOG_CONTEXT, { logger.info(`Forcing shell execution for agent on Windows for PATH access`, LOG_CONTEXT, {

View File

@@ -3,6 +3,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getOutputParser } from '../../parsers'; import { getOutputParser } from '../../parsers';
import { getAgentCapabilities } from '../../agents'; import { getAgentCapabilities } from '../../agents';
@@ -188,6 +189,24 @@ export class ChildProcessSpawner {
); );
} }
// Auto-enable shell for Windows when command is a shell script (extensionless with shebang)
// This handles tools like OpenCode installed via npm with shell scripts
if (isWindows && !useShell && !commandExt && commandHasPath) {
try {
const fileContent = fs.readFileSync(spawnCommand, 'utf8');
if (fileContent.startsWith('#!')) {
useShell = true;
logger.info(
'[ProcessManager] Auto-enabling shell for Windows to execute shell script',
'ProcessManager',
{ command: spawnCommand, shebang: fileContent.split('\n')[0] }
);
}
} catch {
// If we can't read the file, just continue without special handling
}
}
if (isWindows && useShell) { if (isWindows && useShell) {
logger.debug( logger.debug(
'[ProcessManager] Forcing shell=true for agent spawn on Windows (runInShell or auto)', '[ProcessManager] Forcing shell=true for agent spawn on Windows (runInShell or auto)',

View File

@@ -8433,6 +8433,7 @@ You are taking over this conversation. Based on the context above, provide a bri
customPath?: string; customPath?: string;
customArgs?: string; customArgs?: string;
customEnvVars?: Record<string, string>; customEnvVars?: Record<string, string>;
customModel?: string;
} }
) => { ) => {
const chat = await window.maestro.groupChat.create(name, moderatorAgentId, moderatorConfig); const chat = await window.maestro.groupChat.create(name, moderatorAgentId, moderatorConfig);

View File

@@ -347,23 +347,22 @@ export function EditGroupChatModal({
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Dropdown */} {/* Dropdown */}
<div className="relative flex-1"> <div className="relative flex-1" style={{ zIndex: 10000 }}>
<select <select
value={selectedAgent || ''} value={selectedAgent || ''}
onChange={(e) => handleAgentChange(e.target.value)} onChange={(e) => handleAgentChange(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded-lg border outline-none appearance-none cursor-pointer text-sm" className="w-full px-3 py-2 pr-10 rounded-lg border outline-none appearance-none cursor-pointer text-sm relative"
style={{ style={{
backgroundColor: theme.colors.bgMain, backgroundColor: theme.colors.bgMain,
borderColor: theme.colors.border, borderColor: theme.colors.border,
color: theme.colors.textMain, color: theme.colors.textMain,
zIndex: 10000,
}} }}
aria-label="Select moderator agent" aria-label="Select moderator agent"
> >
{availableTiles.map((tile) => { {availableTiles.map((tile) => {
const isBeta = const isBeta =
tile.id === 'codex' || tile.id === 'codex' || tile.id === 'opencode' || tile.id === 'factory-droid';
tile.id === 'opencode' ||
tile.id === 'factory-droid';
return ( return (
<option key={tile.id} value={tile.id}> <option key={tile.id} value={tile.id}>
{tile.name} {tile.name}
@@ -374,7 +373,7 @@ export function EditGroupChatModal({
</select> </select>
<ChevronDown <ChevronDown
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none" className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: theme.colors.textDim }} style={{ color: theme.colors.textDim, zIndex: 10001 }}
/> />
</div> </div>

View File

@@ -163,15 +163,18 @@ export function NewGroupChatModal({
// Build moderator config from state // Build moderator config from state
const buildModeratorConfig = useCallback((): ModeratorConfig | undefined => { const buildModeratorConfig = useCallback((): ModeratorConfig | undefined => {
const hasConfig = customPath || customArgs || Object.keys(customEnvVars).length > 0; const customModelValue = agentConfig.model;
const hasConfig =
customPath || customArgs || Object.keys(customEnvVars).length > 0 || customModelValue;
if (!hasConfig) return undefined; if (!hasConfig) return undefined;
return { return {
customPath: customPath || undefined, customPath: customPath || undefined,
customArgs: customArgs || undefined, customArgs: customArgs || undefined,
customEnvVars: Object.keys(customEnvVars).length > 0 ? customEnvVars : undefined, customEnvVars: Object.keys(customEnvVars).length > 0 ? customEnvVars : undefined,
customModel: customModelValue || undefined,
}; };
}, [customPath, customArgs, customEnvVars]); }, [customPath, customArgs, customEnvVars, agentConfig]);
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
if (name.trim() && selectedAgent) { if (name.trim() && selectedAgent) {
@@ -337,23 +340,22 @@ export function NewGroupChatModal({
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Dropdown */} {/* Dropdown */}
<div className="relative flex-1"> <div className="relative flex-1" style={{ zIndex: 10000 }}>
<select <select
value={selectedAgent || ''} value={selectedAgent || ''}
onChange={(e) => handleAgentChange(e.target.value)} onChange={(e) => handleAgentChange(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded-lg border outline-none appearance-none cursor-pointer text-sm" className="w-full px-3 py-2 pr-10 rounded-lg border outline-none appearance-none cursor-pointer text-sm relative"
style={{ style={{
backgroundColor: theme.colors.bgMain, backgroundColor: theme.colors.bgMain,
borderColor: theme.colors.border, borderColor: theme.colors.border,
color: theme.colors.textMain, color: theme.colors.textMain,
zIndex: 10000,
}} }}
aria-label="Select moderator agent" aria-label="Select moderator agent"
> >
{availableTiles.map((tile) => { {availableTiles.map((tile) => {
const isBeta = const isBeta =
tile.id === 'codex' || tile.id === 'codex' || tile.id === 'opencode' || tile.id === 'factory-droid';
tile.id === 'opencode' ||
tile.id === 'factory-droid';
return ( return (
<option key={tile.id} value={tile.id}> <option key={tile.id} value={tile.id}>
{tile.name} {tile.name}
@@ -364,7 +366,7 @@ export function NewGroupChatModal({
</select> </select>
<ChevronDown <ChevronDown
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none" className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: theme.colors.textDim }} style={{ color: theme.colors.textDim, zIndex: 10001 }}
/> />
</div> </div>

View File

@@ -73,6 +73,8 @@ export interface ModeratorConfig {
customArgs?: string; customArgs?: string;
/** Custom environment variables */ /** Custom environment variables */
customEnvVars?: Record<string, string>; customEnvVars?: Record<string, string>;
/** Custom model selection (e.g., 'ollama/qwen3:8b') */
customModel?: string;
/** SSH remote config for remote execution */ /** SSH remote config for remote execution */
sshRemoteConfig?: { sshRemoteConfig?: {
enabled: boolean; enabled: boolean;