Skip to content
Merged
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
9 changes: 9 additions & 0 deletions agent/lsp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
from urllib.parse import quote, unquote

from hermes_cli._subprocess_compat import windows_hide_flags

from agent.lsp.protocol import (
ERROR_CONTENT_MODIFIED,
ERROR_METHOD_NOT_FOUND,
Expand Down Expand Up @@ -294,6 +296,12 @@ async def _spawn(self) -> None:
cmd = self._command
if sys.platform == "win32":
cmd = self._win_wrap_cmd(cmd)
# Suppress the cmd.exe console window that would otherwise flash
# every time we launch a ``.cmd``-wrapped language server
# (e.g. pyright-langserver.CMD) from a console-less host such as
# a VS Code/Zed extension running the ACP adapter.
# windows_hide_flags() is CREATE_NO_WINDOW on Windows, 0 on POSIX.
creationflags = windows_hide_flags()

try:
# start_new_session=True detaches the LSP server into its own
Expand All @@ -312,6 +320,7 @@ async def _spawn(self) -> None:
env=env,
cwd=self._cwd,
start_new_session=True,
creationflags=creationflags,
)
except FileNotFoundError as e:
raise LSPProtocolError(
Expand Down
4 changes: 4 additions & 0 deletions agent/lsp/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
from pathlib import Path
from typing import Any, Dict, Optional

from hermes_cli._subprocess_compat import windows_hide_flags

logger = logging.getLogger("agent.lsp.install")

# Package-name → install-strategy hint registry. Each entry is a
Expand Down Expand Up @@ -268,6 +270,7 @@ def _install_npm(
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning(
Expand Down Expand Up @@ -317,6 +320,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hellofrommorgan
104 changes: 104 additions & 0 deletions tests/test_windows_subprocess_no_window_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -837,3 +837,107 @@ def fake_popen(cmd, **kwargs):
# Hide-only: the app-server wire still needs its pipes.
assert kwargs["stdin"] == subprocess.PIPE
assert kwargs["stdout"] == subprocess.PIPE


# ── #47971 LSP spawn + installer paths (salvage) ────────────────────────────
#
# The LSP language-server spawn (agent/lsp/client.py::_spawn) and the
# npm/go LSP auto-installers (agent/lsp/install.py) are reachable from
# console-less parents — a VS Code/Zed extension host running the ACP
# adapter — where a .cmd-wrapped server (pyright-langserver.CMD via
# cmd.exe /c) or an npm/go console app flashes a window on Windows.
# All are hide-only (creationflags); PIPE stdio must stay intact and the
# POSIX start_new_session detach must be preserved on the client spawn.


def test_lsp_client_spawn_hides_console_window(monkeypatch):
import asyncio

from agent.lsp import client as lsp_client

captured = []

class _FakeProc:
stdin = None
stdout = None
stderr = None

async def fake_exec(*cmd, **kwargs):
captured.append((list(cmd), kwargs))
return _FakeProc()

monkeypatch.setattr(lsp_client, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
monkeypatch.setattr(
lsp_client.asyncio, "create_subprocess_exec", fake_exec
)

client = lsp_client.LSPClient(
server_id="test-server",
workspace_root="/tmp/ws",
command=["fake-langserver", "--stdio"],
)
asyncio.run(client._spawn())

assert len(captured) == 1, captured
cmd, kwargs = captured[0]
assert cmd == ["fake-langserver", "--stdio"]
assert kwargs["creationflags"] == _CREATE_NO_WINDOW
# Hide-only: the LSP wire still needs its pipes, and the POSIX
# process-group detach (mcp orphan-sweep guard) must survive.
assert kwargs["stdin"] == asyncio.subprocess.PIPE
assert kwargs["stdout"] == asyncio.subprocess.PIPE
assert kwargs["start_new_session"] is True


def test_lsp_install_npm_hides_console_window(monkeypatch, tmp_path):
from agent.lsp import install as lsp_install

captured = []

def fake_run(cmd, **kwargs):
captured.append((cmd, kwargs))
return _Completed(stdout="")

monkeypatch.setattr(lsp_install, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
monkeypatch.setattr(lsp_install.subprocess, "run", fake_run)
monkeypatch.setattr(lsp_install.shutil, "which", lambda name: f"/fake/bin/{name}")
monkeypatch.setattr(
lsp_install, "hermes_lsp_bin_dir", lambda: tmp_path / "lsp" / "bin"
)

# Bin lookup after the install misses (nothing staged) → None; the
# spawn contract is what is under test here.
lsp_install._install_npm("pyright", "pyright-langserver")

spawns = _spawns(captured, "/fake/bin/npm", "install", "pyright")
assert len(spawns) == 1, captured
cmd, kwargs = spawns[0]
assert kwargs["creationflags"] == _CREATE_NO_WINDOW
assert kwargs["stdin"] == subprocess.DEVNULL
assert kwargs["capture_output"] is True


def test_lsp_install_go_hides_console_window(monkeypatch, tmp_path):
from agent.lsp import install as lsp_install

captured = []

def fake_run(cmd, **kwargs):
captured.append((cmd, kwargs))
return _Completed(stdout="")

monkeypatch.setattr(lsp_install, "windows_hide_flags", lambda: _CREATE_NO_WINDOW)
monkeypatch.setattr(lsp_install.subprocess, "run", fake_run)
monkeypatch.setattr(lsp_install.shutil, "which", lambda name: f"/fake/bin/{name}")
monkeypatch.setattr(
lsp_install, "hermes_lsp_bin_dir", lambda: tmp_path / "lsp" / "bin"
)

lsp_install._install_go("golang.org/x/tools/gopls@latest", "gopls")

spawns = _spawns(captured, "/fake/bin/go", "install")
assert len(spawns) == 1, captured
cmd, kwargs = spawns[0]
assert kwargs["creationflags"] == _CREATE_NO_WINDOW
assert kwargs["stdin"] == subprocess.DEVNULL
assert kwargs["capture_output"] is True
Loading