NousResearch/hermes-agent

Windows process spawn failures for npm/pip CLI tools

Cluster 34864. Data as of

Nothing open on GitHub. Fix merged in #35250 on 30 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #35250. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #35250. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35250 fix(lsp): spawn + discover npm/pip language servers on native Windows

    merged to main

    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

    Show the diff
    • agent/lsp/cli.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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
      @@ -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:
      @@ -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
       
       
      @@ -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
      @@ -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)
      @@ -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
      @@ -1205,6 +1205,7 @@
           "[email protected]": "1RB",  # PR #25462 salvage (discord forwarded messages)
           "[email protected]": "ayushere",  # PR #25342 salvage (memory teardown leak)
           "[email protected]": "domtriola",  # PR #25424 salvage (docs tirith link)
      +    "[redacted:email]": "tuancookiez-hub",  # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864)
           "[email protected]": "ephron-ren",  # PR #25358 salvage (MiMo reasoning echo-back)
           "[email protected]": "freqyfreqy",  # PR #25423 salvage (docs LSP worktree -> repo)
           "[email protected]": "fu576",  # PR #25369 salvage (api_mode not inherited cross-provider)
    • tests/agent/lsp/test_install_and_lint_fixes.py
      @@ -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
       # ---------------------------------------------------------------------------

1 PR closed without merging

From the close comments: merged via another change 1

  • #34865 fix(lsp): handle Windows .cmd shims in LSP process spawn · merged via another change → 296fcdf, #35250, #34864
Closed issues · 1
  • Closed#34864LSP spawn fails on Windows with WinError 193 for npm-installed language servers
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#35250
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
30 May 2026

For your agent

The same facts, typed.