Skip to content

Windows: start_process with shell-name commands (e.g. powershell) breaks interact_with_process due to nested shell calling AllocConsole() #443

@timbuilt-DB

Description

@timbuilt-DB

Summary

On Windows, calling start_process with a command that is itself a shell invocation (e.g. powershell, powershell -NoProfile, cmd, bash) returns a PID successfully but the stdio channel is unusable. interact_with_process calls return ok but produce no effect, no prompt indicator is detected, and a visible orphan console window appears on the desktop.

Root cause is at the Win32 console layer: the inner shell calls AllocConsole() and switches its REPL to that console buffer, bypassing the inherited stdio pipes that DC opened.

Repro

  1. Windows 11, Node 24, @wonderwhy-er/desktop-commander 0.2.38
  2. DC config has "defaultShell": "powershell.exe"
  3. From a Claude Desktop session, call: start_process({ command: "powershell -NoProfile -NoLogo", timeout_ms: 5000 })
  4. Observe response: PID returned, but the (detected: ">") prompt indicator is missing
  5. Call interact_with_process({ pid: <pid>, input: "Get-Date" }) — returns ok but no output
  6. A visible PowerShell window appears on the desktop, orphaned (its parent process exits)

Expected

  • Prompt indicator detected
  • interact_with_process sends input to the REPL and reads output back
  • No visible window
  • No orphan

Mechanism

In dist/terminal-manager.js (lines 30-36), getShellSpawnArgs for PowerShell hardcodes the -Command wrapping:

if (shellName === 'powershell' || shellName === 'powershell.exe') {
    return {
        executable: shellPath,
        args: ['-Command', command],
        useShellOption: false
    };
}

When the user's command is itself a shell invocation, the resulting spawn is:

spawn("powershell.exe", ["-Command", "powershell -NoProfile -NoLogo"], {
    stdio: ['pipe','pipe','pipe'],
    windowsHide: true
})

Process tree at runtime:

  1. Outer powershell (DC's direct child): no console (windowsHide: true), stdio pipes inherited from DC. Runs the -Command payload and blocks on WaitForExit of its child.
  2. Inner powershell (-NoProfile -NoLogo): starts interactively, detects no inherited console, calls AllocConsole() -> gets its own visible console window.
  3. Inner's REPL uses [Console]::ReadLine(), which reads from the new console buffer, not from the inherited stdin pipe.
  4. DC tracks the outer's PID. interact_with_process writes bytes to the outer's stdin pipe. The outer is blocked on WaitForExit and does no forwarding. Bytes never reach the inner's REPL.
  5. Inner's prompts (PS C:\>) write to the new console, not to the inherited stdout pipe. DC's prompt detection reads from the pipe and sees nothing.

Note: windowsHide: true does not help — it only suppresses the immediate child's window. Once the inner calls AllocConsole(), the window appears anyway. Removing windowsHide doesn't fix it either; the console split happens regardless. Win32 console redirection is sticky once AllocConsole runs.

This affects every nested-shell case on Windows: powershell, pwsh, cmd, bash (under WSL/Git Bash), sh, zsh, fish.

Proposed fix

When the user's command is itself a shell invocation, skip the -Command wrapping and spawn that shell directly so it becomes DC's direct child. With pipes as stdio, the shell uses the pipes and does not call AllocConsole.

Patch in getShellSpawnArgs:

function getShellSpawnArgs(shellPath, command) {
    const shellName = path.basename(shellPath).toLowerCase();

    // If the user's command is itself a shell invocation, spawn it directly
    // so it inherits stdio pipes and avoids AllocConsole on Windows.
    const SHELL_RE = /^(powershell|pwsh|cmd|bash|sh|zsh|fish)(\.exe)?(\s|$)/i;
    const trimmed = command.trim();
    if (SHELL_RE.test(trimmed)) {
        const parts = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
        return {
            executable: parts[0],
            args: parts.slice(1).map(s => s.replace(/^"|"$/g, '')),
            useShellOption: false
        };
    }

    // ... existing logic ...
}

~15 lines. execute_command (one-shot) is unaffected because callers don't typically pass a bare shell name to it.

Workaround for users until this lands

  • Use execute_command for one-shot commands
  • Use start_process only with non-shell REPLs that respect inherited stdio: python -i, node, sqlite3 mydb.db. Those work because the single-process REPL inherits pipes correctly without the nested-shell AllocConsole problem.
  • On Windows, do not use start_process with powershell, pwsh, cmd, or bash until fixed.

Diagnosis credit

Independent verification by a second AI assistant (Windsurf Cascade) corrected an initial misdiagnosis (timing race) to the actual mechanism (AllocConsole stdio split). Worth noting in case anyone tries to test the fix by "keeping the outer alive longer" — that won't validate anything because the console split happens at inner spawn time regardless of outer lifetime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions