{"repo":"NousResearch/hermes-agent","public_id":"34864","display_label":"Windows process spawn failures for npm/pip CLI tools","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35250,"merged_at":"2026-05-30T09:08:37+00:00"},"resolution":{"state":"resolved","by":"pr-35250"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T09:08:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34864,"pr_number":35250,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T09:08:37+00:00"}],"members":[{"kind":"issue","number":34864,"title":"LSP spawn fails on Windows with WinError 193 for npm-installed language servers","url":null,"state":"closed"},{"kind":"pr","number":34865,"title":"fix(lsp): handle Windows .cmd shims in LSP process spawn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"296fcdfa5","number":0},{"kind":"pr","value":"#35250","number":35250},{"kind":"pr","value":"#34864","number":34864}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35250,"title":"fix(lsp): spawn + discover npm/pip language servers on native Windows","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/lsp/cli.py","hunks":["@@ -247,18 +247,13 @@ def _cmd_restart() -> int:\n \n \n def _cmd_which(server_id: str) -> int:\n-    from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir\n-    import shutil as _shutil\n+    from agent.lsp.install import INSTALL_RECIPES, _existing_binary\n \n     recipe = INSTALL_RECIPES.get(server_id)\n     bin_name = (recipe or {}).get(\"bin\", server_id)\n-    staged = hermes_lsp_bin_dir() / bin_name\n-    if staged.exists():\n-        sys.stdout.write(str(staged) + \"\\n\")\n-        return 0\n-    on_path = _shutil.which(bin_name)\n-    if on_path:\n-        sys.stdout.write(on_path + \"\\n\")\n+    resolved = _existing_binary(bin_name)\n+    if resolved:\n+        sys.stdout.write(resolved + \"\\n\")\n         return 0\n     sys.stderr.write(f\"{server_id}: not installed\\n\")\n     return 1","@@ -292,11 +287,9 @@ def _backend_warnings() -> list:\n     suggestion across common platforms.\n     \"\"\"\n     import shutil as _shutil\n-    from agent.lsp.install import hermes_lsp_bin_dir\n+    from agent.lsp.install import _existing_binary\n     notes: list = []\n-    bash_installed = _shutil.which(\"bash-language-server\") is not None or (\n-        (hermes_lsp_bin_dir() / \"bash-language-server\").exists()\n-    )\n+    bash_installed = _existing_binary(\"bash-language-server\") is not None\n     if bash_installed and _shutil.which(\"shellcheck\") is None:\n         notes.append(\n             \"bash-language-server is installed but shellcheck is missing — \""]},{"file":"agent/lsp/client.py","hunks":["@@ -44,6 +44,7 @@\n import asyncio\n import logging\n import os\n+import sys\n from pathlib import Path\n from typing import Any, Awaitable, Callable, Dict, List, Optional, Set\n from urllib.parse import quote, unquote","@@ -244,15 +245,27 @@ async def start(self) -> None:\n             await self._cleanup_process()\n             raise\n \n+    @staticmethod\n+    def _win_wrap_cmd(cmd: List[str]) -> List[str]:\n+        \"\"\"On Windows, wrap .cmd/.bat shims so CreateProcess can run them.\"\"\"\n+        exe = cmd[0]\n+        if exe.lower().endswith((\".cmd\", \".bat\")):\n+            return [\"cmd.exe\", \"/c\", *cmd]\n+        return cmd\n+\n     async def _spawn(self) -> None:\n         env = dict(os.environ)\n         if self._env:\n             env.update(self._env)\n \n+        cmd = self._command\n+        if sys.platform == \"win32\":\n+            cmd = self._win_wrap_cmd(cmd)\n+\n         try:\n             self._proc = await asyncio.create_subprocess_exec(\n-                self._command[0],\n-                *self._command[1:],\n+                cmd[0],\n+                *cmd[1:],\n                 stdin=asyncio.subprocess.PIPE,\n                 stdout=asyncio.subprocess.PIPE,\n                 stderr=asyncio.subprocess.PIPE,","@@ -261,7 +274,7 @@ async def _spawn(self) -> None:\n             )\n         except FileNotFoundError as e:\n             raise LSPProtocolError(\n-                f\"LSP server binary not found: {self._command[0]} ({e})\"\n+                f\"LSP server binary not found: {cmd[0]} ({e})\"\n             ) from e\n \n         # Drain stderr at debug level — if we don't, the pipe buffer"]},{"file":"agent/lsp/install.py","hunks":["@@ -108,6 +108,11 @@\n _install_locks: Dict[str, threading.Lock] = {}\n _install_results: Dict[str, Optional[str]] = {}\n _install_lock_meta = threading.Lock()\n+_WINDOWS_WRAPPER_SUFFIXES = (\".cmd\", \".exe\", \".bat\")\n+\n+\n+def _is_windows() -> bool:\n+    return os.name == \"nt\"\n \n \n def hermes_lsp_bin_dir() -> Path:","@@ -120,14 +125,33 @@ def hermes_lsp_bin_dir() -> Path:\n     return p\n \n \n+def _native_binary_candidates(base: Path) -> list[Path]:\n+    \"\"\"Return platform-native executable candidates for a staged binary.\"\"\"\n+    candidates = [base]\n+    if _is_windows():\n+        existing = {str(base).lower()}\n+        for suffix in _WINDOWS_WRAPPER_SUFFIXES:\n+            candidate = Path(str(base) + suffix)\n+            key = str(candidate).lower()\n+            if key not in existing:\n+                candidates.append(candidate)\n+                existing.add(key)\n+    return candidates\n+\n+\n def _existing_binary(name: str) -> Optional[str]:\n     \"\"\"Probe the staging dir + PATH for a binary named ``name``.\"\"\"\n-    staged = hermes_lsp_bin_dir() / name\n-    if staged.exists() and os.access(staged, os.X_OK):\n-        return str(staged)\n+    for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):\n+        if staged.exists() and os.access(staged, os.X_OK):\n+            return str(staged)\n     on_path = shutil.which(name)\n     if on_path:\n         return on_path\n+    if _is_windows():\n+        for suffix in _WINDOWS_WRAPPER_SUFFIXES:\n+            on_path = shutil.which(f\"{name}{suffix}\")\n+            if on_path:\n+                return on_path\n     return None\n \n ","@@ -250,12 +274,7 @@ def _install_npm(\n \n     # Find the bin\n     nm_bin = staging / \"node_modules\" / \".bin\" / bin_name\n-    if os.name == \"nt\":\n-        # On Windows npm sometimes drops `.cmd` shims\n-        candidates = [nm_bin, nm_bin.with_suffix(\".cmd\")]\n-    else:\n-        candidates = [nm_bin]\n-    for c in candidates:\n+    for c in _native_binary_candidates(nm_bin):\n         if c.exists():\n             # Symlink into our `lsp/bin/` for stable PATH access.\n             link = hermes_lsp_bin_dir() / c.name","@@ -301,7 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:\n         logger.warning(\"[install] go install errored for %s: %s\", pkg, e)\n         return None\n     bin_path = staging / bin_name\n-    if os.name == \"nt\":\n+    if _is_windows():\n         bin_path = bin_path.with_suffix(\".exe\")\n     if bin_path.exists():\n         return str(bin_path)","@@ -337,19 +356,24 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:\n     except (subprocess.TimeoutExpired, OSError) as e:\n         logger.warning(\"[install] pip install errored for %s: %s\", pkg, e)\n         return None\n-    # Look for the script\n-    bin_path = pip_target / \"bin\" / bin_name\n-    if bin_path.exists():\n-        link = hermes_lsp_bin_dir() / bin_name\n-        if not link.exists():\n-            try:\n-                link.symlink_to(bin_path)\n-            except (OSError, NotImplementedError):\n-                try:\n-                    shutil.copy2(bin_path, link)\n-                except OSError:\n-                    return str(bin_path)\n-        return str(link if link.exists() else bin_path)\n+    # Look for the console script.  POSIX wheels generally write to bin/,\n+    # while native Windows installs use Scripts/.\n+    script_dirs = [pip_target / \"bin\"]\n+    if _is_windows():\n+        script_dirs.append(pip_target / \"Scripts\")\n+    for script_dir in script_dirs:\n+        for bin_path in _native_binary_candidates(script_dir / bin_name):\n+            if bin_path.exists():\n+                link = hermes_lsp_bin_dir() / bin_path.name\n+                if not link.exists():\n+                    try:\n+                        link.symlink_to(bin_path)\n+                    except (OSError, NotImplementedError):\n+                        try:\n+                            shutil.copy2(bin_path, link)\n+                        except OSError:\n+                            return str(bin_path)\n+                return str(link if link.exists() else bin_path)\n     return None\n \n "]},{"file":"scripts/release.py","hunks":["@@ -1205,6 +1205,7 @@\n     \"86501179+1RB@users.noreply.github.com\": \"1RB\",  # PR #25462 salvage (discord forwarded messages)\n     \"44045943+ayushere@users.noreply.github.com\": \"ayushere\",  # PR #25342 salvage (memory teardown leak)\n     \"15791290+domtriola@users.noreply.github.com\": \"domtriola\",  # PR #25424 salvage (docs tirith link)\n+    \"[redacted:email]\": \"tuancookiez-hub\",  # PR #34865 salvage (LSP Windows .cmd shim spawn, #34864)\n     \"284216128+ephron-ren@users.noreply.github.com\": \"ephron-ren\",  # PR #25358 salvage (MiMo reasoning echo-back)\n     \"96843562+freqyfreqy@users.noreply.github.com\": \"freqyfreqy\",  # PR #25423 salvage (docs LSP worktree -> repo)\n     \"54306477+fu576@users.noreply.github.com\": \"fu576\",  # PR #25369 salvage (api_mode not inherited cross-provider)"]},{"file":"tests/agent/lsp/test_install_and_lint_fixes.py","hunks":["@@ -94,6 +94,47 @@ def fake_run(cmd, **kwargs):\n     assert install_targets == [\"pyright\"]\n \n \n+def test_existing_binary_finds_windows_wrapper_in_staging(tmp_path, monkeypatch):\n+    \"\"\"Installed Windows shims should satisfy later status/probe calls.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from agent.lsp import install as install_mod\n+\n+    wrapper = install_mod.hermes_lsp_bin_dir() / \"pyright-langserver.cmd\"\n+    wrapper.write_text(\"@echo off\\n\")\n+    wrapper.chmod(0o755)\n+\n+    monkeypatch.setattr(install_mod, \"_is_windows\", lambda: True)\n+    monkeypatch.setattr(install_mod.shutil, \"which\", lambda _name: None)\n+\n+    assert install_mod._existing_binary(\"pyright-langserver\") == str(wrapper)\n+    assert install_mod.detect_status(\"pyright\") == \"installed\"\n+\n+\n+def test_install_pip_finds_windows_scripts_launcher(tmp_path, monkeypatch):\n+    \"\"\"pip console scripts can land in Scripts/ on native Windows.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    from agent.lsp import install as install_mod\n+\n+    def fake_run(cmd, **kwargs):\n+        scripts_dir = install_mod.hermes_lsp_bin_dir().parent / \"python-packages\" / \"Scripts\"\n+        scripts_dir.mkdir(parents=True, exist_ok=True)\n+        launcher = scripts_dir / \"fake-language-server.exe\"\n+        launcher.write_text(\"launcher\\n\")\n+        launcher.chmod(0o755)\n+        return MagicMock(returncode=0, stderr=\"\")\n+\n+    monkeypatch.setattr(install_mod, \"_is_windows\", lambda: True)\n+    monkeypatch.setattr(install_mod.subprocess, \"run\", fake_run)\n+\n+    resolved = install_mod._install_pip(\"fake-lsp\", \"fake-language-server\")\n+\n+    assert resolved is not None\n+    assert resolved.endswith(\"fake-language-server.exe\")\n+    assert (install_mod.hermes_lsp_bin_dir() / \"fake-language-server.exe\").exists()\n+\n+\n # ---------------------------------------------------------------------------\n # Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash\n # ---------------------------------------------------------------------------\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}