{"repo":"NousResearch/hermes-agent","public_id":"592","display_label":"Gateway background process watcher notification modes","summary":"These members introduce configurable notification modes for the gateway's background process watcher. The work includes adding notification mode options and addressing related spinner output behavior.","subsystem":"gateway","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":840,"merged_at":"2026-03-10T13:17:24+00:00"},"resolution":{"state":"resolved","by":"pr-840"},"freshness":{"state":"stale","last_verified_at":"2026-03-10T13:17:24+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":592,"pr_number":840,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-10T13:17:24+00:00"}],"members":[{"kind":"issue","number":592,"title":"[Feature]: configurable gateway background process watcher notifications","url":null,"state":"closed"},{"kind":"pr","number":593,"title":"feat(gateway): add watcher notification modes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#840","number":840}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":840,"title":"feat(gateway): background process notification modes + fix spinner line spam","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["AGENTS.md","agent/display.py","cli-config.yaml.example","gateway/run.py","tests/gateway/test_background_process_notifications.py"],"fix_hunks":[{"file":"AGENTS.md","hunks":["@@ -300,6 +300,17 @@ Cache-breaking forces dramatically higher costs. The ONLY time we alter context\n - **CLI**: Uses current directory (`.` → `os.getcwd()`)\n - **Messaging**: Uses `MESSAGING_CWD` env var (default: home directory)\n \n+### Background Process Notifications (Gateway)\n+\n+When `terminal(background=true, check_interval=...)` is used, the gateway runs a watcher that\n+pushes status updates to the user's chat. Control verbosity with `display.background_process_notifications`\n+in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):\n+\n+- `all` — running-output updates + final message (default)\n+- `result` — only the final completion message\n+- `error` — only the final message when exit code != 0\n+- `off` — no watcher messages at all\n+\n ---\n \n ## Known Pitfalls"]},{"file":"agent/display.py","hunks":["@@ -206,6 +206,7 @@ def __init__(self, message: str = \"\", spinner_type: str = 'dots'):\n         self.frame_idx = 0\n         self.start_time = None\n         self.last_line_len = 0\n+        self._last_flush_time = 0.0  # Rate-limit flushes for patch_stdout compat\n         # Capture stdout NOW, before any redirect_stdout(devnull) from\n         # child agents can replace sys.stdout with a black hole.\n         self._out = sys.stdout","@@ -236,7 +237,18 @@ def _animate(self):\n             else:\n                 line = f\"  {frame} {self.message} ({elapsed:.1f}s)\"\n             pad = max(self.last_line_len - len(line), 0)\n-            self._write(f\"\\r{line}{' ' * pad}\", end='', flush=True)\n+            # Rate-limit flush() calls to avoid spinner spam under\n+            # prompt_toolkit's patch_stdout.  Each flush() pushes a queue\n+            # item that may trigger a separate run_in_terminal() call; if\n+            # items are processed one-at-a-time the \\r overwrite is lost\n+            # and every frame appears on its own line.  By flushing at\n+            # most every 0.4s we guarantee multiple \\r-frames are batched\n+            # into a single write, so the terminal collapses them correctly.\n+            now = time.time()\n+            should_flush = (now - self._last_flush_time) >= 0.4\n+            self._write(f\"\\r{line}{' ' * pad}\", end='', flush=should_flush)\n+            if should_flush:\n+                self._last_flush_time = now\n             self.last_line_len = len(line)\n             self.frame_idx += 1\n             time.sleep(0.12)"]},{"file":"cli-config.yaml.example","hunks":["@@ -655,6 +655,15 @@ display:\n   # Toggle at runtime with /verbose in the CLI\n   tool_progress: all\n \n+  # Background process notifications (gateway/messaging only).\n+  # Controls how chatty the process watcher is when you use\n+  # terminal(background=true, check_interval=...) from Telegram/Discord/etc.\n+  #   off:     No watcher messages at all\n+  #   result:  Only the final completion message\n+  #   error:   Only the final message when exit code != 0\n+  #   all:     Running output updates + final message (default)\n+  background_process_notifications: all\n+\n   # Play terminal bell when agent finishes a response.\n   # Useful for long-running tasks — your terminal will ding when the agent is done.\n   # Works over SSH. Most terminals can be configured to flash the taskbar or play a sound."]},{"file":"gateway/run.py","hunks":["@@ -391,6 +391,41 @@ def _load_reasoning_config() -> dict | None:\n         logger.warning(\"Unknown reasoning_effort '%s', using default (medium)\", effort)\n         return None\n \n+    @staticmethod\n+    def _load_background_notifications_mode() -> str:\n+        \"\"\"Load background process notification mode from config or env var.\n+\n+        Modes:\n+          - ``all``    — push running-output updates *and* the final message (default)\n+          - ``result`` — only the final completion message (regardless of exit code)\n+          - ``error``  — only the final message when exit code is non-zero\n+          - ``off``    — no watcher messages at all\n+        \"\"\"\n+        mode = os.getenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", \"\")\n+        if not mode:\n+            try:\n+                import yaml as _y\n+                cfg_path = _hermes_home / \"config.yaml\"\n+                if cfg_path.exists():\n+                    with open(cfg_path, encoding=\"utf-8\") as _f:\n+                        cfg = _y.safe_load(_f) or {}\n+                    raw = cfg.get(\"display\", {}).get(\"background_process_notifications\")\n+                    if raw is False:\n+                        mode = \"off\"\n+                    elif raw not in (None, \"\"):\n+                        mode = str(raw)\n+            except Exception:\n+                pass\n+        mode = (mode or \"all\").strip().lower()\n+        valid = {\"all\", \"result\", \"error\", \"off\"}\n+        if mode not in valid:\n+            logger.warning(\n+                \"Unknown background_process_notifications '%s', defaulting to 'all'\",\n+                mode,\n+            )\n+            return \"all\"\n+        return mode\n+\n     @staticmethod\n     def _load_provider_routing() -> dict:\n         \"\"\"Load OpenRouter provider routing preferences from config.yaml.\"\"\"","@@ -2370,6 +2405,12 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n \n         Runs as an asyncio task. Stays silent when nothing changed.\n         Auto-removes when the process exits or is killed.\n+\n+        Notification mode (from ``display.background_process_notifications``):\n+          - ``all``    — running-output updates + final message\n+          - ``result`` — final completion message only\n+          - ``error``  — final message only when exit code != 0\n+          - ``off``    — no messages at all\n         \"\"\"\n         from tools.process_registry import process_registry\n ","@@ -2378,8 +2419,21 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n         session_key = watcher.get(\"session_key\", \"\")\n         platform_name = watcher.get(\"platform\", \"\")\n         chat_id = watcher.get(\"chat_id\", \"\")\n+        notify_mode = self._load_background_notifications_mode()\n \n-        logger.debug(\"Process watcher started: %s (every %ss)\", session_id, interval)\n+        logger.debug(\"Process watcher started: %s (every %ss, notify=%s)\",\n+                      session_id, interval, notify_mode)\n+\n+        if notify_mode == \"off\":\n+            # Still wait for the process to exit so we can log it, but don't\n+            # push any messages to the user.\n+            while True:\n+                await asyncio.sleep(interval)\n+                session = process_registry.get(session_id)\n+                if session is None or session.exited:\n+                    break\n+            logger.debug(\"Process watcher ended (silent): %s\", session_id)\n+            return\n \n         last_output_len = 0\n         while True:","@@ -2394,27 +2448,31 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n             last_output_len = current_output_len\n \n             if session.exited:\n-                # Process finished -- deliver final update\n-                new_output = session.output_buffer[-1000:] if session.output_buffer else \"\"\n-                message_text = (\n-                    f\"[Background process {session_id} finished with exit code {session.exit_code}~ \"\n-                    f\"Here's the final output:\\n{new_output}]\"\n+                # Decide whether to notify based on mode\n+                should_notify = (\n+                    notify_mode in (\"all\", \"result\")\n+                    or (notify_mode == \"error\" and session.exit_code not in (0, None))\n                 )\n-                # Try to deliver to the originating platform\n-                adapter = None\n-                for p, a in self.adapters.items():\n-                    if p.value == platform_name:\n-                        adapter = a\n-                        break\n-                if adapter and chat_id:\n-                    try:\n-                        await adapter.send(chat_id, message_text)\n-                    except Exception as e:\n-                        logger.error(\"Watcher delivery error: %s\", e)\n+                if should_notify:\n+                    new_output = session.output_buffer[-1000:] if session.output_buffer else \"\"\n+                    message_text = (\n+                        f\"[Background process {session_id} finished with exit code {session.exit_code}~ \"\n+                        f\"Here's the final output:\\n{new_output}]\"\n+                    )\n+                    adapter = None\n+                    for p, a in self.adapters.items():\n+                        if p.value == platform_name:\n+                            adapter = a\n+                            break\n+                    if adapter and chat_id:\n+                        try:\n+                            await adapter.send(chat_id, message_text)\n+                        except Exception as e:\n+                            logger.error(\"Watcher delivery error: %s\", e)\n                 break\n \n-            elif has_new_output:\n-                # New output available -- deliver status update\n+            elif has_new_output and notify_mode == \"all\":\n+                # New output available -- deliver status update (only in \"all\" mode)\n                 new_output = session.output_buffer[-500:] if session.output_buffer else \"\"\n                 message_text = (\n                     f\"[Background process {session_id} is still running~ \""]},{"file":"tests/gateway/test_background_process_notifications.py","hunks":["@@ -0,0 +1,198 @@\n+\"\"\"Tests for configurable background process notification modes.\n+\n+The gateway process watcher pushes status updates to users' chats when\n+background terminal commands run.  ``display.background_process_notifications``\n+controls verbosity: off | result | error | all (default).\n+\n+Contributed by @PeterFile (PR #593), reimplemented on current main.\n+\"\"\"\n+\n+import asyncio\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform\n+from gateway.run import GatewayRunner\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+class _FakeRegistry:\n+    \"\"\"Return pre-canned sessions, then None once exhausted.\"\"\"\n+\n+    def __init__(self, sessions):\n+        self._sessions = list(sessions)\n+\n+    def get(self, session_id):\n+        if self._sessions:\n+            return self._sessions.pop(0)\n+        return None\n+\n+\n+def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:\n+    \"\"\"Create a GatewayRunner with a fake config for the given mode.\"\"\"\n+    (tmp_path / \"config.yaml\").write_text(\n+        f\"display:\\n  background_process_notifications: {mode}\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    import gateway.run as gateway_run\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+\n+    runner = GatewayRunner(GatewayConfig())\n+    adapter = SimpleNamespace(send=AsyncMock())\n+    runner.adapters[Platform.TELEGRAM] = adapter\n+    return runner\n+\n+\n+def _watcher_dict(session_id=\"proc_test\"):\n+    return {\n+        \"session_id\": session_id,\n+        \"check_interval\": 0,\n+        \"platform\": \"telegram\",\n+        \"chat_id\": \"123\",\n+    }\n+\n+\n+# ---------------------------------------------------------------------------\n+# _load_background_notifications_mode unit tests\n+# ---------------------------------------------------------------------------\n+\n+class TestLoadBackgroundNotificationsMode:\n+\n+    def test_defaults_to_all(self, monkeypatch, tmp_path):\n+        import gateway.run as gw\n+        monkeypatch.setattr(gw, \"_hermes_home\", tmp_path)\n+        monkeypatch.delenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", raising=False)\n+        assert GatewayRunner._load_background_notifications_mode() == \"all\"\n+\n+    def test_reads_config_yaml(self, monkeypatch, tmp_path):\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"display:\\n  background_process_notifications: error\\n\"\n+        )\n+        import gateway.run as gw\n+        monkeypatch.setattr(gw, \"_hermes_home\", tmp_path)\n+        monkeypatch.delenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", raising=False)\n+        assert GatewayRunner._load_background_notifications_mode() == \"error\"\n+\n+    def test_env_var_overrides_config(self, monkeypatch, tmp_path):\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"display:\\n  background_process_notifications: error\\n\"\n+        )\n+        import gateway.run as gw\n+        monkeypatch.setattr(gw, \"_hermes_home\", tmp_path)\n+        monkeypatch.setenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", \"off\")\n+        assert GatewayRunner._load_background_notifications_mode() == \"off\"\n+\n+    def test_false_value_maps_to_off(self, monkeypatch, tmp_path):\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"display:\\n  background_process_notifications: false\\n\"\n+        )\n+        import gateway.run as gw\n+        monkeypatch.setattr(gw, \"_hermes_home\", tmp_path)\n+        monkeypatch.delenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", raising=False)\n+        assert GatewayRunner._load_background_notifications_mode() == \"off\"\n+\n+    def test_invalid_value_defaults_to_all(self, monkeypatch, tmp_path):\n+        (tmp_path / \"config.yaml\").write_text(\n+            \"display:\\n  background_process_notifications: banana\\n\"\n+        )\n+        import gateway.run as gw\n+        monkeypatch.setattr(gw, \"_hermes_home\", tmp_path)\n+        monkeypatch.delenv(\"HERMES_BACKGROUND_NOTIFICATIONS\", raising=False)\n+        assert GatewayRunner._load_background_notifications_mode() == \"all\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _run_process_watcher integration tests\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n[redacted:email](\n+    (\"mode\", \"sessions\", \"expected_calls\", \"expected_fragment\"),\n+    [\n+        # all mode: running output → sends update\n+        (\n+            \"all\",\n+            [\n+                SimpleNamespace(output_buffer=\"building...\\n\", exited=False, exit_code=None),\n+                None,  # process disappears → watcher exits\n+            ],\n+            1,\n+            \"is still running\",\n+        ),\n+        # result mode: running output → no update\n+        (\n+            \"result\",\n+            [\n+                SimpleNamespace(output_buffer=\"building...\\n\", exited=False, exit_code=None),\n+                None,\n+            ],\n+            0,\n+            None,\n+        ),\n+        # off mode: exited process → no notification\n+        (\n+            \"off\",\n+            [SimpleNamespace(output_buffer=\"done\\n\", exited=True, exit_code=0)],\n+            0,\n+            None,\n+        ),\n+        # result mode: exited → notifies\n+        (\n+            \"result\",\n+            [SimpleNamespace(output_buffer=\"done\\n\", exited=True, exit_code=0)],\n+            1,\n+            \"finished with exit code 0\",\n+        ),\n+        # error mode: exit 0 → no notification\n+        (\n+            \"error\",\n+            [SimpleNamespace(output_buffer=\"done\\n\", exited=True, exit_code=0)],\n+            0,\n+            None,\n+        ),\n+        # error mode: exit 1 → notifies\n+        (\n+            \"error\",\n+            [SimpleNamespace(output_buffer=\"traceback\\n\", exited=True, exit_code=1)],\n+            1,\n+            \"finished with exit code 1\",\n+        ),\n+        # all mode: exited → notifies\n+        (\n+            \"all\",\n+            [SimpleNamespace(output_buffer=\"ok\\n\", exited=True, exit_code=0)],\n+            1,\n+            \"finished with exit code 0\",\n+        ),\n+    ],\n+)\n+async def test_run_process_watcher_respects_notification_mode(\n+    monkeypatch, tmp_path, mode, sessions, expected_calls, expected_fragment\n+):\n+    import tools.process_registry as pr_module\n+\n+    monkeypatch.setattr(pr_module, \"process_registry\", _FakeRegistry(sessions))\n+\n+    # Patch asyncio.sleep to avoid real delays\n+    async def _instant_sleep(*_a, **_kw):\n+        pass\n+    monkeypatch.setattr(asyncio, \"sleep\", _instant_sleep)\n+\n+    runner = _build_runner(monkeypatch, tmp_path, mode)\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+\n+    await runner._run_process_watcher(_watcher_dict())\n+\n+    assert adapter.send.await_count == expected_calls, (\n+        f\"mode={mode}: expected {expected_calls} sends, got {adapter.send.await_count}\"\n+    )\n+    if expected_fragment is not None:\n+        sent_message = adapter.send.await_args.args[1]\n+        assert expected_fragment in sent_message\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}