# NousResearch/hermes-agent · Gateway process detection ps command compatibility issues

## Open now

- 1 issue, 1 PR open on GitHub.
- #9069: \[Bug\]: hermes cron list: Gateway process detection fails on FreeBSD due to \`ps eww\` output format
- PR #9396: Fix FreeBSD gateway process detection

Multiple issues and fixes addressing incompatibilities with the \`ps\` command used for gateway process detection, particularly affecting Docker containers and FreeBSD where BSD-style flags like \`-ax\` and \`eww\` are not available or behave differently. Proposed solutions include switching to POSIX-compliant flags and/or installing the procps package.

## TL;DR

- 11 member(s): 1 open issue(s), 2 closed issue(s), 1 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #22860. Verify it addresses your case.
- **Recommended:** read PR #22860 (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: `partially_resolved` · deciding PR(s): `pr-10569`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.16`: 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.

## Open related issues

- #9069: \[Bug\]: hermes cron list: Gateway process detection fails on FreeBSD due to \`ps eww\` output format

## Closed / duplicate issues

- #9723: \[Bug\]: \`ps eww -ax\` fails in Docker  — gateway falsely reported as not running
- #10761: Gateway status detection fails in Docker container (ps eww -ax incompatibility)

## Prior & competing PRs

- **#9100: fix: use ps without environment for gateway scan**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#9396`, `#9069`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#10569: fix: use POSIX ps -A instead of BSD -ax for Docker compat**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/gateway.py`
  - fix hunks:
  - `hermes_cli/gateway.py`

    ```diff
    @@ -222,7 +222,7 @@ def _matches_current_profile(command: str) -> bool:
                         current_cmd = ""
             else:
                 result = subprocess.run(
    -                ["ps", "eww", "-ax", "-o", "pid=,command="],
    +                ["ps", "-A", "eww", "-o", "pid=,command="],
                     capture_output=True,
                     text=True,
                     timeout=10,
    ```
- **#10968: fix(gateway): use ps ww for gateway pid detection**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#22860: fix(gateway): detect gateway process via /proc in Docker without procps (salvage #22693)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/gateway.py`, `tests/hermes_cli/test_gateway.py`, `tests/hermes_cli/test_gateway_proc_fallback.py`
  - fix hunks:
  - `hermes_cli/gateway.py`

    ```diff
    @@ -394,42 +394,68 @@ def _matches_current_profile(command: str) -> bool:
                                 pass
                         current_cmd = ""
             else:
    -            result = subprocess.run(
    -                ["ps", "-A", "eww", "-o", "pid=,command="],
    -                capture_output=True,
    -                text=True,
    -                timeout=10,
    -            )
    -            if result.returncode != 0:
    -                return []
    -            for line in result.stdout.split("\n"):
    -                stripped = line.strip()
    -                if not stripped or "grep" in stripped:
    -                    continue
    +            # Try /proc first (works in Docker without procps installed),
    +            # fall back to ps -A eww.
    +            _found_via_proc = False
    +            if os.path.isdir("/proc"):
    +                try:
    +                    my_pid = os.getpid()
    +                    for entry in os.listdir("/proc"):
    +                        if not entry.isdigit():
    +                            continue
    +                        pid = int(entry)
    +                        if pid == my_pid or pid in exclude_pids:
    +                            continue
    +                        try:
    +                            cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace")
    +                            cmdline = cmdline.replace("\x00", " ")
    +                            if any(p in cmdline for p in patterns) and (
    +                                all_profiles or _matches_current_profile(cmdline)
    +                            ):
    +                                _append_unique_pid(pids, pid, exclude_pids)
    +                        except (OSError, PermissionError):
    +                            continue
    +                    _found_via_proc = True
    +                except Exception:
    +                    pass
     
    -                pid = None
    -                command = ""
    +            if not _found_via_proc:
    +                result = subprocess.run(
    +                    ["ps", "-A", "eww", "-o", "pid=,command="],
    +                    capture_output=True,
    +                    text=True,
    +                    timeout=10,
    +                )
    +                if result.returncode != 0:
    +                    return []
    +                for line in result.stdout.split("\n"):
    +                    stripped = line.strip()
    +                    if not stripped or "grep" in stripped:
    +                        continue
     
    -                parts = stripped.split(None, 1)
    -                if len(parts) == 2:
    -                    try:
    -                        pid = int(parts[0])
    -                        command = parts[1]
    -                    except ValueError:
    -                        pid = None
    -
    -                if pid is None:
    -                    aux_parts = stripped.split()
    -                    if len(aux_parts) > 10 and aux_parts[1].isdigit():
    -                        pid = int(aux_parts[1])
    -                        command = " ".join(aux_parts[10:])
    -
    -                if pid is None:
    -                    continue
    -                if any(pattern in command for pattern in patterns) and (
    -                    all_profiles or _matches_current_profile(command)
    -                ):
    -                    _append_unique_pid(pids, pid, exclude_pids)
    +                    pid = None
    +                    command = ""
    +
    +                    parts = stripped.split(None, 1)
    +                    if len(parts) == 2:
    +                        try:
    +                            pid = int(parts[0])
    +                            command = parts[1]
    +                        except ValueError:
    +                            pid = None
    +
    +                    if pid is None:
    +                        aux_parts = stripped.split()
    +                        if len(aux_parts) > 10 and aux_parts[1].isdigit():
    +                            pid = int(aux_parts[1])
    +                            command = " ".join(aux_parts[10:])
    +
    +                    if pid is None:
    +                        continue
    +                    if any(pattern in command for pattern in patterns) and (
    +                        all_profiles or _matches_current_profile(command)
    +                    ):
    +                        _append_unique_pid(pids, pid, exclude_pids)
         except (OSError, subprocess.TimeoutExpired):
             return []
     
    ```
  - `tests/hermes_cli/test_gateway.py`

    ```diff
    @@ -404,6 +404,15 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey
         monkeypatch.setattr(gateway, "is_windows", lambda: False)
         monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321)
     
    +    # /proc walk is the first path tried (#22693). Force os.listdir on /proc
    +    # to raise so the function falls back to ps, where fake_run takes over.
    +    _real_listdir = gateway.os.listdir
    +    def _no_proc_listdir(path):
    +        if path == "/proc":
    +            raise OSError("test stub: /proc unavailable")
    +        return _real_listdir(path)
    +    monkeypatch.setattr(gateway.os, "listdir", _no_proc_listdir)
    +
         def fake_run(cmd, **kwargs):
             if cmd[:4] == ["ps", "-A", "eww", "-o"]:
                 return SimpleNamespace(returncode=1, stdout="", stderr="ps failed")
    ```
  - `tests/hermes_cli/test_gateway_proc_fallback.py`

    ```diff
    @@ -0,0 +1,138 @@
    +"""Tests for /proc-based gateway PID detection in Docker environments.
    +
    +Verifies that _scan_gateway_pids() uses /proc/*/cmdline when available
    +(Docker without procps) and falls back to ps only when /proc is absent.
    +
    +See: NousResearch/hermes-agent#7622
    +"""
    +
    +import os
    +from unittest.mock import MagicMock, patch
    +
    +import hermes_cli.gateway as gateway_mod
    +
    +
    +# ---------------------------------------------------------------------------
    +# Helpers
    +# ---------------------------------------------------------------------------
    +
    +_GATEWAY_CMD = "python -m hermes_cli.main gateway run"
    +_OTHER_CMD = "python -m some_other_thing"
    +
    +
    +def _fake_proc_dir(entries: dict):
    +    """Return side_effects that simulate /proc: isdir → True, listdir → pids,
    +    open(cmdline) → null-delimited command bytes."""
    +    def _isdir(path):
    +        return str(path) == "/proc"
    +
    +    def _listdir(path):
    +        if str(path) == "/proc":
    +            return [str(pid) for pid in entries] + ["self", "version"]
    +        raise FileNotFoundError(path)
    +
    +    def _open(path, mode="r", **kwargs):
    +        path_str = str(path)
    +        if "/cmdline" in path_str:
    +            pid = int(path_str.split("/proc/")[1].split("/")[0])
    +            raw = entries.get(pid, "").encode("utf-8").replace(b" ", b"\x00")
    +            m = MagicMock()
    +            m.read.return_value = raw
    +            m.__enter__ = lambda s: s
    +            m.__exit__ = MagicMock(return_value=False)
    +            return m
    +        raise FileNotFoundError(path)
    +
    +    return _isdir, _listdir, _open
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestProcFallback:
    +    """_scan_gateway_pids reads /proc when available, skips ps."""
    +
    +    def test_detects_gateway_pid_via_proc(self):
    +        my_pid = os.getpid()
    +        entries = {
    +            my_pid: "python -m hermes_cli.main",   # own process — excluded
    +            12345: _GATEWAY_CMD,
    +            99999: _OTHER_CMD,
    +        }
    +        _isdir, _listdir, _open = _fake_proc_dir(entries)
    +
    +        with (
    +            patch("hermes_cli.gateway.is_windows", return_value=False),
    +            patch("os.path.isdir", side_effect=_isdir),
    +            patch("os.listdir", side_effect=_listdir),
    +            patch("builtins.open", side_effect=_open),
    +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
    +            patch("subprocess.run") as mock_ps,
    +        ):
    +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
    +
    +        assert 12345 in pids
    +        assert 99999 not in pids
    +        mock_ps.assert_not_called()  # ps must NOT be called when /proc worked
    +
    +    def test_excludes_own_pid_from_proc_scan(self):
    +        my_pid = os.getpid()
    +        entries = {my_pid: _GATEWAY_CMD}
    +        _isdir, _listdir, _open = _fake_proc_dir(entries)
    +
    +        with (
    +            patch("hermes_cli.gateway.is_windows", return_value=False),
    +            patch("os.path.isdir", side_effect=_isdir),
    +            patch("os.listdir", side_effect=_listdir),
    +            patch("builtins.open", side_effect=_open),
    +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
    +            patch("subprocess.run"),
    +        ):
    +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
    +
    +        assert my_pid not in pids
    +
    +    def test_falls_back_to_ps_when_proc_absent(self):
    +        ps_output = f"12345 {_GATEWAY_CMD}\n99999 {_OTHER_CMD}\n"
    +        mock_result = MagicMock()
    +        mock_result.returncode = 0
    +        mock_result.stdout = ps_output
    +
    +        with (
    +            patch("hermes_cli.gateway.is_windows", return_value=False),
    +            patch("os.path.isdir", return_value=False),
    +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
    +            patch("subprocess.run", return_value=mock_result) as mock_ps,
    +        ):
    +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
    +
    +        mock_ps.assert_called_once()
    +        assert 12345 in pids
    +
    +    def test_proc_permission_error_skips_pid(self):
    +        def _isdir(path):
    +            return str(path) == "/proc"
    +
    +        def _listdir(path):
    +            if str(path) == "/proc":
    +                return ["12345", "self"]
    +            raise FileNotFoundError
    +
    +        def _open(path, mode="r", **kwargs):
    +            raise PermissionError("no access")
    +
    +        with (
    +            patch("hermes_cli.gateway.is_windows", return_value=False),
    +            patch("os.path.isdir", side_effect=_isdir),
    +            patch("os.listdir", side_effect=_listdir),
    +            patch("builtins.open", side_effect=_open),
    +            patch("hermes_cli.gateway._get_ancestor_pids", return_value=set()),
    +            patch("subprocess.run") as mock_ps,
    +        ):
    +            pids = gateway_mod._scan_gateway_pids(set(), all_profiles=True)
    +
    +        # PermissionError swallowed — empty result, no crash
    +        assert 12345 not in pids
    +        mock_ps.assert_not_called()  # /proc dir existed, so ps not called
    ```
- **#9396: Fix FreeBSD gateway process detection**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/gateway.py`, `tests/hermes_cli/test_update_gateway_restart.py`
- **#9762: fix: use POSIX \`-e\` instead of BSD \`-ax\` in ps for Docker compatibility**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#9723`, `#10569`
- **#10186: fix(cli): replace ps -ax with POSIX -e for Docker compatibility**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#10815`
- **#10806: Fix gateway status detection inside Docker containers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#22860`

## Fix status

- Merged to main in PR #22860 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-10`.
- Subsystem: `hermes_cli`
