Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions PowerShell.MCP.Proxy/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
26 changes: 26 additions & 0 deletions PowerShell.MCP.Proxy/Services/PowerShellProcessManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down Expand Up @@ -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<STARTUPINFOW>() };

// 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)
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions Tests/Unit/Proxy/PwshLauncherSharedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down