# NousResearch/hermes-agent · Windows terminal hangs when launching GUI processes

## Open now

- Nothing open on GitHub.

These items address an issue where terminal sessions on Windows become unresponsive when GUI programs are launched, and propose or implement killing the entire process tree to prevent the hang.

## 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 #55547. Verify it addresses your case.
- **Recommended:** read PR #55547 (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: `unresolved`. 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

- #54201: \[Windows\] terminal tool hangs when launching GUI programs via .exe or start command — session becomes unresponsive

## Prior & competing PRs

- **#55547: fix(desktop): tree-kill Windows terminal descendants**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/electron/main.cjs`, `apps/desktop/electron/windows-child-process.test.cjs`, `tests/tools/test_local_interrupt_cleanup.py`, `tests/tools/test_process_registry.py`, `tools/environments/local.py`, `tools/process_registry.py`
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -5108,13 +5108,24 @@ function resetBootProgressForReconnect() {
       )
     }
     
    +function stopBackendChild(child) {
    +  if (!child || child.killed) return
    +  try {
    +    if (IS_WINDOWS && Number.isInteger(child.pid)) {
    +      forceKillProcessTree(child.pid)
    +    } else {
    +      child.kill('SIGTERM')
    +    }
    +  } catch {
    +    // Already gone.
    +  }
    +}
    +
     function resetHermesConnection() {
       connectionPromise = null
       backendStartFailure = null
     
    -  if (hermesProcess && !hermesProcess.killed) {
    -    hermesProcess.kill('SIGTERM')
    -  }
    +  stopBackendChild(hermesProcess)
     
       hermesProcess = null
       resetBootProgressForReconnect()
    ```

    ```diff
    @@ -5362,13 +5373,7 @@ function stopPoolBackend(profile) {
       const entry = backendPool.get(profile)
       if (!entry) return
       backendPool.delete(profile)
    -  if (entry.process && !entry.process.killed) {
    -    try {
    -      entry.process.kill('SIGTERM')
    -    } catch {
    -      // Already gone.
    -    }
    -  }
    +  stopBackendChild(entry.process)
     }
     
     async function teardownPoolBackendAndWait(profile) {
    ```

    ```diff
    @@ -5376,13 +5381,7 @@ async function teardownPoolBackendAndWait(profile) {
       if (!entry) return
       backendPool.delete(profile)
     
    -  if (entry.process && !entry.process.killed) {
    -    try {
    -      entry.process.kill('SIGTERM')
    -    } catch {
    -      // Already gone.
    -    }
    -  }
    +  stopBackendChild(entry.process)
     
       await waitForBackendExit(entry.process)
     }
    ```

    ```diff
    @@ -7600,9 +7599,7 @@ app.on('before-quit', () => {
         disposeTerminalSession(id)
       }
     
    -  if (hermesProcess && !hermesProcess.killed) {
    -    hermesProcess.kill('SIGTERM')
    -  }
    +  stopBackendChild(hermesProcess)
       stopAllPoolBackends()
     })
     
    ```
  - `apps/desktop/electron/windows-child-process.test.cjs`

    ```diff
    @@ -74,6 +74,29 @@ test('desktop backend launches console python so child consoles are inherited, n
       requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
     })
     
    +test('desktop backend teardown tree-kills Windows backend descendants', () => {
    +  const source = readElectronFile('main.cjs')
    +
    +  const helperIndex = source.indexOf('function stopBackendChild(child)')
    +  assert.notEqual(helperIndex, -1, 'missing backend teardown helper')
    +  const helperSnippet = source.slice(helperIndex, helperIndex + 500)
    +  assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/)
    +  assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/)
    +  assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/)
    +
    +  const resetIndex = source.indexOf('function resetHermesConnection()')
    +  assert.notEqual(resetIndex, -1, 'missing resetHermesConnection')
    +  const resetSnippet = source.slice(resetIndex, resetIndex + 300)
    +  assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/)
    +  assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
    +
    +  const quitIndex = source.indexOf("app.on('before-quit'")
    +  assert.notEqual(quitIndex, -1, 'missing before-quit handler')
    +  const quitSnippet = source.slice(quitIndex, quitIndex + 900)
    +  assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/)
    +  assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/)
    +})
    +
     test('intentional or interactive desktop child processes stay documented', () => {
       const source = readElectronFile('main.cjs')
     
    ```
  - `tests/tools/test_local_interrupt_cleanup.py`

    ```diff
    @@ -20,6 +20,7 @@
     
     import pytest
     
    +from tools.environments import local as local_mod
     from tools.environments.local import LocalEnvironment
     
     
    ```

    ```diff
    @@ -96,6 +97,32 @@ def fake_killpg(pgid, sig):
         assert killpg_calls == [(67890, signal.SIGTERM), (67890, 0)]
     
     
    +def test_kill_process_uses_windows_tree_kill(monkeypatch):
    +    """Windows must kill the whole Bash process tree, not just the wrapper."""
    +    env = object.__new__(LocalEnvironment)
    +    terminate_calls = []
    +    waits = []
    +    killed = []
    +
    +    def fake_terminate(pid, *, force=False):
    +        terminate_calls.append((pid, force))
    +
    +    proc = SimpleNamespace(
    +        pid=12345,
    +        kill=lambda: killed.append(True),
    +        wait=lambda timeout=None: waits.append(timeout),
    +    )
    +
    +    monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
    +    monkeypatch.setattr("gateway.status.terminate_pid", fake_terminate)
    +
    +    env._kill_process(proc)
    +
    +    assert terminate_calls == [(12345, True)]
    +    assert waits == [2.0]
    +    assert killed == []
    +
    +
     def test_wait_for_process_kills_subprocess_on_keyboardinterrupt():
         """When KeyboardInterrupt arrives mid-poll, the subprocess group must be
         killed before the exception is re-raised."""
    ```
  - `tests/tools/test_process_registry.py`

    ```diff
    @@ -1103,6 +1103,26 @@ def test_kill_already_exited(self, registry):
             result = registry.kill_process(s.id)
             assert result["status"] == "already_exited"
     
    +    def test_kill_local_popen_uses_host_tree_terminator(self, registry, monkeypatch):
    +        s = _make_session(sid="proc_local", command="sleep 999")
    +        s.process = MagicMock()
    +        s.process.pid = 12345
    +        s.host_start_time = 67890
    +        registry._running[s.id] = s
    +        terminate_calls = []
    +
    +        monkeypatch.setattr(
    +            registry,
    +            "_terminate_host_pid",
    +            lambda pid, expected_start=None: terminate_calls.append((pid, expected_start)),
    +        )
    +        monkeypatch.setattr(registry, "_write_checkpoint", lambda: None)
    +
    +        result = registry.kill_process(s.id)
    +
    +        assert result["status"] == "killed"
    +        assert terminate_calls == [(12345, 67890)]
    +
         def test_kill_detached_session_uses_host_pid(self, registry):
             s = _make_session(sid="proc_detached", command="sleep 999")
             s.pid = 424242
    ```
  - `tools/environments/local.py`

    ```diff
    @@ -892,7 +892,16 @@ def _wait_for_group_exit(pgid: int, timeout: float) -> bool:
     
             try:
                 if _IS_WINDOWS:
    -                proc.terminate()
    +                try:
    +                    from gateway.status import terminate_pid
    +
    +                    terminate_pid(proc.pid, force=True)
    +                except Exception:
    +                    proc.kill()
    +                try:
    +                    proc.wait(timeout=2.0)
    +                except (subprocess.TimeoutExpired, OSError):
    +                    pass
                 else:
                     try:
                         pgid = os.getpgid(proc.pid)
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -1433,24 +1433,10 @@ def kill_process(self, session_id: str, *, source: str = "process.kill") -> dict
                         if session.pid:
                             os.kill(session.pid, signal.SIGTERM)
                 elif session.process:
    -                # Local process -- kill the process tree
    -                try:
    -                    if _IS_WINDOWS:
    -                        session.process.terminate()
    -                    else:
    -                        import psutil
    -                        try:
    -                            parent = psutil.Process(session.process.pid)
    -                            for child in parent.children(recursive=True):
    -                                try:
    -                                    child.terminate()
    -                                except psutil.NoSuchProcess:
    -                                    pass
    -                            parent.terminate()
    -                        except psutil.NoSuchProcess:
    -                            pass
    -                except (ProcessLookupError, PermissionError):
    -                    session.process.kill()
    +                # Local process -- kill the process tree. On Windows this
    +                # must be taskkill /T /F; Popen.terminate() only kills the
    +                # shell wrapper and leaves Git Bash descendants behind.
    +                self._terminate_host_pid(session.process.pid, session.host_start_time)
                 elif session.env_ref and session.pid:
                     # Non-local -- kill inside sandbox
                     session.env_ref.execute(f"kill {session.pid} 2>/dev/null", timeout=5)
    ```
- **#54235: fix: kill entire process tree on Windows to prevent GUI program hang (#54201)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `e5253d852b24f87af91d77b66141cc5c0b9e6f69`, `#55547`

## Fix status

- Merged to main in PR #55547 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-30`.
- Subsystem: `environments`
