# NousResearch/hermes-agent · Windows process spawn failures for npm/pip CLI tools

## Open now

- Nothing open on GitHub.

These issues and fixes address WinError 193 and related failures when spawning npm-installed language servers and CLI tools (like codex) on Windows, where CreateProcess cannot handle Unix shell scripts or unresolved PATHEXT extensions like .cmd. Fixes involve resolving executables via shutil.which or wrapping launchers appropriately.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #35250. Verify it addresses your case.
- **Recommended:** read PR #35250 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-35250`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #34864: LSP spawn fails on Windows with WinError 193 for npm-installed language servers

## Prior & competing PRs

- **#35250: fix(lsp): spawn + discover npm/pip language servers on native Windows**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/lsp/cli.py`, `agent/lsp/client.py`, `agent/lsp/install.py`, `scripts/release.py`, `tests/agent/lsp/test_install_and_lint_fixes.py`
  - fix hunks:
  - `agent/lsp/cli.py`

    ```diff
    @@ -247,18 +247,13 @@ def _cmd_restart() -> int:
     
     
     def _cmd_which(server_id: str) -> int:
    -    from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
    -    import shutil as _shutil
    +    from agent.lsp.install import INSTALL_RECIPES, _existing_binary
     
         recipe = INSTALL_RECIPES.get(server_id)
         bin_name = (recipe or {}).get("bin", server_id)
    -    staged = hermes_lsp_bin_dir() / bin_name
    -    if staged.exists():
    -        sys.stdout.write(str(staged) + "\n")
    -        return 0
    -    on_path = _shutil.which(bin_name)
    -    if on_path:
    -        sys.stdout.write(on_path + "\n")
    +    resolved = _existing_binary(bin_name)
    +    if resolved:
    +        sys.stdout.write(resolved + "\n")
             return 0
         sys.stderr.write(f"{server_id}: not installed\n")
         return 1
    ```

    ```diff
    @@ -292,11 +287,9 @@ def _backend_warnings() -> list:
         suggestion across common platforms.
         """
         import shutil as _shutil
    -    from agent.lsp.install import hermes_lsp_bin_dir
    +    from agent.lsp.install import _existing_binary
         notes: list = []
    -    bash_installed = _shutil.which("bash-language-server") is not None or (
    -        (hermes_lsp_bin_dir() / "bash-language-server").exists()
    -    )
    +    bash_installed = _existing_binary("bash-language-server") is not None
         if bash_installed and _shutil.which("shellcheck") is None:
             notes.append(
                 "bash-language-server is installed but shellcheck is missing — "
    ```
  - `agent/lsp/client.py`

    ```diff
    @@ -44,6 +44,7 @@
     import asyncio
     import logging
     import os
    +import sys
     from pathlib import Path
     from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
     from urllib.parse import quote, unquote
    ```

    ```diff
    @@ -244,15 +245,27 @@ async def start(self) -> None:
                 await self._cleanup_process()
                 raise
     
    +    @staticmethod
    +    def _win_wrap_cmd(cmd: List[str]) -> List[str]:
    +        """On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
    +        exe = cmd[0]
    +        if exe.lower().endswith((".cmd", ".bat")):
    +            return ["cmd.exe", "/c", *cmd]
    +        return cmd
    +
         async def _spawn(self) -> None:
             env = dict(os.environ)
             if self._env:
                 env.update(self._env)
     
    +        cmd = self._command
    +        if sys.platform == "win32":
    +            cmd = self._win_wrap_cmd(cmd)
    +
             try:
                 self._proc = await asyncio.create_subprocess_exec(
    -                self._command[0],
    -                *self._command[1:],
    +                cmd[0],
    +                *cmd[1:],
                     stdin=asyncio.subprocess.PIPE,
                     stdout=asyncio.subprocess.PIPE,
                     stderr=asyncio.subprocess.PIPE,
    ```

    ```diff
    @@ -261,7 +274,7 @@ async def _spawn(self) -> None:
                 )
             except FileNotFoundError as e:
                 raise LSPProtocolError(
    -                f"LSP server binary not found: {self._command[0]} ({e})"
    +                f"LSP server binary not found: {cmd[0]} ({e})"
                 ) from e
     
             # Drain stderr at debug level — if we don't, the pipe buffer
    ```
  - `agent/lsp/install.py`

    ```diff
    @@ -108,6 +108,11 @@
     _install_locks: Dict[str, threading.Lock] = {}
     _install_results: Dict[str, Optional[str]] = {}
     _install_lock_meta = threading.Lock()
    +_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
    +
    +
    +def _is_windows() -> bool:
    +    return os.name == "nt"
     
     
     def hermes_lsp_bin_dir() -> Path:
    ```

    ```diff
    @@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path:
         return p
     
     
    +def _native_binary_candidates(base: Path) -> list[Path]:
    +    """Return platform-native executable candidates for a staged binary."""
    +    candidates = [base]
    +    if _is_windows():
    +        existing = {str(base).lower()}
    +        for suffix in _WINDOWS_WRAPPER_SUFFIXES:
    +            candidate = Path(str(base) + suffix)
    +            key = str(candidate).lower()
    +            if key not in existing:
    +                candidates.append(candidate)
    +                existing.add(key)
    +    return candidates
    +
    +
     def _existing_binary(name: str) -> Optional[str]:
         """Probe the staging dir + PATH for a binary named ``name``."""
    -    staged = hermes_lsp_bin_dir() / name
    -    if staged.exists() and os.access(staged, os.X_OK):
    -        return str(staged)
    +    for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
    +        if staged.exists() and os.access(staged, os.X_OK):
    +            return str(staged)
         on_path = shutil.which(name)
         if on_path:
             return on_path
    +    if _is_windows():
    +        for suffix in _WINDOWS_WRAPPER_SUFFIXES:
    +            on_path = shutil.which(f"{name}{suffix}")
    +            if on_path:
    +                return on_path
         return None
     
     
    ```

    ```diff
    @@ -250,12 +274,7 @@ def _install_npm(
     
         # Find the bin
         nm_bin = staging / "node_modules" / ".bin" / bin_name
    -    if os.name == "nt":
    -        # On Windows npm sometimes drops `.cmd` shims
    -        candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
    -    else:
    -        candidates = [nm_bin]
    -    for c in candidates:
    +    for c in _native_binary_candidates(nm_bin):
             if c.exists():
                 # Symlink into our `lsp/bin/` for stable PATH access.
                 link = hermes_lsp_bin_dir() / c.name
    ```

    ```diff
    @@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
             logger.warning("[install] go install errored for %s: %s", pkg, e)
             return None
         bin_path = staging / bin_name
    -    if os.name == "nt":
    +    if _is_windows():
             bin_path = bin_path.with_suffix(".exe")
         if bin_path.exists():
             return str(bin_path)
    ```

    ```diff
    @@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
         except (subprocess.TimeoutExpired, OSError) as e:
             logger.warning("[install] pip install errored for %s: %s", pkg, e)
             return None
    -    # Look for the script
    -    bin_path = pip_target / "bin" / bin_name
    -    if bin_path.exists():
    -        link = hermes_lsp_bin_dir() / bin_name
    -        if not link.exists():
    -            try:
    -                link.symlink_to(bin_path)
    -            except (OSError, NotImplementedError):
    -                try:
    -                    shutil.copy2(bin_path, link)
    -                except OSError:
    -                    return str(bin_path)
    -        return str(link if link.exists() else bin_path)
    +    # Look for the console script.  POSIX wheels generally write to bin/,
    +    # while native Windows installs use Scripts/.
    +    script_dirs = [pip_target / "bin"]
    +    if _is_windows():
    +        script_dirs.append(pip_target / "Scripts")
    +    for script_dir in script_dirs:
    +        for bin_path in _native_binary_candidates(script_dir / bin_name):
    +            if bin_path.exists():
    +                link = hermes_lsp_bin_dir() / bin_path.name
    +                if not link.exists():
    +                    try:
    +                        link.symlink_to(bin_path)
    +                    except (OSError, NotImplementedError):
    +                        try:
    +                            shutil.copy2(bin_path, link)
    +                        except OSError:
    +                            return str(bin_path)
    +                return str(link if link.exists() else bin_path)
         return None
     
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -1205,6 +1205,7 @@
         "86501179+1RB@users.noreply.github.com": "1RB",  # PR #25462 salvage (discord forwarded messages)
         "44045943+ayushere@users.noreply.github.com": "ayushere",  # PR #25342 salvage (memory teardown leak)
         "15791290+domtriola@users.noreply.github.com": "domtriola",  # PR #25424 salvage (docs tirith link)
    +    "[redacted:email]": "tuancookiez-hub",  # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864)
         "284216128+ephron-ren@users.noreply.github.com": "ephron-ren",  # PR #25358 salvage (MiMo reasoning echo-back)
         "96843562+freqyfreqy@users.noreply.github.com": "freqyfreqy",  # PR #25423 salvage (docs LSP worktree -> repo)
         "54306477+fu576@users.noreply.github.com": "fu576",  # PR #25369 salvage (api_mode not inherited cross-provider)
    ```
  - `tests/agent/lsp/test_install_and_lint_fixes.py`

    ```diff
    @@ -94,6 +94,47 @@ def fake_run(cmd, **kwargs):
         assert install_targets == ["pyright"]
     
     
    +def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):
    +    """Installed Windows shims should satisfy later status/probe calls."""
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +
    +    from agent.lsp import install as install_mod
    +
    +    wrapper = install_mod.hermes_lsp_bin_dir() / "pyright-langserver.cmd"
    +    wrapper.write_text("@echo off\n")
    +    wrapper.chmod(0o755)
    +
    +    monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
    +    monkeypatch.setattr(install_mod.shutil, "which", lambda _name: None)
    +
    +    assert install_mod._existing_binary("pyright-langserver") == str(wrapper)
    +    assert install_mod.detect_status("pyright") == "installed"
    +
    +
    +def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):
    +    """pip console scripts can land in Scripts/ on native Windows."""
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +
    +    from agent.lsp import install as install_mod
    +
    +    def fake_run(cmd, **kwargs):
    +        scripts_dir = install_mod.hermes_lsp_bin_dir().parent / "python-packages" / "Scripts"
    +        scripts_dir.mkdir(parents=True, exist_ok=True)
    +        launcher = scripts_dir / "fake-language-server.exe"
    +        launcher.write_text("launcher\n")
    +        launcher.chmod(0o755)
    +        return MagicMock(returncode=0, stderr="")
    +
    +    monkeypatch.setattr(install_mod, "_is_windows", lambda: True)
    +    monkeypatch.setattr(install_mod.subprocess, "run", fake_run)
    +
    +    resolved = install_mod._install_pip("fake-lsp", "fake-language-server")
    +
    +    assert resolved is not None
    +    assert resolved.endswith("fake-language-server.exe")
    +    assert (install_mod.hermes_lsp_bin_dir() / "fake-language-server.exe").exists()
    +
    +
     # ---------------------------------------------------------------------------
     # Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash
     # ---------------------------------------------------------------------------
    ```
- **#34865: fix(lsp): handle Windows .cmd shims in LSP process spawn**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `296fcdfa5`, `#35250`, `#34864`

## Fix status

- Merged to main in PR #35250 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-30`.
