{"repo":"NousResearch/hermes-agent","public_id":"9532","display_label":"Background process notifications routed to wrong session or thread","summary":"Most members concern background process or watch-pattern notifications being routed to the wrong session or thread (Telegram DM topics, Discord, Slack), with proposed changes to route them to the originating session. One member, \"fix: resolve 7 identified issues [automated]\", has a generic title and may not belong to this theme.","subsystem":"gateway","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":4,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10460,"merged_at":"2026-04-15T18:16:01+00:00"},"resolution":{"state":"partially_resolved","by":"pr-10460"},"freshness":{"state":"stale","last_verified_at":"2026-04-15T18:16:01+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14690909090909093},"fix_edges":[{"issue_number":9532,"pr_number":10460,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-15T18:16:01+00:00"}],"members":[{"kind":"issue","number":9532,"title":"[Bug]: background process watch notifications can leak into the wrong threaded session","url":null,"state":"closed"},{"kind":"issue","number":9736,"title":"Bug: Background process completion notifications can route to the wrong session","url":null,"state":"closed"},{"kind":"issue","number":10411,"title":"[Bug]: Background watch_match notifications can be injected into the wrong Discord thread","url":null,"state":"closed"},{"kind":"issue","number":18101,"title":"[Bug]: Background process updates can leak into the wrong Slack thread","url":null,"state":"closed"},{"kind":"pr","number":9537,"title":"fix(gateway): route synthetic background events to their originating session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10460","number":10460},{"kind":"pr","value":"#10446","number":10446}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10446,"title":"fix(gateway): route watch-pattern notifications to originating session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10460","number":10460},{"kind":"pr","value":"#9537","number":9537}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10460,"title":"fix(gateway): route watch-pattern notifications to originating session","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_background_process_notifications.py","tests/gateway/test_internal_event_bypass_pairing.py","tests/tools/test_watch_patterns.py","tools/process_registry.py","tools/terminal_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -482,6 +482,27 @@ def _resolve_hermes_bin() -> Optional[list[str]]:\n     return None\n \n \n+def _parse_session_key(session_key: str) -> \"dict | None\":\n+    \"\"\"Parse a session key into its component parts.\n+\n+    Session keys follow the format\n+    ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``.\n+    Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and\n+    optionally ``thread_id`` keys, or None if the key doesn't match.\n+    \"\"\"\n+    parts = session_key.split(\":\")\n+    if len(parts) >= 5 and parts[0] == \"agent\" and parts[1] == \"main\":\n+        result = {\n+            \"platform\": parts[2],\n+            \"chat_type\": parts[3],\n+            \"chat_id\": parts[4],\n+        }\n+        if len(parts) > 5:\n+            result[\"thread_id\"] = parts[5]\n+        return result\n+    return None\n+\n+\n def _format_gateway_process_notification(evt: dict) -> \"str | None\":\n     \"\"\"Format a watch pattern event from completion_queue into a [SYSTEM:] message.\"\"\"\n     evt_type = evt.get(\"type\", \"completion\")","@@ -1489,12 +1510,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n         notified: set = set()\n         for session_key in active:\n             # Parse platform + chat_id from the session key.\n-            # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]\n-            parts = session_key.split(\":\")\n-            if len(parts) < 5:\n+            _parsed = _parse_session_key(session_key)\n+            if not _parsed:\n                 continue\n-            platform_str = parts[2]\n-            chat_id = parts[4]\n+            platform_str = _parsed[\"platform\"]\n+            chat_id = _parsed[\"chat_id\"]\n \n             # Deduplicate: one notification per chat, even if multiple\n             # sessions (different users/threads) share the same chat.","@@ -1510,7 +1530,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n \n                 # Include thread_id if present so the message lands in the\n                 # correct forum topic / thread.\n-                thread_id = parts[5] if len(parts) > 5 else None\n+                thread_id = _parsed.get(\"thread_id\")\n                 metadata = {\"thread_id\": thread_id} if thread_id else None\n \n                 await adapter.send(chat_id, msg, metadata=metadata)","@@ -3958,7 +3978,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                     synth_text = _format_gateway_process_notification(evt)\n                     if synth_text:\n                         try:\n-                            await self._inject_watch_notification(synth_text, event)\n+                            await self._inject_watch_notification(synth_text, evt)\n                         except Exception as e2:\n                             logger.error(\"Watch notification injection error: %s\", e2)\n             except Exception as e:","@@ -7452,14 +7472,75 @@ async def _enrich_message_with_transcription(\n             return prefix\n         return user_text\n \n-    async def _inject_watch_notification(self, synth_text: str, original_event) -> None:\n+    def _build_process_event_source(self, evt: dict):\n+        \"\"\"Resolve the canonical source for a synthetic background-process event.\n+\n+        Prefer the persisted session-store origin for the event's session key.\n+        Falling back to the currently active foreground event is what causes\n+        cross-topic bleed, so don't do that.\n+        \"\"\"\n+        from gateway.session import SessionSource\n+\n+        session_key = str(evt.get(\"session_key\") or \"\").strip()\n+        derived_platform = \"\"\n+        derived_chat_type = \"\"\n+        derived_chat_id = \"\"\n+\n+        if session_key:\n+            try:\n+                self.session_store._ensure_loaded()\n+                entry = self.session_store._entries.get(session_key)\n+                if entry and getattr(entry, \"origin\", None):\n+                    return entry.origin\n+            except Exception as exc:\n+                logger.debug(\n+                    \"Synthetic process-event session-store lookup failed for %s: %s\",\n+                    session_key,\n+                    exc,\n+                )\n+\n+            _parsed = _parse_session_key(session_key)\n+            if _parsed:\n+                derived_platform = _parsed[\"platform\"]\n+                derived_chat_type = _parsed[\"chat_type\"]\n+                derived_chat_id = _parsed[\"chat_id\"]\n+\n+        platform_name = str(evt.get(\"platform\") or derived_platform or \"\").strip().lower()\n+        chat_type = str(evt.get(\"chat_type\") or derived_chat_type or \"\").strip().lower()\n+        chat_id = str(evt.get(\"chat_id\") or derived_chat_id or \"\").strip()\n+        if not platform_name or not chat_type or not chat_id:\n+            return None\n+\n+        try:\n+            platform = Platform(platform_name)\n+        except Exception:\n+            logger.warning(\n+                \"Synthetic process event has invalid platform metadata: %r\",\n+                platform_name,\n+            )\n+            return None\n+\n+        return SessionSource(\n+            platform=platform,\n+            chat_id=chat_id,\n+            chat_type=chat_type,\n+            thread_id=str(evt.get(\"thread_id\") or \"\").strip() or None,\n+            user_id=str(evt.get(\"user_id\") or \"\").strip() or None,\n+            user_name=str(evt.get(\"user_name\") or \"\").strip() or None,\n+        )\n+\n+    async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None:\n         \"\"\"Inject a watch-pattern notification as a synthetic message event.\n \n-        Uses the source from the original user event to route the notification\n-        back to the correct chat/adapter.\n+        Routing must come from the queued watch event itself, not from whatever\n+        foreground message happened to be active when the queue was drained.\n         \"\"\"\n-        source = getattr(original_event, \"source\", None)\n+        source = self._build_process_event_source(evt)\n         if not source:\n+            logger.warning(\n+                \"Dropping watch notification with no routing metadata for process %s\",\n+                evt.get(\"session_id\", \"unknown\"),\n+            )\n             return\n         platform_name = source.platform.value if hasattr(source.platform, \"value\") else str(source.platform)\n         adapter = None","@@ -7477,7 +7558,12 @@ async def _inject_watch_notification(self, synth_text: str, original_event) -> N\n                 source=source,\n                 internal=True,\n             )\n-            logger.info(\"Watch pattern notification — injecting for %s\", platform_name)\n+            logger.info(\n+                \"Watch pattern notification — injecting for %s chat=%s thread=%s\",\n+                platform_name,\n+                source.chat_id,\n+                source.thread_id,\n+            )\n             await adapter.handle_message(synth_event)\n         except Exception as e:\n             logger.error(\"Watch notification injection error: %s\", e)","@@ -7547,33 +7633,42 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n                         f\"Command: {session.command}\\n\"\n                         f\"Output:\\n{_out}]\"\n                     )\n+                    source = self._build_process_event_source({\n+                        \"session_id\": session_id,\n+                        \"session_key\": session_key,\n+                        \"platform\": platform_name,\n+                        \"chat_id\": chat_id,\n+                        \"thread_id\": thread_id,\n+                        \"user_id\": user_id,\n+                        \"user_name\": user_name,\n+                    })\n+                    if not source:\n+                        logger.warning(\n+                            \"Dropping completion notification with no routing metadata for process %s\",\n+                            session_id,\n+                        )\n+                        break\n+\n                     adapter = None\n                     for p, a in self.adapters.items():\n-                        if p.value == platform_name:\n+                        if p == source.platform:\n                             adapter = a\n                             break\n-                    if adapter and chat_id:\n+                    if adapter and source.chat_id:\n                         try:\n                             from gateway.platforms.base import MessageEvent, MessageType\n-                            from gateway.session import SessionSource\n-                            from gateway.config import Platform\n-                            _platform_enum = Platform(platform_name)\n-                            _source = SessionSource(\n-                                platform=_platform_enum,\n-                                chat_id=chat_id,\n-                                thread_id=thread_id or None,\n-                                user_id=user_id or None,\n-                                user_name=user_name or None,\n-                            )\n                             synth_event = MessageEvent(\n                                 text=synth_text,\n                                 message_type=MessageType.TEXT,\n-                                source=_source,\n+                                source=source,\n                                 internal=True,\n                             )\n                             logger.info(\n-                                \"Process %s finished — injecting agent notification for session %s\",\n-                                session_id, session_key,\n+                                \"Process %s finished — injecting agent notification for session %s chat=%s thread=%s\",\n+                                session_id,\n+                                session_key,\n+                                source.chat_id,\n+                                source.thread_id,\n                             )\n                             await adapter.handle_message(synth_event)\n                         except Exception as e:"]},{"file":"tests/gateway/test_background_process_notifications.py","hunks":["@@ -14,7 +14,7 @@\n import pytest\n \n from gateway.config import GatewayConfig, Platform\n-from gateway.run import GatewayRunner\n+from gateway.run import GatewayRunner, _parse_session_key\n \n \n # ---------------------------------------------------------------------------","@@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:\n     monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n \n     runner = GatewayRunner(GatewayConfig())\n-    adapter = SimpleNamespace(send=AsyncMock())\n+    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())\n     runner.adapters[Platform.TELEGRAM] = adapter\n     return runner\n ","@@ -243,3 +243,162 @@ async def _instant_sleep(*_a, **_kw):\n     assert adapter.send.await_count == 1\n     _, kwargs = adapter.send.call_args\n     assert kwargs[\"metadata\"] is None\n+\n+\n[redacted:email]\n+async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path):\n+    from gateway.session import SessionSource\n+\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"123\",\n+            user_name=\"Emiliyan\",\n+        )\n+    )\n+\n+    evt = {\n+        \"session_id\": \"proc_watch\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+    }\n+\n+    await runner._inject_watch_notification(\"[SYSTEM: Background process matched]\", evt)\n+\n+    adapter.handle_message.assert_awaited_once()\n+    synth_event = adapter.handle_message.await_args.args[0]\n+    assert synth_event.internal is True\n+    assert synth_event.source.platform == Platform.TELEGRAM\n+    assert synth_event.source.chat_id == \"-100\"\n+    assert synth_event.source.chat_type == \"group\"\n+    assert synth_event.source.thread_id == \"42\"\n+    assert synth_event.source.user_id == \"123\"\n+    assert synth_event.source.user_name == \"Emiliyan\"\n+\n+\n+def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path):\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_watch\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+        \"platform\": \"telegram\",\n+        \"chat_id\": \"-100\",\n+        \"thread_id\": \"42\",\n+        \"user_id\": \"123\",\n+        \"user_name\": \"Emiliyan\",\n+    }\n+\n+    source = runner._build_process_event_source(evt)\n+\n+    assert source is not None\n+    assert source.platform == Platform.TELEGRAM\n+    assert source.chat_id == \"-100\"\n+    assert source.chat_type == \"group\"\n+    assert source.thread_id == \"42\"\n+    assert source.user_id == \"123\"\n+    assert source.user_name == \"Emiliyan\"\n+\n+\n[redacted:email]\n+async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path):\n+    \"\"\"Negative test: watch notification must NOT route to the foreground thread.\"\"\"\n+    from gateway.session import SessionSource\n+\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+\n+    # Session store has the process's original thread (thread 42)\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"proc_owner\",\n+            user_name=\"alice\",\n+        )\n+    )\n+\n+    # The evt dict carries the correct session_key — NOT a foreground event\n+    evt = {\n+        \"session_id\": \"proc_cross_thread\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+    }\n+\n+    await runner._inject_watch_notification(\"[SYSTEM: watch match]\", evt)\n+\n+    adapter.handle_message.assert_awaited_once()\n+    synth_event = adapter.handle_message.await_args.args[0]\n+    # Must route to thread 42 (process origin), NOT some other thread\n+    assert synth_event.source.thread_id == \"42\"\n+    assert synth_event.source.user_id == \"proc_owner\"\n+\n+\n+def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path):\n+    \"\"\"Missing session_key and no platform metadata → None (drop notification).\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    source = runner._build_process_event_source({\"session_id\": \"proc_orphan\"})\n+    assert source is None\n+\n+\n+def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path):\n+    \"\"\"Invalid platform string → None.\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_bad\",\n+        \"platform\": \"not_a_real_platform\",\n+        \"chat_type\": \"dm\",\n+        \"chat_id\": \"123\",\n+    }\n+    source = runner._build_process_event_source(evt)\n+    assert source is None\n+\n+\n+def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path):\n+    \"\"\"Session key with <5 parts doesn't parse, falls through to empty metadata → None.\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_short\",\n+        \"session_key\": \"agent:main:telegram\",  # Too few parts\n+    }\n+    source = runner._build_process_event_source(evt)\n+    assert source is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _parse_session_key helper\n+# ---------------------------------------------------------------------------\n+\n+def test_parse_session_key_valid():\n+    result = _parse_session_key(\"agent:main:telegram:group:-100\")\n+    assert result == {\"platform\": \"telegram\", \"chat_type\": \"group\", \"chat_id\": \"-100\"}\n+\n+\n+def test_parse_session_key_with_extra_parts():\n+    \"\"\"Thread ID (6th part) is extracted; further parts are ignored.\"\"\"\n+    result = _parse_session_key(\"agent:main:discord:group:chan123:thread456\")\n+    assert result == {\"platform\": \"discord\", \"chat_type\": \"group\", \"chat_id\": \"chan123\", \"thread_id\": \"thread456\"}\n+\n+\n+def test_parse_session_key_with_user_id_part():\n+    \"\"\"7th part (user_id) is ignored — only up to thread_id is extracted.\"\"\"\n+    result = _parse_session_key(\"agent:main:telegram:group:chat1:thread42:user99\")\n+    assert result == {\"platform\": \"telegram\", \"chat_type\": \"group\", \"chat_id\": \"chat1\", \"thread_id\": \"thread42\"}\n+\n+\n+def test_parse_session_key_too_short():\n+    assert _parse_session_key(\"agent:main:telegram\") is None\n+    assert _parse_session_key(\"\") is None\n+\n+\n+def test_parse_session_key_wrong_prefix():\n+    assert _parse_session_key(\"cron:main:telegram:dm:123\") is None\n+    assert _parse_session_key(\"agent:cron:telegram:dm:123\") is None"]},{"file":"tests/gateway/test_internal_event_bypass_pairing.py","hunks":["@@ -230,6 +230,59 @@ async def _instant_sleep(*_a, **_kw):\n     assert event.source.user_name == \"alice\"\n \n \n[redacted:email]\n+async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path):\n+    import tools.process_registry as pr_module\n+    from gateway.session import SessionSource\n+\n+    sessions = [\n+        SimpleNamespace(\n+            output_buffer=\"done\\n\", exited=True, exit_code=0, command=\"echo test\"\n+        ),\n+    ]\n+    monkeypatch.setattr(pr_module, \"process_registry\", _FakeRegistry(sessions))\n+\n+    async def _instant_sleep(*_a, **_kw):\n+        pass\n+    monkeypatch.setattr(asyncio, \"sleep\", _instant_sleep)\n+\n+    runner = GatewayRunner(GatewayConfig())\n+    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())\n+    runner.adapters[Platform.TELEGRAM] = adapter\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"user-42\",\n+            user_name=\"alice\",\n+        )\n+    )\n+\n+    watcher = {\n+        \"session_id\": \"proc_test_internal\",\n+        \"check_interval\": 0,\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+        \"platform\": \"telegram\",\n+        \"chat_id\": \"-100\",\n+        \"thread_id\": \"42\",\n+        \"notify_on_complete\": True,\n+    }\n+\n+    await runner._run_process_watcher(watcher)\n+\n+    assert adapter.handle_message.await_count == 1\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.internal is True\n+    assert event.source.platform == Platform.TELEGRAM\n+    assert event.source.chat_id == \"-100\"\n+    assert event.source.chat_type == \"group\"\n+    assert event.source.thread_id == \"42\"\n+    assert event.source.user_id == \"user-42\"\n+    assert event.source.user_name == \"alice\"\n+\n+\n @pytest.mark.asyncio\n async def test_none_user_id_skips_pairing(monkeypatch, tmp_path):\n     \"\"\"A non-internal event with user_id=None should be silently dropped.\"\"\""]},{"file":"tests/tools/test_watch_patterns.py","hunks":["@@ -92,6 +92,25 @@ def test_basic_match(self, registry):\n         assert \"disk full\" in evt[\"output\"]\n         assert evt[\"session_id\"] == \"proc_test_watch\"\n \n+    def test_match_carries_session_key_and_watcher_routing_metadata(self, registry):\n+        session = _make_session(watch_patterns=[\"ERROR\"])\n+        session.session_key = \"agent:main:telegram:group:-100:42\"\n+        session.watcher_platform = \"telegram\"\n+        session.watcher_chat_id = \"-100\"\n+        session.watcher_user_id = \"u123\"\n+        session.watcher_user_name = \"alice\"\n+        session.watcher_thread_id = \"42\"\n+\n+        registry._check_watch_patterns(session, \"ERROR: disk full\\n\")\n+        evt = registry.completion_queue.get_nowait()\n+\n+        assert evt[\"session_key\"] == \"agent:main:telegram:group:-100:42\"\n+        assert evt[\"platform\"] == \"telegram\"\n+        assert evt[\"chat_id\"] == \"-100\"\n+        assert evt[\"user_id\"] == \"u123\"\n+        assert evt[\"user_name\"] == \"alice\"\n+        assert evt[\"thread_id\"] == \"42\"\n+\n     def test_multiple_patterns(self, registry):\n         \"\"\"First matching pattern is reported.\"\"\"\n         session = _make_session(watch_patterns=[\"WARN\", \"ERROR\"])"]},{"file":"tools/process_registry.py","hunks":["@@ -191,9 +191,15 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:\n                     session._watch_disabled = True\n                     self.completion_queue.put({\n                         \"session_id\": session.id,\n+                        \"session_key\": session.session_key,\n                         \"command\": session.command,\n                         \"type\": \"watch_disabled\",\n                         \"suppressed\": session._watch_suppressed,\n+                        \"platform\": session.watcher_platform,\n+                        \"chat_id\": session.watcher_chat_id,\n+                        \"user_id\": session.watcher_user_id,\n+                        \"user_name\": session.watcher_user_name,\n+                        \"thread_id\": session.watcher_thread_id,\n                         \"message\": (\n                             f\"Watch patterns disabled for process {session.id} — \"\n                             f\"too many matches ({session._watch_suppressed} suppressed). \"","@@ -219,11 +225,17 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:\n \n         self.completion_queue.put({\n             \"session_id\": session.id,\n+            \"session_key\": session.session_key,\n             \"command\": session.command,\n             \"type\": \"watch_match\",\n             \"pattern\": matched_pattern,\n             \"output\": output,\n             \"suppressed\": suppressed,\n+            \"platform\": session.watcher_platform,\n+            \"chat_id\": session.watcher_chat_id,\n+            \"user_id\": session.watcher_user_id,\n+            \"user_name\": session.watcher_user_name,\n+            \"thread_id\": session.watcher_thread_id,\n         })\n \n     @staticmethod"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1384,14 +1384,10 @@ def terminal_tool(\n                 if pty_disabled_reason:\n                     result_data[\"pty_note\"] = pty_disabled_reason\n \n-                # Mark for agent notification on completion\n-                if notify_on_complete and background:\n-                    proc_session.notify_on_complete = True\n-                    result_data[\"notify_on_complete\"] = True\n-\n-                    # In gateway mode, auto-register a fast watcher so the\n-                    # gateway can detect completion and trigger a new agent\n-                    # turn.  CLI mode uses the completion_queue directly.\n+                # Populate routing metadata on the session so that\n+                # watch-pattern and completion notifications can be\n+                # routed back to the correct chat/thread.\n+                if background and (notify_on_complete or watch_patterns):\n                     from gateway.session_context import get_session_env as _gse\n                     _gw_platform = _gse(\"HERMES_SESSION_PLATFORM\", \"\")\n                     if _gw_platform:","@@ -1404,16 +1400,26 @@ def terminal_tool(\n                         proc_session.watcher_user_id = _gw_user_id\n                         proc_session.watcher_user_name = _gw_user_name\n                         proc_session.watcher_thread_id = _gw_thread_id\n+\n+                # Mark for agent notification on completion\n+                if notify_on_complete and background:\n+                    proc_session.notify_on_complete = True\n+                    result_data[\"notify_on_complete\"] = True\n+\n+                    # In gateway mode, auto-register a fast watcher so the\n+                    # gateway can detect completion and trigger a new agent\n+                    # turn.  CLI mode uses the completion_queue directly.\n+                    if proc_session.watcher_platform:\n                         proc_session.watcher_interval = 5\n                         process_registry.pending_watchers.append({\n                             \"session_id\": proc_session.id,\n                             \"check_interval\": 5,\n                             \"session_key\": session_key,\n-                            \"platform\": _gw_platform,\n-                            \"chat_id\": _gw_chat_id,\n-                            \"user_id\": _gw_user_id,\n-                            \"user_name\": _gw_user_name,\n-                            \"thread_id\": _gw_thread_id,\n+                            \"platform\": proc_session.watcher_platform,\n+                            \"chat_id\": proc_session.watcher_chat_id,\n+                            \"user_id\": proc_session.watcher_user_id,\n+                            \"user_name\": proc_session.watcher_user_name,\n+                            \"thread_id\": proc_session.watcher_thread_id,\n                             \"notify_on_complete\": True,\n                         })\n \n"]}]},{"kind":"pr","number":12918,"title":"fix(gateway): route watch notifications to correct Discord thread (#10411)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"dee592a0b","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":18337,"title":"fix: resolve 7 identified issues [automated]","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"88bd1c01e","number":0},{"kind":"commit","value":"9caa12f4e","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26945,"title":"fix(telegram): preserve DM topic routing for synthetic background process events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27212","number":27212},{"kind":"pr","value":"#9532","number":9532},{"kind":"pr","value":"#27937","number":27937}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":27212,"title":"fix(telegram): reply-anchor background-process notifications into DM topics","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26945","number":26945}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}