diff --git a/PowerShell.MCP.Proxy/Program.cs b/PowerShell.MCP.Proxy/Program.cs index 40f76e1..3b959d0 100644 --- a/PowerShell.MCP.Proxy/Program.cs +++ b/PowerShell.MCP.Proxy/Program.cs @@ -25,6 +25,13 @@ public static async Task Main(string[] args) PwshLauncherShared.SuppressProfileOnInteractive = Array.Exists(args, a => string.Equals(a, "--no-profile", StringComparison.OrdinalIgnoreCase)); + // Opt-in: `--no-activate` starts the Windows console with SW_SHOWNOACTIVATE, + // so it does not pull focus away from whatever the user is typing into. + // Default off — a console that appears in front is usually what you want. + // No-op on macOS/Linux, where the terminal emulator owns window activation. + PwshLauncherShared.SuppressWindowActivation = + Array.Exists(args, a => string.Equals(a, "--no-activate", StringComparison.OrdinalIgnoreCase)); + var builder = Host.CreateApplicationBuilder(args); builder.Logging.AddConsole(consoleLogOptions => diff --git a/PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs b/PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs index 9579cf1..da317e6 100644 --- a/PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs +++ b/PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs @@ -214,6 +214,23 @@ internal static string BuildLinuxPwshCommand(string encodedCommand, bool noProfi // blocking on input (e.g. Read-Host) in a process with no console. internal static string[] BuildHeadlessPwshArguments(string initCommand) => [NoProfileArgument, "-NoExit", "-Command", initCommand]; + + // STARTUPINFOW.dwFlags / wShowWindow values used by the Windows launcher. + internal const uint STARTF_USESHOWWINDOW = 0x00000001; + internal const ushort SW_SHOWNOACTIVATE = 4; + + // Set once at startup from the proxy's --no-activate command-line flag. + // Gates SW_SHOWNOACTIVATE on the Windows console launcher only: macOS and + // Linux hand window creation to the user's terminal emulator, which the proxy + // does not control. Default false keeps today's behaviour, where the new + // console takes foreground. + internal static bool SuppressWindowActivation { get; set; } + + // Extracted so the flag mapping is unit-testable without spawning a console. + // Returning (0, 0) leaves STARTUPINFOW untouched, so CreateProcessW applies its + // default activation - bit-for-bit the pre-flag code path. + internal static (uint DwFlags, ushort WShowWindow) BuildWindowsShowWindow(bool noActivate) => + noActivate ? (STARTF_USESHOWWINDOW, SW_SHOWNOACTIVATE) : (0u, (ushort)0); } /// @@ -297,6 +314,15 @@ public static int LaunchPwsh(string agentId, string? startupCommands = null, str var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var si = new STARTUPINFOW { cb = (uint)Marshal.SizeOf() }; + + // Opt-in (--no-activate): create the console without stealing focus. + // wShowWindow is ignored unless STARTF_USESHOWWINDOW is set, so the + // default path leaves both fields zeroed exactly as before. + var (showFlags, showWindow) = PwshLauncherShared.BuildWindowsShowWindow( + PwshLauncherShared.SuppressWindowActivation); + si.dwFlags |= showFlags; + si.wShowWindow = showWindow; + var pi = new PROCESS_INFORMATION(); // Build command with optional startup commands (pre-built Write-Host statements) diff --git a/README.md b/README.md index a5ed333..496f099 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,28 @@ claude mcp add pwsh -s user -- "$(Get-MCPProxyPath)" --no-profile > The headless / CI launcher always uses `-NoProfile` regardless of this flag. +### Background consoles (`--no-activate`, Windows) + +By default the console the proxy launches takes foreground when it appears. That is usually what you want — you asked the AI to run something and you want to watch it. If the console is long-lived and you keep working in another app while the AI uses it, that same behaviour steals focus mid-keystroke. Pass `--no-activate` to create the window with `SW_SHOWNOACTIVATE`: it still appears, is still fully visible and interactive (so `Read-Host`, credential and elevation prompts keep working), it just does not take focus. + +**Claude Code:** +```powershell +claude mcp add pwsh -s user -- "$(Get-MCPProxyPath)" --no-activate +``` + +**Claude Desktop / other MCP clients:** + +```json +"mcpServers": { + "pwsh": { + "command": "...PowerShell.MCP.Proxy.exe", + "args": ["--no-activate"] + } +} +``` + +> Windows only. On macOS and Linux the terminal emulator owns window activation, so the flag is a no-op there. Combine with `--no-profile` if you want both. + --- ## First-Time Demo diff --git a/Tests/Unit/Proxy/PwshLauncherSharedTests.cs b/Tests/Unit/Proxy/PwshLauncherSharedTests.cs index 152e5c1..8bd8746 100644 --- a/Tests/Unit/Proxy/PwshLauncherSharedTests.cs +++ b/Tests/Unit/Proxy/PwshLauncherSharedTests.cs @@ -17,6 +17,34 @@ public class PwshLauncherSharedTests private const string DefaultAgentId = "default"; private const int DefaultPid = 12345; + // --no-activate maps to STARTF_USESHOWWINDOW + SW_SHOWNOACTIVATE. Windows ignores + // wShowWindow unless STARTF_USESHOWWINDOW is present, so the default must leave + // both fields zeroed to stay bit-for-bit identical to the pre-flag behaviour. + [Fact] + public void BuildWindowsShowWindow_Default_LeavesStartupInfoUntouched() + { + var (dwFlags, wShowWindow) = PwshLauncherShared.BuildWindowsShowWindow(noActivate: false); + + Assert.Equal(0u, dwFlags); + Assert.Equal((ushort)0, wShowWindow); + } + + [Fact] + public void BuildWindowsShowWindow_NoActivate_RequestsShowNoActivate() + { + var (dwFlags, wShowWindow) = PwshLauncherShared.BuildWindowsShowWindow(noActivate: true); + + Assert.Equal(PwshLauncherShared.STARTF_USESHOWWINDOW, dwFlags & PwshLauncherShared.STARTF_USESHOWWINDOW); + Assert.Equal(PwshLauncherShared.SW_SHOWNOACTIVATE, wShowWindow); + } + + [Fact] + public void BuildWindowsShowWindow_UsesDocumentedWin32Constants() + { + Assert.Equal(0x00000001u, PwshLauncherShared.STARTF_USESHOWWINDOW); + Assert.Equal((ushort)4, PwshLauncherShared.SW_SHOWNOACTIVATE); + } + [Fact] public void BuildInitCommand_DefaultAgent_ContainsNoDoubleSingleQuotes() {