NousResearch/hermes-agent

Windows terminal hangs when launching GUI processes

Cluster 54201 · environments. Data as of

Nothing open on GitHub. Fix merged in #55547 on 30 Jun 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 #55547. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

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.

  • #55547 fix(desktop): tree-kill Windows terminal descendants

    merged to main

    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

    Show the diff
    • apps/desktop/electron/main.cjs
      @@ -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()
      @@ -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) {
      @@ -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)
       }
      @@ -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
      @@ -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
      @@ -20,6 +20,7 @@
       
       import pytest
       
      +from tools.environments import local as local_mod
       from tools.environments.local import LocalEnvironment
       
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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)

1 PR closed without merging

From the close comments: done elsewhere 1

  • #54235 fix: kill entire process tree on Windows to prevent GUI program hang (#54201) · done elsewhere → e5253d8, #55547
Closed issues · 1
  • Closed#54201[Windows] terminal tool hangs when launching GUI programs via .exe or start command — session becomes unresponsive
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

Subsystem
environments
Merged PRs
#55547
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
30 Jun 2026

For your agent

The same facts, typed.