{"repo":"NousResearch/hermes-agent","public_id":"2104","display_label":"Async event loop and timeout handling fixes","summary":"These issues and fixes center on asyncio misuse across multiple plugins and tools, particularly 'Timeout context manager should be used inside a task' errors and event loop lifecycle problems. Affected areas include Weixin/WeChat, Matrix, Mattermost, Home Assistant, and cron delivery.","subsystem":"agent","counts":{"members":61,"open_issues":7,"open_issues_distinct":7,"open_prs":3,"closed_issues":22,"merged_prs":8,"attempt_prs":24},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2190},"resolution":{"state":"partially_resolved","by":"pr-2190,pr-2701,pr-10470,pr-11594,pr-80838"},"freshness":{"state":"verified","last_verified_at":"2026-08-07T06:14:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.03361748633879779},"fix_edges":[{"issue_number":2104,"pr_number":2190,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":2681,"pr_number":2701,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":3436,"pr_number":3398,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8848,"pr_number":11594,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10089,"pr_number":11594,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10200,"pr_number":10470,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":11602,"pr_number":11594,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":41037,"pr_number":80807,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57331,"pr_number":80807,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57331,"pr_number":80838,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61495,"pr_number":80807,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":61495,"pr_number":80838,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2104,"title":"[Bug]: 'Exception Event loop is closed' after `vision_analyze` used as first call to hermes in chat session/some chained tool calls","url":null,"state":"closed"},{"kind":"issue","number":2338,"title":"Event Loop is Closed - Cleanup Crash After Vision/Web Tools","url":null,"state":"closed"},{"kind":"issue","number":2681,"title":"AsyncOpenAI/httpx deadlocks during vision tool execution in gateway mode","url":null,"state":"closed"},{"kind":"issue","number":3436,"title":"Bug: \"RuntimeError: Event loop is closed\" crashes CLI mid-session with \"Press ENTER to continue...\"","url":null,"state":"closed"},{"kind":"issue","number":8043,"title":"Bug: persistent event loops in model_tools.py never closed, resource accumulation in long-running gateway","url":null,"state":"open"},{"kind":"issue","number":10089,"title":"[Bug]: Weixin messages from cron/send_message are silently dropped when gateway is running","url":null,"state":"closed"},{"kind":"issue","number":10200,"title":"Bug: auxiliary_client._client_cache accumulates AsyncOpenAI clients indefinitely, causing fd exhaustion in long-running gateway","url":null,"state":"closed"},{"kind":"issue","number":12154,"title":"[WeChat] asyncio.timeout bug and session expiration not handled gracefully","url":null,"state":"open"},{"kind":"issue","number":13099,"title":"[Bug]: WeChat send_message causes \"Timeout context manager should be used inside a task\" error","url":null,"state":"closed"},{"kind":"issue","number":13281,"title":"[Bug] send_message tool fails with Weixin: Timeout context manager should be used inside a task","url":null,"state":"closed"},{"kind":"issue","number":13365,"title":"Weixin: Voice send fails - asyncio Task attached to different loop","url":null,"state":"open"},{"kind":"issue","number":13586,"title":"[Bug]: Home Assistant tool still uses per-call asyncio.run() path inside running event loops","url":null,"state":"closed"},{"kind":"issue","number":14452,"title":"Weixin send_message fails with session timeout due to process isolation","url":null,"state":"closed"},{"kind":"issue","number":16293,"title":"[Bug]: Weixin message error","url":null,"state":"closed"},{"kind":"issue","number":16570,"title":"[Bug]: model_tools async bridge recreates loops in running-loop contexts","url":null,"state":"open"},{"kind":"issue","number":17347,"title":"[Bug] Weixin send_message fails with \"Timeout context manager should be used inside a task\"","url":null,"state":"closed"},{"kind":"issue","number":17595,"title":"[Weixin] File/image sending fails with asyncio timeout error","url":null,"state":"closed"},{"kind":"issue","number":18014,"title":"Bug: Cron job delivery to WeChat fails with \"Timeout context manager should be used inside a task\"","url":null,"state":"closed"},{"kind":"issue","number":18437,"title":"[Bug] Weixin send_message fails with event-loop errors in non-gateway sessions (v0.11.0)","url":null,"state":"closed"},{"kind":"issue","number":18791,"title":"[Bug] Weixin send_message fails with 'Timeout context manager should be used inside a task'","url":null,"state":"closed"},{"kind":"issue","number":18836,"title":"[Bug] Weixin Gateway: Timeout context manager should be used inside a task","url":null,"state":"closed"},{"kind":"issue","number":20229,"title":"WeChat adapter: \"Timeout context manager should be used inside a task\" error when cron job delivers to WeChat","url":null,"state":"closed"},{"kind":"issue","number":23371,"title":"send_message weixin: asyncio 'Future attached to a different loop' bug","url":null,"state":"closed"},{"kind":"issue","number":24142,"title":"[Bug]: Weixin send_message fails: 'Timeout context manager should be used inside a task'","url":null,"state":"closed"},{"kind":"issue","number":29037,"title":"fix(weixin): replace aiohttp ClientTimeout with asyncio.wait_for()","url":null,"state":"closed"},{"kind":"issue","number":35215,"title":"Weixin Session Expired Error Log Spam (Loop)","url":null,"state":"open"},{"kind":"issue","number":37005,"title":"Mattermost plugin: send_message fails with 'Timeout context manager should be used inside a task' (v0.15.x)","url":null,"state":"open"},{"kind":"issue","number":39836,"title":"[Bug] Mattermost cron job delivery fails with \"Timeout context manager should be used inside a task\"","url":null,"state":"open"},{"kind":"issue","number":61495,"title":"[Bug]: Matrix manual cron delivery fails with \"Timeout context manager should be used inside a task\"","url":null,"state":"closed"},{"kind":"pr","number":2146,"title":"fix: use persistent event loop in _run_async to prevent \"Event loop is closed\" (#2104)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2190","number":2190}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2190,"title":"fix: event loop lifecycle + session search lineage exclusion","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","model_tools.py","tests/test_model_tools_async_bridge.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1191,8 +1191,18 @@ def _get_cached_client(\n     cache_key = (provider, async_mode, base_url or \"\", api_key or \"\")\n     with _client_cache_lock:\n         if cache_key in _client_cache:\n-            cached_client, cached_default = _client_cache[cache_key]\n-            return cached_client, model or cached_default\n+            cached_client, cached_default, cached_loop = _client_cache[cache_key]\n+            if async_mode:\n+                # Async clients are bound to the event loop that created them.\n+                # A cached async client whose loop has been closed will raise\n+                # \"Event loop is closed\" when httpx tries to clean up its\n+                # transport.  Discard the stale client and create a fresh one.\n+                if cached_loop is not None and cached_loop.is_closed():\n+                    del _client_cache[cache_key]\n+                else:\n+                    return cached_client, model or cached_default\n+            else:\n+                return cached_client, model or cached_default\n     # Build outside the lock\n     client, default_model = resolve_provider_client(\n         provider,","@@ -1202,11 +1212,20 @@ def _get_cached_client(\n         explicit_api_key=[redacted:secret-assignment]\n     )\n     if client is not None:\n+        # For async clients, remember which loop they were created on so we\n+        # can detect stale entries later.\n+        bound_loop = None\n+        if async_mode:\n+            try:\n+                import asyncio as _aio\n+                bound_loop = _aio.get_event_loop()\n+            except RuntimeError:\n+                pass\n         with _client_cache_lock:\n             if cache_key not in _client_cache:\n-                _client_cache[cache_key] = (client, default_model)\n+                _client_cache[cache_key] = (client, default_model, bound_loop)\n             else:\n-                client, default_model = _client_cache[cache_key]\n+                client, default_model, _ = _client_cache[cache_key]\n     return client, model or default_model\n \n "]},{"file":"model_tools.py","hunks":["@@ -24,6 +24,7 @@\n import asyncio\n import os\n import logging\n+import threading\n from typing import Dict, Any, List, Optional, Tuple\n \n from tools.registry import registry","@@ -36,6 +37,25 @@\n # Async Bridging  (single source of truth -- used by registry.dispatch too)\n # =============================================================================\n \n+_tool_loop = None          # persistent loop for the main (CLI) thread\n+_tool_loop_lock = threading.Lock()\n+\n+\n+def _get_tool_loop():\n+    \"\"\"Return a long-lived event loop for running async tool handlers.\n+\n+    Using a persistent loop (instead of asyncio.run() which creates and\n+    *closes* a fresh loop every time) prevents \"Event loop is closed\"\n+    errors that occur when cached httpx/AsyncOpenAI clients attempt to\n+    close their transport on a dead loop during garbage collection.\n+    \"\"\"\n+    global _tool_loop\n+    with _tool_loop_lock:\n+        if _tool_loop is None or _tool_loop.is_closed():\n+            _tool_loop = asyncio.new_event_loop()\n+        return _tool_loop\n+\n+\n def _run_async(coro):\n     \"\"\"Run an async coroutine from a sync context.\n ","@@ -44,6 +64,10 @@ def _run_async(coro):\n     disposable thread so asyncio.run() can create its own loop without\n     conflicting.\n \n+    For the common CLI path (no running loop), we use a persistent event\n+    loop so that cached async clients (httpx / AsyncOpenAI) remain bound\n+    to a live loop and don't trigger \"Event loop is closed\" on GC.\n+\n     This is the single source of truth for sync->async bridging in tool\n     handlers. The RL paths (agent_loop.py, tool_context.py) also provide\n     outer thread-pool wrapping as defense-in-depth, but each handler is","@@ -59,7 +83,9 @@ def _run_async(coro):\n         with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:\n             future = pool.submit(asyncio.run, coro)\n             return future.result(timeout=300)\n-    return asyncio.run(coro)\n+\n+    tool_loop = _get_tool_loop()\n+    return tool_loop.run_until_complete(coro)\n \n \n # ============================================================================="]},{"file":"tests/test_model_tools_async_bridge.py","hunks":["@@ -0,0 +1,211 @@\n+\"\"\"Regression tests for the _run_async() event-loop lifecycle.\n+\n+These tests verify the fix for GitHub issue #2104:\n+  \"Event loop is closed\" after vision_analyze used as first call in session.\n+\n+Root cause: asyncio.run() creates and *closes* a fresh event loop on every\n+call.  Cached httpx/AsyncOpenAI clients that were bound to the now-dead loop\n+would crash with RuntimeError(\"Event loop is closed\") when garbage-collected.\n+\n+The fix replaces asyncio.run() with a persistent event loop in _run_async().\n+\"\"\"\n+\n+import asyncio\n+import json\n+import threading\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+async def _get_current_loop():\n+    \"\"\"Return the running event loop from inside a coroutine.\"\"\"\n+    return asyncio.get_event_loop()\n+\n+\n+async def _create_and_return_transport():\n+    \"\"\"Simulate an async client creating a transport on the current loop.\n+\n+    Returns a simple asyncio.Future bound to the running loop so we can\n+    later check whether the loop is still alive.\n+    \"\"\"\n+    loop = asyncio.get_event_loop()\n+    fut = loop.create_future()\n+    fut.set_result(\"ok\")\n+    return loop, fut\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests\n+# ---------------------------------------------------------------------------\n+\n+class TestRunAsyncLoopLifecycle:\n+    \"\"\"Verify _run_async() keeps the event loop alive after returning.\"\"\"\n+\n+    def test_loop_not_closed_after_run_async(self):\n+        \"\"\"The loop used by _run_async must still be open after the call.\"\"\"\n+        from model_tools import _run_async\n+\n+        loop = _run_async(_get_current_loop())\n+\n+        assert not loop.is_closed(), (\n+            \"_run_async() closed the event loop — cached async clients will \"\n+            \"crash with 'Event loop is closed' on GC (issue #2104)\"\n+        )\n+\n+    def test_same_loop_reused_across_calls(self):\n+        \"\"\"Consecutive _run_async calls should reuse the same loop.\"\"\"\n+        from model_tools import _run_async\n+\n+        loop1 = _run_async(_get_current_loop())\n+        loop2 = _run_async(_get_current_loop())\n+\n+        assert loop1 is loop2, (\n+            \"_run_async() created a new loop on the second call — cached \"\n+            \"async clients from the first call would be orphaned\"\n+        )\n+\n+    def test_cached_transport_survives_between_calls(self):\n+        \"\"\"A transport/future created in call 1 must be valid in call 2.\"\"\"\n+        from model_tools import _run_async\n+\n+        loop, fut = _run_async(_create_and_return_transport())\n+\n+        assert not loop.is_closed()\n+        assert fut.result() == \"ok\"\n+\n+        loop2 = _run_async(_get_current_loop())\n+        assert loop2 is loop, \"Loop changed between calls\"\n+        assert not loop.is_closed(), \"Loop closed before second call\"\n+\n+\n+class TestRunAsyncWithRunningLoop:\n+    \"\"\"When a loop is already running, _run_async falls back to a thread.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_run_async_from_async_context(self):\n+        \"\"\"_run_async should still work when called from inside an\n+        already-running event loop (gateway / Atropos path).\"\"\"\n+        from model_tools import _run_async\n+\n+        async def _simple():\n+            return 42\n+\n+        result = await asyncio.get_event_loop().run_in_executor(\n+            None, _run_async, _simple()\n+        )\n+        assert result == 42\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration: full vision_analyze dispatch chain\n+# ---------------------------------------------------------------------------\n+\n+def _mock_vision_response():\n+    \"\"\"Build a fake LLM response matching async_call_llm's return shape.\"\"\"\n+    message = SimpleNamespace(content=\"A cat sitting on a chair.\")\n+    choice = SimpleNamespace(index=0, message=message, finish_reason=\"stop\")\n+    return SimpleNamespace(choices=[choice], model=\"test/vision\", usage=None)\n+\n+\n+class TestVisionDispatchLoopSafety:\n+    \"\"\"Simulate the full registry.dispatch('vision_analyze') chain and\n+    verify the event loop stays alive afterwards — the exact scenario\n+    from issue #2104.\"\"\"\n+\n+    def test_vision_dispatch_keeps_loop_alive(self, tmp_path):\n+        \"\"\"After dispatching vision_analyze via the registry, the event\n+        loop must remain open so cached async clients don't crash on GC.\"\"\"\n+        from model_tools import _run_async, _get_tool_loop\n+        from tools.registry import registry\n+\n+        fake_response = _mock_vision_response()\n+\n+        with (\n+            patch(\n+                \"tools.vision_tools.async_call_llm\",\n+                new_callable=AsyncMock,\n+                return_value=fake_response,\n+            ),\n+            patch(\n+                \"tools.vision_tools._download_image\",\n+                new_callable=AsyncMock,\n+                side_effect=lambda url, dest, **kw: _write_fake_image(dest),\n+            ),\n+            patch(\n+                \"tools.vision_tools._validate_image_url\",\n+                return_value=True,\n+            ),\n+            patch(\n+                \"tools.vision_tools._image_to_base64_data_url\",\n+                return_value=\"data:image/jpeg;base64,abc\",\n+            ),\n+        ):\n+            result_json = registry.dispatch(\n+                \"vision_analyze\",\n+                {\"image_url\": \"https://example.com/cat.png\", \"question\": \"What is this?\"},\n+            )\n+\n+        result = json.loads(result_json)\n+        assert result.get(\"success\") is True, f\"dispatch failed: {result}\"\n+        assert \"cat\" in result.get(\"analysis\", \"\").lower()\n+\n+        loop = _get_tool_loop()\n+        assert not loop.is_closed(), (\n+            \"Event loop closed after vision_analyze dispatch — cached async \"\n+            \"clients will crash with 'Event loop is closed' (issue #2104)\"\n+        )\n+\n+    def test_two_consecutive_vision_dispatches(self, tmp_path):\n+        \"\"\"Two back-to-back vision_analyze dispatches must both succeed\n+        and share the same loop (simulates 'first call fails, second\n+        works' from the issue report).\"\"\"\n+        from model_tools import _get_tool_loop\n+        from tools.registry import registry\n+\n+        fake_response = _mock_vision_response()\n+\n+        with (\n+            patch(\n+                \"tools.vision_tools.async_call_llm\",\n+                new_callable=AsyncMock,\n+                return_value=fake_response,\n+            ),\n+            patch(\n+                \"tools.vision_tools._download_image\",\n+                new_callable=AsyncMock,\n+                side_effect=lambda url, dest, **kw: _write_fake_image(dest),\n+            ),\n+            patch(\n+                \"tools.vision_tools._validate_image_url\",\n+                return_value=True,\n+            ),\n+            patch(\n+                \"tools.vision_tools._image_to_base64_data_url\",\n+                return_value=\"data:image/jpeg;base64,abc\",\n+            ),\n+        ):\n+            args = {\"image_url\": \"https://example.com/cat.png\", \"question\": \"Describe\"}\n+\n+            r1 = json.loads(registry.dispatch(\"vision_analyze\", args))\n+            loop_after_first = _get_tool_loop()\n+\n+            r2 = json.loads(registry.dispatch(\"vision_analyze\", args))\n+            loop_after_second = _get_tool_loop()\n+\n+        assert r1.get(\"success\") is True\n+        assert r2.get(\"success\") is True\n+        assert loop_after_first is loop_after_second, \"Loop changed between dispatches\"\n+        assert not loop_after_second.is_closed()\n+\n+\n+def _write_fake_image(dest):\n+    \"\"\"Write minimal bytes so vision_analyze_tool thinks download succeeded.\"\"\"\n+    dest.parent.mkdir(parents=True, exist_ok=True)\n+    dest.write_bytes(b\"\\xff\\xd8\\xff\" + b\"\\x00\" * 16)\n+    return dest\n"]}]},{"kind":"pr","number":2371,"title":"fix: Event Loop is Closed - Cleanup Crash After Vision/Web Tools","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":2682,"title":"fix(vision): use sync call_llm to avoid AsyncOpenAI deadlock in gateway mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2701","number":2701}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":2701,"title":"fix(agent): prevent AsyncOpenAI/httpx cross-loop deadlock in gateway mode","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/test_crossloop_client_cache.py","tools/session_search_tool.py"]},{"kind":"pr","number":3398,"title":"fix: eliminate 'Event loop is closed' / 'Press ENTER to continue' during idle","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","cli.py","tests/test_async_httpx_del_neuter.py"]},{"kind":"pr","number":3437,"title":"fix(async): prevent 'Event loop is closed' GC crashes mid-session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0dbbdb2c","number":0},{"kind":"pr","value":"#3398","number":3398}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8073,"title":"fix: add atexit cleanup and timeout for event loop in model_tools","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"76c454914","number":0},{"kind":"commit","value":"b0435cc16","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10091,"title":"fix(weixin): split poll/send sessions and reuse live adapter to prevent message loss","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10089","number":10089},{"kind":"pr","value":"#9425","number":9425},{"kind":"pr","value":"#9193","number":9193}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10470,"title":"fix: bound auxiliary client cache to prevent fd exhaustion in long-running gateways","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/run_agent/test_async_httpx_del_neuter.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1835,9 +1835,15 @@ def auxiliary_max_tokens_param(value: int) -> dict:\n # Every auxiliary LLM consumer should use these instead of manually\n # constructing clients and calling .chat.completions.create().\n \n-# Client cache: (provider, async_mode, base_url, api_key) -> (client, default_model)\n+# Client cache: (provider, async_mode, base_url, api_key, api_mode, runtime_key) -> (client, default_model, loop)\n+# NOTE: loop identity is NOT part of the key.  On async cache hits we check\n+# whether the cached loop is the *current* loop; if not, the stale entry is\n+# replaced in-place.  This bounds cache growth to one entry per unique\n+# provider config rather than one per (config × event-loop), which previously\n+# caused unbounded fd accumulation in long-running gateway processes (#10200).\n _client_cache: Dict[tuple, tuple] = {}\n _client_cache_lock = threading.Lock()\n+_CLIENT_CACHE_MAX_SIZE = 64  # safety belt — evict oldest when exceeded\n \n \n def neuter_async_httpx_del() -> None:","@@ -1970,39 +1976,49 @@ def _get_cached_client(\n     Async clients (AsyncOpenAI) use httpx.AsyncClient internally, which\n     binds to the event loop that was current when the client was created.\n     Using such a client on a *different* loop causes deadlocks or\n-    RuntimeError.  To prevent cross-loop issues (especially in gateway\n-    mode where _run_async() may spawn fresh loops in worker threads), the\n-    cache key for async clients includes the current event loop's identity\n-    so each loop gets its own client instance.\n+    RuntimeError.  To prevent cross-loop issues, the cache validates on\n+    every async hit that the cached loop is the *current, open* loop.\n+    If the loop changed (e.g. a new gateway worker-thread loop), the stale\n+    entry is replaced in-place rather than creating an additional entry.\n+\n+    This keeps cache size bounded to one entry per unique provider config,\n+    preventing the fd-exhaustion that previously occurred in long-running\n+    gateways where recycled worker threads created unbounded entries (#10200).\n     \"\"\"\n-    # Include loop identity for async clients to prevent cross-loop reuse.\n-    # httpx.AsyncClient (inside AsyncOpenAI) is bound to the loop where it\n-    # was created — reusing it on a different loop causes deadlocks (#2681).\n-    loop_id = 0\n+    # Resolve the current event loop for async clients so we can validate\n+    # cached entries.  Loop identity is NOT in the cache key — instead we\n+    # check at hit time whether the cached loop is still current and open.\n+    # This prevents unbounded cache growth from recycled worker-thread loops\n+    # while still guaranteeing we never reuse a client on the wrong loop\n+    # (which causes deadlocks, see #2681).\n     current_loop = None\n     if async_mode:\n         try:\n             import asyncio as _aio\n             current_loop = _aio.get_event_loop()\n-            loop_id = id(current_loop)\n         except RuntimeError:\n             pass\n     runtime = _normalize_main_runtime(main_runtime)\n     runtime_key = tuple(runtime.get(field, \"\") for field in _MAIN_RUNTIME_FIELDS) if provider == \"auto\" else ()\n-    cache_key = (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", loop_id, runtime_key)\n+    cache_key = (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", runtime_key)\n     with _client_cache_lock:\n         if cache_key in _client_cache:\n             cached_client, cached_default, cached_loop = _client_cache[cache_key]\n             if async_mode:\n-                # A cached async client whose loop has been closed will raise\n-                # \"Event loop is closed\" when httpx tries to clean up its\n-                # transport.  Discard the stale client and create a fresh one.\n-                if cached_loop is not None and cached_loop.is_closed():\n-                    _force_close_async_httpx(cached_client)\n-                    del _client_cache[cache_key]\n-                else:\n+                # Validate: the cached client must be bound to the CURRENT,\n+                # OPEN loop.  If the loop changed or was closed, the httpx\n+                # transport inside is dead — force-close and replace.\n+                loop_ok = (\n+                    cached_loop is not None\n+                    and cached_loop is current_loop\n+                    and not cached_loop.is_closed()\n+                )\n+                if loop_ok:\n                     effective = _compat_model(cached_client, model, cached_default)\n                     return cached_client, effective\n+                # Stale — evict and fall through to create a new client.\n+                _force_close_async_httpx(cached_client)\n+                del _client_cache[cache_key]\n             else:\n                 effective = _compat_model(cached_client, model, cached_default)\n                 return cached_client, effective","@@ -2022,6 +2038,12 @@ def _get_cached_client(\n         bound_loop = current_loop\n         with _client_cache_lock:\n             if cache_key not in _client_cache:\n+                # Safety belt: if the cache has grown beyond the max, evict\n+                # the oldest entries (FIFO — dict preserves insertion order).\n+                while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE:\n+                    evict_key, evict_entry = next(iter(_client_cache.items()))\n+                    _force_close_async_httpx(evict_entry[0])\n+                    del _client_cache[evict_key]\n                 _client_cache[cache_key] = (client, default_model, bound_loop)\n             else:\n                 client, default_model, _ = _client_cache[cache_key]"]},{"file":"tests/run_agent/test_async_httpx_del_neuter.py","hunks":["@@ -103,7 +103,7 @@ def test_removes_stale_entries(self):\n         mock_client._client = MagicMock()\n         mock_client._client.is_closed = False\n \n-        key = (\"test_stale\", True, \"\", \"\", id(loop))\n+        key = (\"test_stale\", True, \"\", \"\", \"\", ())\n         with _client_cache_lock:\n             _client_cache[key] = (mock_client, \"test-model\", loop)\n ","@@ -127,7 +127,7 @@ def test_keeps_live_entries(self):\n         loop = asyncio.new_event_loop()  # NOT closed\n \n         mock_client = MagicMock()\n-        key = (\"test_live\", True, \"\", \"\", id(loop))\n+        key = (\"test_live\", True, \"\", \"\", \"\", ())\n         with _client_cache_lock:\n             _client_cache[key] = (mock_client, \"test-model\", loop)\n ","@@ -149,7 +149,7 @@ def test_keeps_entries_without_loop(self):\n         )\n \n         mock_client = MagicMock()\n-        key = (\"test_sync\", False, \"\", \"\", 0)\n+        key = (\"test_sync\", False, \"\", \"\", \"\", ())\n         with _client_cache_lock:\n             _client_cache[key] = (mock_client, \"test-model\", None)\n ","@@ -160,3 +160,131 @@ def test_keeps_entries_without_loop(self):\n         finally:\n             with _client_cache_lock:\n                 _client_cache.pop(key, None)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cache bounded growth (#10200)\n+# ---------------------------------------------------------------------------\n+\n+class TestClientCacheBoundedGrowth:\n+    \"\"\"Verify the cache stays bounded when loops change (fix for #10200).\n+\n+    Previously, loop_id was part of the cache key, so every new event loop\n+    created a new entry for the same provider config.  Now loop identity is\n+    validated at hit time and stale entries are replaced in-place.\n+    \"\"\"\n+\n+    def test_same_key_replaces_stale_loop_entry(self):\n+        \"\"\"When the loop changes, the old entry should be replaced, not duplicated.\"\"\"\n+        from agent.auxiliary_client import (\n+            _client_cache,\n+            _client_cache_lock,\n+            _get_cached_client,\n+        )\n+\n+        key = (\"test_replace\", True, \"\", \"\", \"\", ())\n+\n+        # Simulate a stale entry from a closed loop\n+        old_loop = asyncio.new_event_loop()\n+        old_loop.close()\n+        old_client = MagicMock()\n+        old_client._client = MagicMock()\n+        old_client._client.is_closed = False\n+\n+        with _client_cache_lock:\n+            _client_cache[key] = (old_client, \"old-model\", old_loop)\n+\n+        try:\n+            # Now call _get_cached_client — should detect stale loop and evict\n+            with patch(\"agent.auxiliary_client.resolve_provider_client\") as mock_resolve:\n+                mock_resolve.return_value = (MagicMock(), \"new-model\")\n+                client, model = _get_cached_client(\n+                    \"test_replace\", async_mode=True,\n+                )\n+            # The old entry should have been replaced\n+            with _client_cache_lock:\n+                assert key in _client_cache, \"Key should still exist (replaced)\"\n+                entry = _client_cache[key]\n+                assert entry[1] == \"new-model\", \"Should have the new model\"\n+        finally:\n+            with _client_cache_lock:\n+                _client_cache.pop(key, None)\n+\n+    def test_different_loops_do_not_grow_cache(self):\n+        \"\"\"Multiple event loops for the same provider should NOT create multiple entries.\"\"\"\n+        from agent.auxiliary_client import (\n+            _client_cache,\n+            _client_cache_lock,\n+        )\n+\n+        key = (\"test_no_grow\", True, \"\", \"\", \"\", ())\n+\n+        loops = []\n+        try:\n+            for i in range(5):\n+                loop = asyncio.new_event_loop()\n+                loops.append(loop)\n+                mock_client = MagicMock()\n+                mock_client._client = MagicMock()\n+                mock_client._client.is_closed = False\n+\n+                # Close previous loop entries (simulating worker thread recycling)\n+                if i > 0:\n+                    loops[i - 1].close()\n+\n+                with _client_cache_lock:\n+                    # Simulate what _get_cached_client does: replace on loop mismatch\n+                    if key in _client_cache:\n+                        old_entry = _client_cache[key]\n+                        del _client_cache[key]\n+                    _client_cache[key] = (mock_client, f\"model-{i}\", loop)\n+\n+            # Only one entry should exist for this key\n+            with _client_cache_lock:\n+                count = sum(1 for k in _client_cache if k == key)\n+                assert count == 1, f\"Expected 1 entry, got {count}\"\n+        finally:\n+            for loop in loops:\n+                if not loop.is_closed():\n+                    loop.close()\n+            with _client_cache_lock:\n+                _client_cache.pop(key, None)\n+\n+    def test_max_cache_size_eviction(self):\n+        \"\"\"Cache should not exceed _CLIENT_CACHE_MAX_SIZE.\"\"\"\n+        from agent.auxiliary_client import (\n+            _client_cache,\n+            _client_cache_lock,\n+            _CLIENT_CACHE_MAX_SIZE,\n+        )\n+\n+        # Save existing cache state\n+        with _client_cache_lock:\n+            saved = dict(_client_cache)\n+            _client_cache.clear()\n+\n+        try:\n+            # Fill to max + 5\n+            for i in range(_CLIENT_CACHE_MAX_SIZE + 5):\n+                mock_client = MagicMock()\n+                mock_client._client = MagicMock()\n+                mock_client._client.is_closed = False\n+                key = (f\"evict_test_{i}\", False, \"\", \"\", \"\", ())\n+                with _client_cache_lock:\n+                    # Inline the eviction logic (same as _get_cached_client)\n+                    while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE:\n+                        evict_key = next(iter(_client_cache))\n+                        del _client_cache[evict_key]\n+                    _client_cache[key] = (mock_client, f\"model-{i}\", None)\n+\n+            with _client_cache_lock:\n+                assert len(_client_cache) <= _CLIENT_CACHE_MAX_SIZE, \\\n+                    f\"Cache size {len(_client_cache)} exceeds max {_CLIENT_CACHE_MAX_SIZE}\"\n+                # The earliest entries should have been evicted\n+                assert (\"evict_test_0\", False, \"\", \"\", \"\", ()) not in _client_cache\n+                # The latest entries should be present\n+                assert (f\"evict_test_{_CLIENT_CACHE_MAX_SIZE + 4}\", False, \"\", \"\", \"\", ()) in _client_cache\n+        finally:\n+            with _client_cache_lock:\n+                _client_cache.clear()\n+                _client_cache.update(saved)\n"]}]},{"kind":"pr","number":11594,"title":"fix(cron+weixin): Batch-4 salvage — origin fallback dict + Weixin session split + multi-target deliver","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","gateway/platforms/weixin.py","scripts/release.py","tests/cron/test_scheduler.py","tests/gateway/test_weixin.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -27,7 +27,7 @@\n     except ImportError:\n         msvcrt = None\n from pathlib import Path\n-from typing import Optional\n+from typing import List, Optional\n \n # Add parent directory to path for imports BEFORE repo-level imports.\n # Without this, standalone invocations (e.g. after `hermes update` reloads","@@ -49,6 +49,25 @@\n     \"qqbot\",\n })\n \n+# Platforms that support a configured cron/notification home target, mapped to\n+# the environment variable used by gateway setup/runtime config.\n+_HOME_TARGET_ENV_VARS = {\n+    \"matrix\": \"MATRIX_HOME_ROOM\",\n+    \"telegram\": \"TELEGRAM_HOME_CHANNEL\",\n+    \"discord\": \"DISCORD_HOME_CHANNEL\",\n+    \"slack\": \"SLACK_HOME_CHANNEL\",\n+    \"signal\": \"SIGNAL_HOME_CHANNEL\",\n+    \"mattermost\": \"MATTERMOST_HOME_CHANNEL\",\n+    \"sms\": \"SMS_HOME_CHANNEL\",\n+    \"email\": \"EMAIL_HOME_ADDRESS\",\n+    \"dingtalk\": \"DINGTALK_HOME_CHANNEL\",\n+    \"feishu\": \"FEISHU_HOME_CHANNEL\",\n+    \"wecom\": \"WECOM_HOME_CHANNEL\",\n+    \"weixin\": \"WEIXIN_HOME_CHANNEL\",\n+    \"bluebubbles\": \"BLUEBUBBLES_HOME_CHANNEL\",\n+    \"qqbot\": \"QQ_HOME_CHANNEL\",\n+}\n+\n from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run\n \n # Sentinel: when a cron agent has nothing new to report, it can start its","@@ -76,15 +95,23 @@ def _resolve_origin(job: dict) -> Optional[dict]:\n     return None\n \n \n-def _resolve_delivery_target(job: dict) -> Optional[dict]:\n-    \"\"\"Resolve the concrete auto-delivery target for a cron job, if any.\"\"\"\n-    deliver = job.get(\"deliver\", \"local\")\n+def _get_home_target_chat_id(platform_name: str) -> str:\n+    \"\"\"Return the configured home target chat/room ID for a delivery platform.\"\"\"\n+    env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())\n+    if not env_var:\n+        return \"\"\n+    return os.getenv(env_var, \"\")\n+\n+\n+def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]:\n+    \"\"\"Resolve one concrete auto-delivery target for a cron job.\"\"\"\n+\n     origin = _resolve_origin(job)\n \n-    if deliver == \"local\":\n+    if deliver_value == \"local\":\n         return None\n \n-    if deliver == \"origin\":\n+    if deliver_value == \"origin\":\n         if origin:\n             return {\n                 \"platform\": origin[\"platform\"],","@@ -93,8 +120,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n             }\n         # Origin missing (e.g. job created via API/script) — try each\n         # platform's home channel as a fallback instead of silently dropping.\n-        for platform_name in (\"matrix\", \"telegram\", \"discord\", \"slack\", \"bluebubbles\"):\n-            chat_id = os.getenv(f\"{platform_name.upper()}_HOME_CHANNEL\", \"\")\n+        for platform_name in _HOME_TARGET_ENV_VARS:\n+            chat_id = _get_home_target_chat_id(platform_name)\n             if chat_id:\n                 logger.info(\n                     \"Job '%s' has deliver=origin but no origin; falling back to %s home channel\",","@@ -108,8 +135,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n                 }\n         return None\n \n-    if \":\" in deliver:\n-        platform_name, rest = deliver.split(\":\", 1)\n+    if \":\" in deliver_value:\n+        platform_name, rest = deliver_value.split(\":\", 1)\n         platform_key = platform_name.lower()\n \n         from tools.send_message_tool import _parse_target_ref","@@ -139,7 +166,7 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n             \"thread_id\": thread_id,\n         }\n \n-    platform_name = deliver\n+    platform_name = deliver_value\n     if origin and origin.get(\"platform\") == platform_name:\n         return {\n             \"platform\": platform_name,","@@ -149,7 +176,7 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n \n     if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS:\n         return None\n-    chat_id = os.getenv(f\"{platform_name.upper()}_HOME_CHANNEL\", \"\")\n+    chat_id = _get_home_target_chat_id(platform_name)\n     if not chat_id:\n         return None\n ","@@ -160,6 +187,30 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n     }\n \n \n+def _resolve_delivery_targets(job: dict) -> List[dict]:\n+    \"\"\"Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver).\"\"\"\n+    deliver = job.get(\"deliver\", \"local\")\n+    if deliver == \"local\":\n+        return []\n+    parts = [p.strip() for p in str(deliver).split(\",\") if p.strip()]\n+    seen = set()\n+    targets = []\n+    for part in parts:\n+        target = _resolve_single_delivery_target(job, part)\n+        if target:\n+            key = (target[\"platform\"].lower(), str(target[\"chat_id\"]), target.get(\"thread_id\"))\n+            if key not in seen:\n+                seen.add(key)\n+                targets.append(target)\n+    return targets\n+\n+\n+def _resolve_delivery_target(job: dict) -> Optional[dict]:\n+    \"\"\"Resolve the concrete auto-delivery target for a cron job, if any.\"\"\"\n+    targets = _resolve_delivery_targets(job)\n+    return targets[0] if targets else None\n+\n+\n # Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background\n _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'})\n _VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})","@@ -200,7 +251,7 @@ def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata:\n \n def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Optional[str]:\n     \"\"\"\n-    Deliver job output to the configured target (origin chat, specific platform, etc.).\n+    Deliver job output to the configured target(s) (origin chat, specific platform, etc.).\n \n     When ``adapters`` and ``loop`` are provided (gateway is running), tries to\n     use the live adapter first — this supports E2EE rooms (e.g. Matrix) where","@@ -209,33 +260,14 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n \n     Returns None on success, or an error string on failure.\n     \"\"\"\n-    target = _resolve_delivery_target(job)\n-    if not target:\n+    targets = _resolve_delivery_targets(job)\n+    if not targets:\n         if job.get(\"deliver\", \"local\") != \"local\":\n             msg = f\"no delivery target resolved for deliver={job.get('deliver', 'local')}\"\n             logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n             return msg\n         return None  # local-only jobs don't deliver — not a failure\n \n-    platform_name = target[\"platform\"]\n-    chat_id = target[\"chat_id\"]\n-    thread_id = target.get(\"thread_id\")\n-\n-    # Diagnostic: log thread_id for topic-aware delivery debugging\n-    origin = job.get(\"origin\") or {}\n-    origin_thread = origin.get(\"thread_id\")\n-    if origin_thread and not thread_id:\n-        logger.warning(\n-            \"Job '%s': origin has thread_id=%s but delivery target lost it \"\n-            \"(deliver=%s, target=%s)\",\n-            job[\"id\"], origin_thread, job.get(\"deliver\", \"local\"), target,\n-        )\n-    elif thread_id:\n-        logger.debug(\n-            \"Job '%s': delivering to %s:%s thread_id=%s\",\n-            job[\"id\"], platform_name, chat_id, thread_id,\n-        )\n-\n     from tools.send_message_tool import _send_to_platform\n     from gateway.config import load_gateway_config, Platform\n ","@@ -258,24 +290,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n         \"bluebubbles\": Platform.BLUEBUBBLES,\n         \"qqbot\": Platform.QQBOT,\n     }\n-    platform = platform_map.get(platform_name.lower())\n-    if not platform:\n-        msg = f\"unknown platform '{platform_name}'\"\n-        logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n-        return msg\n-\n-    try:\n-        config = load_gateway_config()\n-    except Exception as e:\n-        msg = f\"failed to load gateway config: {e}\"\n-        logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n-        return msg\n-\n-    pconfig = config.platforms.get(platform)\n-    if not pconfig or not pconfig.enabled:\n-        msg = f\"platform '{platform_name}' not configured/enabled\"\n-        logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n-        return msg\n \n     # Optionally wrap the content with a header/footer so the user knows this\n     # is a cron delivery.  Wrapping is on by default; set cron.wrap_response: false","@@ -304,67 +318,117 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option\n     from gateway.platforms.base import BasePlatformAdapter\n     media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)\n \n-    # Prefer the live adapter when the gateway is running — this supports E2EE\n-    # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.\n-    runtime_adapter = (adapters or {}).get(platform)\n-    if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n-        send_metadata = {\"thread_id\": thread_id} if thread_id else None\n-        try:\n-            # Send cleaned text (MEDIA tags stripped) — not the raw content\n-            text_to_send = cleaned_delivery_content.strip()\n-            adapter_ok = True\n-            if text_to_send:\n-                future = asyncio.run_coroutine_threadsafe(\n-                    runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata),\n-                    loop,\n-                )\n-                send_result = future.result(timeout=60)\n-                if send_result and not getattr(send_result, \"success\", True):\n-                    err = getattr(send_result, \"error\", \"unknown\")\n-                    logger.warning(\n-                        \"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone\",\n-                        job[\"id\"], platform_name, chat_id, err,\n-                    )\n-                    adapter_ok = False  # fall through to standalone path\n+    try:\n+        config = load_gateway_config()\n+    except Exception as e:\n+        msg = f\"failed to load gateway config: {e}\"\n+        logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n+        return msg\n \n-            # Send extracted media files as native attachments via the live adapter\n-            if adapter_ok and media_files:\n-                _send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)\n+    delivery_errors = []\n \n-            if adapter_ok:\n-                logger.info(\"Job '%s': delivered to %s:%s via live adapter\", job[\"id\"], platform_name, chat_id)\n-                return None\n-        except Exception as e:\n+    for target in targets:\n+        platform_name = target[\"platform\"]\n+        chat_id = target[\"chat_id\"]\n+        thread_id = target.get(\"thread_id\")\n+\n+        # Diagnostic: log thread_id for topic-aware delivery debugging\n+        origin = job.get(\"origin\") or {}\n+        origin_thread = origin.get(\"thread_id\")\n+        if origin_thread and not thread_id:\n             logger.warning(\n-                \"Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone\",\n-                job[\"id\"], platform_name, chat_id, e,\n+                \"Job '%s': origin has thread_id=%s but delivery target lost it \"\n+                \"(deliver=%s, target=%s)\",\n+                job[\"id\"], origin_thread, job.get(\"deliver\", \"local\"), target,\n+            )\n+        elif thread_id:\n+            logger.debug(\n+                \"Job '%s': delivering to %s:%s thread_id=%s\",\n+                job[\"id\"], platform_name, chat_id, thread_id,\n             )\n \n-    # Standalone path: run the async send in a fresh event loop (safe from any thread)\n-    coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)\n-    try:\n-        result = asyncio.run(coro)\n-    except RuntimeError:\n-        # asyncio.run() checks for a running loop before awaiting the coroutine;\n-        # when it raises, the original coro was never started — close it to\n-        # prevent \"coroutine was never awaited\" RuntimeWarning, then retry in a\n-        # fresh thread that has no running loop.\n-        coro.close()\n-        import concurrent.futures\n-        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:\n-            future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))\n-            result = future.result(timeout=30)\n-    except Exception as e:\n-        msg = f\"delivery to {platform_name}:{chat_id} failed: {e}\"\n-        logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n-        return msg\n+        platform = platform_map.get(platform_name.lower())\n+        if not platform:\n+            msg = f\"unknown platform '{platform_name}'\"\n+            logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+            delivery_errors.append(msg)\n+            continue\n \n-    if result and result.get(\"error\"):\n-        msg = f\"delivery error: {result['error']}\"\n-        logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n-        return msg\n+        # Prefer the live adapter when the gateway is running — this supports E2EE\n+        # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.\n+        runtime_adapter = (adapters or {}).get(platform)\n+        delivered = False\n+        if runtime_adapter is not None and loop is not None and getattr(loop, \"is_running\", lambda: False)():\n+            send_metadata = {\"thread_id\": thread_id} if thread_id else None\n+            try:\n+                # Send cleaned text (MEDIA tags stripped) — not the raw content\n+                text_to_send = cleaned_delivery_content.strip()\n+                adapter_ok = True\n+                if text_to_send:\n+                    future = asyncio.run_coroutine_threadsafe(\n+                        runtime_adapter.send(chat_id, text_to_send, metadata=send_metadata),\n+                        loop,\n+                    )\n+                    send_result = future.result(timeout=60)\n+                    if send_result and not getattr(send_result, \"success\", True):\n+                        err = getattr(send_result, \"error\", \"unknown\")\n+                        logger.warning(\n+                            \"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone\",\n+                            job[\"id\"], platform_name, chat_id, err,\n+                        )\n+                        adapter_ok = False  # fall through to standalone path\n+\n+                # Send extracted media files as native attachments via the live adapter\n+                if adapter_ok and media_files:\n+                    _send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)\n+\n+                if adapter_ok:\n+                    logger.info(\"Job '%s': delivered to %s:%s via live adapter\", job[\"id\"], platform_name, chat_id)\n+                    delivered = True\n+            except Exception as e:\n+                logger.warning(\n+                    \"Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone\",\n+                    job[\"id\"], platform_name, chat_id, e,\n+                )\n+\n+        if not delivered:\n+            pconfig = config.platforms.get(platform)\n+            if not pconfig or not pconfig.enabled:\n+                msg = f\"platform '{platform_name}' not configured/enabled\"\n+                logger.warning(\"Job '%s': %s\", job[\"id\"], msg)\n+                delivery_errors.append(msg)\n+                continue\n+\n+            # Standalone path: run the async send in a fresh event loop (safe from any thread)\n+            coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)\n+            try:\n+                result = asyncio.run(coro)\n+            except RuntimeError:\n+                # asyncio.run() checks for a running loop before awaiting the coroutine;\n+                # when it raises, the original coro was never started — close it to\n+                # prevent \"coroutine was never awaited\" RuntimeWarning, then retry in a\n+                # fresh thread that has no running loop.\n+                coro.close()\n+                import concurrent.futures\n+                with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:\n+                    future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))\n+                    result = future.result(timeout=30)\n+            except Exception as e:\n+                msg = f\"delivery to {platform_name}:{chat_id} failed: {e}\"\n+                logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n+                delivery_errors.append(msg)\n+                continue\n+\n+            if result and result.get(\"error\"):\n+                msg = f\"delivery error: {result['error']}\"\n+                logger.error(\"Job '%s': %s\", job[\"id\"], msg)\n+                delivery_errors.append(msg)\n+                continue\n+\n+            logger.info(\"Job '%s': delivered to %s:%s\", job[\"id\"], platform_name, chat_id)\n \n-    logger.info(\"Job '%s': delivered to %s:%s\", job[\"id\"], platform_name, chat_id)\n+    if delivery_errors:\n+        return \"; \".join(delivery_errors)\n     return None\n \n "]},{"file":"gateway/platforms/weixin.py","hunks":["@@ -96,6 +96,8 @@\n MEDIA_FILE = 3\n MEDIA_VOICE = 4\n \n+_LIVE_ADAPTERS: Dict[str, Any] = {}\n+\n ITEM_TEXT = 1\n ITEM_IMAGE = 2\n ITEM_VOICE = 3","@@ -1052,7 +1054,8 @@ def __init__(self, config: PlatformConfig):\n         self._hermes_home = hermes_home\n         self._token_store = [redacted:secret-assignment]\n         self._typing_cache = TypingTicketCache()\n-        self._session: Optional[aiohttp.ClientSession] = None\n+        self._poll_session: Optional[aiohttp.ClientSession] = None\n+        self._send_session: Optional[aiohttp.ClientSession] = None\n         self._poll_task: Optional[asyncio.Task] = None\n         self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS)\n ","@@ -1127,14 +1130,17 @@ async def connect(self) -> bool:\n         except Exception as exc:\n             logger.debug(\"[%s] Token lock unavailable (non-fatal): %s\", self.name, exc)\n \n-        self._session = aiohttp.ClientSession(trust_env=True)\n+        self._poll_session = aiohttp.ClientSession(trust_env=True)\n+        self._send_session = aiohttp.ClientSession(trust_env=True)\n         self._token_store.restore(self._account_id)\n         self._poll_task = asyncio.create_task(self._poll_loop(), name=\"weixin-poll\")\n         self._mark_connected()\n+        _LIVE_ADAPTERS[self._token] = self\n         logger.info(\"[%s] Connected account=%s base=%s\", self.name, _safe_id(self._account_id), self._base_url)\n         return True\n \n     async def disconnect(self) -> None:\n+        _LIVE_ADAPTERS.pop(self._token, None)\n         self._running = False\n         if self._poll_task and not self._poll_task.done():\n             self._poll_task.cancel()","@@ -1143,15 +1149,18 @@ async def disconnect(self) -> None:\n             except asyncio.CancelledError:\n                 pass\n         self._poll_task = None\n-        if self._session and not self._session.closed:\n-            await self._session.close()\n-        self._session = None\n+        if self._poll_session and not self._poll_session.closed:\n+            await self._poll_session.close()\n+        self._poll_session = None\n+        if self._send_session and not self._send_session.closed:\n+            await self._send_session.close()\n+        self._send_session = None\n         self._release_platform_lock()\n         self._mark_disconnected()\n         logger.info(\"[%s] Disconnected\", self.name)\n \n     async def _poll_loop(self) -> None:\n-        assert self._session is not None\n+        assert self._poll_session is not None\n         sync_buf = _load_sync_buf(self._hermes_home, self._account_id)\n         timeout_ms = LONG_POLL_TIMEOUT_MS\n         consecutive_failures = 0","@@ -1159,7 +1168,7 @@ async def _poll_loop(self) -> None:\n         while self._running:\n             try:\n                 response = await _get_updates(\n-                    self._session,\n+                    self._poll_session,\n                     base_url=self._base_url,\n                     token=[redacted:secret-assignment]\n                     sync_buf=sync_buf,","@@ -1216,7 +1225,7 @@ async def _process_message_safe(self, message: Dict[str, Any]) -> None:\n             logger.error(\"[%s] unhandled inbound error from=%s: %s\", self.name, _safe_id(message.get(\"from_user_id\")), exc, exc_info=True)\n \n     async def _process_message(self, message: Dict[str, Any]) -> None:\n-        assert self._session is not None\n+        assert self._poll_session is not None\n         sender_id = str(message.get(\"from_user_id\") or \"\").strip()\n         if not sender_id:\n             return","@@ -1309,7 +1318,7 @@ async def _download_image(self, item: Dict[str, Any]) -> Optional[str]:\n         media = _media_reference(item, \"image_item\")\n         try:\n             data = await _download_and_decrypt_media(\n-                self._session,\n+                self._poll_session,\n                 cdn_base_url=self._cdn_base_url,\n                 encrypted_query_param=media.get(\"encrypt_query_param\"),\n                 aes_key_b64=(item.get(\"image_item\") or {}).get(\"aeskey\")","@@ -1327,7 +1336,7 @@ async def _download_video(self, item: Dict[str, Any]) -> Optional[str]:\n         media = _media_reference(item, \"video_item\")\n         try:\n             data = await _download_and_decrypt_media(\n-                self._session,\n+                self._poll_session,\n                 cdn_base_url=self._cdn_base_url,\n                 encrypted_query_param=media.get(\"encrypt_query_param\"),\n                 aes_key_b64=media.get(\"aes_key\"),","@@ -1346,7 +1355,7 @@ async def _download_file(self, item: Dict[str, Any]) -> Tuple[Optional[str], str\n         mime = _mime_from_filename(filename)\n         try:\n             data = await _download_and_decrypt_media(\n-                self._session,\n+                self._poll_session,\n                 cdn_base_url=self._cdn_base_url,\n                 encrypted_query_param=media.get(\"encrypt_query_param\"),\n                 aes_key_b64=media.get(\"aes_key\"),","@@ -1365,7 +1374,7 @@ async def _download_voice(self, item: Dict[str, Any]) -> Optional[str]:\n             return None\n         try:\n             data = await _download_and_decrypt_media(\n-                self._session,\n+                self._poll_session,\n                 cdn_base_url=self._cdn_base_url,\n                 encrypted_query_param=media.get(\"encrypt_query_param\"),\n                 aes_key_b64=media.get(\"aes_key\"),","@@ -1378,13 +1387,13 @@ async def _download_voice(self, item: Dict[str, Any]) -> Optional[str]:\n             return None\n \n     async def _maybe_fetch_typing_ticket(self, user_id: str, context_token: [redacted:secret-assignment] -> None:\n-        if not self._session or not self._token:\n+        if not self._poll_session or not self._token:\n             return\n         if self._typing_cache.get(user_id):\n             return\n         try:\n             response = await _get_config(\n-                self._session,\n+                self._poll_session,\n                 base_url=self._base_url,\n                 token=[redacted:secret-assignment]\n                 user_id=user_id,","@@ -1414,7 +1423,7 @@ async def _send_text_chunk(\n         for attempt in range(self._send_chunk_retries + 1):\n             try:\n                 await _send_message(\n-                    self._session,\n+                    self._send_session,\n                     base_url=self._base_url,\n                     token=[redacted:secret-assignment]\n                     to=chat_id,","@@ -1449,7 +1458,7 @@ async def send(\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return SendResult(success=False, error=\"Not connected\")\n         context_token = [redacted:secret-assignment] chat_id)\n         last_message_id: Optional[str] = None","@@ -1508,14 +1517,14 @@ async def _deliver_media(path: str, is_voice: bool = False) -> None:\n             return SendResult(success=False, error=str(exc))\n \n     async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return\n         typing_ticket = self._typing_cache.get(chat_id)\n         if not typing_ticket:\n             return\n         try:\n             await _send_typing(\n-                self._session,\n+                self._send_session,\n                 base_url=self._base_url,\n                 token=[redacted:secret-assignment]\n                 to_user_id=chat_id,","@@ -1526,14 +1535,14 @@ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = N\n             logger.debug(\"[%s] typing start failed for %s: %s\", self.name, _safe_id(chat_id), exc)\n \n     async def stop_typing(self, chat_id: str) -> None:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return\n         typing_ticket = self._typing_cache.get(chat_id)\n         if not typing_ticket:\n             return\n         try:\n             await _send_typing(\n-                self._session,\n+                self._send_session,\n                 base_url=self._base_url,\n                 token=[redacted:secret-assignment]\n                 to_user_id=chat_id,","@@ -1585,7 +1594,7 @@ async def send_document(\n         caption: str = \"\",\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return SendResult(success=False, error=\"Not connected\")\n         try:\n             message_id = await self._send_file(chat_id, file_path, caption)","@@ -1602,7 +1611,7 @@ async def send_video(\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return SendResult(success=False, error=\"Not connected\")\n         try:\n             message_id = await self._send_file(chat_id, video_path, caption or \"\")","@@ -1619,7 +1628,7 @@ async def send_voice(\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        if not self._session or not self._token:\n+        if not self._send_session or not self._token:\n             return SendResult(success=False, error=\"Not connected\")\n \n         # Native outbound Weixin voice bubbles are not proven-working in the","@@ -1644,8 +1653,8 @@ async def _download_remote_media(self, url: str) -> str:\n         if not is_safe_url(url):\n             raise ValueError(f\"Blocked unsafe URL (SSRF protection): {url}\")\n \n-        assert self._session is not None\n-        async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:\n+        assert self._send_session is not None\n+        async with self._send_session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:\n             response.raise_for_status()\n             data = await response.read()\n             suffix = Path(url.split(\"?\", 1)[0]).suffix or \".bin\"","@@ -1660,7 +1669,7 @@ async def _send_file(\n         caption: str,\n         force_file_attachment: bool = False,\n     ) -> str:\n-        assert self._session is not None and self._token is not None\n+        assert self._send_session is not None and self._token is not None\n         plaintext = Path(path).read_bytes()\n         media_type, item_builder = self._outbound_media_builder(path, force_file_attachment=force_file_attachment)\n         filekey = secrets.token_hex(16)","@@ -1668,7 +1677,7 @@ async def _send_file(\n         rawsize = len(plaintext)\n         rawfilemd5 = hashlib.md5(plaintext).hexdigest()\n         upload_response = await _get_upload_url(\n-            self._session,\n+            self._send_session,\n             base_url=self._base_url,\n             token=[redacted:secret-assignment]\n             to_user_id=chat_id,","@@ -1694,7 +1703,7 @@ async def _send_file(\n             raise RuntimeError(f\"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}\")\n \n         encrypted_query_param = await _upload_ciphertext(\n-            self._session,\n+            self._send_session,\n             ciphertext=ciphertext,\n             upload_url=upload_url,\n         )","@@ -1722,7 +1731,7 @@ async def _send_file(\n         if caption:\n             last_message_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n             await _send_message(\n-                self._session,\n+                self._send_session,\n                 base_url=self._base_url,\n                 token=[redacted:secret-assignment]\n                 to=chat_id,","@@ -1733,7 +1742,7 @@ async def _send_file(\n \n         last_message_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n         await _api_post(\n-            self._session,\n+            self._send_session,\n             base_url=self._base_url,\n             endpoint=EP_SEND_MESSAGE,\n             payload={","@@ -1857,6 +1866,33 @@ async def send_weixin_direct(\n     token_store.restore(account_id)\n     context_token = [redacted:secret-assignment] chat_id)\n \n+    live_adapter = _LIVE_ADAPTERS.get(resolved_token)\n+    send_session = getattr(live_adapter, '_send_session', None)\n+    if live_adapter is not None and send_session is not None and not send_session.closed:\n+        last_result: Optional[SendResult] = None\n+        cleaned = live_adapter.format_message(message)\n+        if cleaned:\n+            last_result = await live_adapter.send(chat_id, cleaned)\n+            if not last_result.success:\n+                return {\"error\": f\"Weixin send failed: {last_result.error}\"}\n+\n+        for media_path, _is_voice in media_files or []:\n+            ext = Path(media_path).suffix.lower()\n+            if ext in {\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".bmp\"}:\n+                last_result = await live_adapter.send_image_file(chat_id, media_path)\n+            else:\n+                last_result = await live_adapter.send_document(chat_id, media_path)\n+            if not last_result.success:\n+                return {\"error\": f\"Weixin media send failed: {last_result.error}\"}\n+\n+        return {\n+            \"success\": True,\n+            \"platform\": \"weixin\",\n+            \"chat_id\": chat_id,\n+            \"message_id\": last_result.message_id if last_result else None,\n+            \"context_token_used\": bool(context_token),\n+        }\n+\n     async with aiohttp.ClientSession(trust_env=True) as session:\n         adapter = WeixinAdapter(\n             PlatformConfig(","@@ -1870,6 +1906,7 @@ async def send_weixin_direct(\n                 },\n             )\n         )\n+        adapter._send_session = session\n         adapter._session = session\n         adapter._token = [redacted:secret-assignment]\n         adapter._account_id = account_id"]},{"file":"scripts/release.py","hunks":["@@ -242,6 +242,8 @@\n     \"[redacted:email]\": \"imink\",\n     \"[redacted:email]\": \"hedgeho9X\",\n     \"[redacted:email]\": \"bernylinville\",\n+    \"[redacted:email]\": \"briandevans\",\n+    \"[redacted:email]\": \"LLQWQ\",\n }\n \n "]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -64,6 +64,60 @@ def test_origin_delivery_preserves_thread_id(self):\n             \"thread_id\": \"17585\",\n         }\n \n+    @pytest.mark.parametrize(\n+        (\"platform\", \"env_var\", \"chat_id\"),\n+        [\n+            (\"matrix\", \"MATRIX_HOME_ROOM\", \"!bot-room:example.org\"),\n+            (\"signal\", \"SIGNAL_HOME_CHANNEL\", \"+15551234567\"),\n+            (\"mattermost\", \"MATTERMOST_HOME_CHANNEL\", \"team-town-square\"),\n+            (\"sms\", \"SMS_HOME_CHANNEL\", \"+15557654321\"),\n+            (\"email\", \"EMAIL_HOME_ADDRESS\", \"[redacted:email]\"),\n+            (\"dingtalk\", \"DINGTALK_HOME_CHANNEL\", \"cidNNN\"),\n+            (\"feishu\", \"FEISHU_HOME_CHANNEL\", \"oc_home\"),\n+            (\"wecom\", \"WECOM_HOME_CHANNEL\", \"wecom-home\"),\n+            (\"weixin\", \"WEIXIN_HOME_CHANNEL\", \"wxid_home\"),\n+            (\"qqbot\", \"QQ_HOME_CHANNEL\", \"group-openid-home\"),\n+        ],\n+    )\n+    def test_origin_delivery_without_origin_falls_back_to_supported_home_channels(\n+        self, monkeypatch, platform, env_var, chat_id\n+    ):\n+        for fallback_env in (\n+            \"MATRIX_HOME_ROOM\",\n+            \"MATRIX_HOME_CHANNEL\",\n+            \"TELEGRAM_HOME_CHANNEL\",\n+            \"DISCORD_HOME_CHANNEL\",\n+            \"SLACK_HOME_CHANNEL\",\n+            \"SIGNAL_HOME_CHANNEL\",\n+            \"MATTERMOST_HOME_CHANNEL\",\n+            \"SMS_HOME_CHANNEL\",\n+            \"EMAIL_HOME_ADDRESS\",\n+            \"DINGTALK_HOME_CHANNEL\",\n+            \"BLUEBUBBLES_HOME_CHANNEL\",\n+            \"FEISHU_HOME_CHANNEL\",\n+            \"WECOM_HOME_CHANNEL\",\n+            \"WEIXIN_HOME_CHANNEL\",\n+            \"QQ_HOME_CHANNEL\",\n+        ):\n+            monkeypatch.delenv(fallback_env, raising=False)\n+        monkeypatch.setenv(env_var, chat_id)\n+\n+        assert _resolve_delivery_target({\"deliver\": \"origin\"}) == {\n+            \"platform\": platform,\n+            \"chat_id\": chat_id,\n+            \"thread_id\": None,\n+        }\n+\n+    def test_bare_matrix_delivery_uses_matrix_home_room(self, monkeypatch):\n+        monkeypatch.delenv(\"MATRIX_HOME_CHANNEL\", raising=False)\n+        monkeypatch.setenv(\"MATRIX_HOME_ROOM\", \"!room123:example.org\")\n+\n+        assert _resolve_delivery_target({\"deliver\": \"matrix\"}) == {\n+            \"platform\": \"matrix\",\n+            \"chat_id\": \"!room123:example.org\",\n+            \"thread_id\": None,\n+        }\n+\n     def test_explicit_telegram_topic_target_with_thread_id(self):\n         \"\"\"deliver: 'telegram:chat_id:thread_id' parses correctly.\"\"\"\n         job = {"]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -311,6 +311,7 @@ class TestWeixinChunkDelivery:\n     def _connected_adapter(self) -> WeixinAdapter:\n         adapter = _make_adapter()\n         adapter._session = object()\n+        adapter._send_session = adapter._session\n         adapter._token = \"[redacted:secret-assignment]\"\n         adapter._base_url = \"https://weixin.example.com\"\n         adapter._token_store.get = lambda account_id, chat_id: \"ctx-token\"","@@ -420,6 +421,7 @@ def test_split_text_returns_empty_list_for_empty_string_split_per_line(self):\n     def test_send_empty_content_does_not_call_send_message(self, send_message_mock):\n         adapter = _make_adapter()\n         adapter._session = object()\n+        adapter._send_session = adapter._session\n         adapter._token = \"[redacted:secret-assignment]\"\n         adapter._base_url = \"https://weixin.example.com\"\n         adapter._token_store.get = lambda account_id, chat_id: \"ctx-token\"","@@ -525,6 +527,7 @@ def test_send_image_file_uses_image_path_parameter(self, send_document_mock):\n         \"\"\"Verify send_image_file accepts image_path and forwards to send_document.\"\"\"\n         adapter = _make_adapter()\n         adapter._session = object()\n+        adapter._send_session = adapter._session\n         adapter._token = \"[redacted:secret-assignment]\"\n \n         send_document_mock.return_value = weixin.SendResult(success=True, message_id=\"test-id\")","@@ -552,6 +555,7 @@ def test_send_image_file_works_without_optional_params(self, send_document_mock)\n         \"\"\"Verify send_image_file works with minimal required params.\"\"\"\n         adapter = _make_adapter()\n         adapter._session = object()\n+        adapter._send_session = adapter._session\n         adapter._token = \"[redacted:secret-assignment]\"\n \n         send_document_mock.return_value = weixin.SendResult(success=True, message_id=\"test-id\")","@@ -576,6 +580,7 @@ class TestWeixinVoiceSending:\n     def _connected_adapter(self) -> WeixinAdapter:\n         adapter = _make_adapter()\n         adapter._session = object()\n+        adapter._send_session = adapter._session\n         adapter._token = \"[redacted:secret-assignment]\"\n         adapter._base_url = \"https://weixin.example.com\"\n         adapter._token_store.get = lambda account_id, chat_id: \"ctx-token\""]},{"file":"tools/send_message_tool.py","hunks":["@@ -215,7 +215,27 @@ def _handle_send(args):\n \n     pconfig = config.platforms.get(platform)\n     if not pconfig or not pconfig.enabled:\n-        return tool_error(f\"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.\")\n+        # Weixin can be configured purely via .env; synthesize a pconfig so\n+        # send_message and cron delivery work without a gateway.yaml entry.\n+        if platform_name == \"weixin\":\n+            import os\n+            wx_token = [redacted:secret-assignment]\"WEIXIN_TOKEN\", \"\").strip()\n+            wx_account = os.getenv(\"WEIXIN_ACCOUNT_ID\", \"\").strip()\n+            if wx_token and wx_account:\n+                from gateway.config import PlatformConfig\n+                pconfig = PlatformConfig(\n+                    enabled=True,\n+                    token=[redacted:secret-assignment]\n+                    extra={\n+                        \"account_id\": wx_account,\n+                        \"base_url\": os.getenv(\"WEIXIN_BASE_URL\", \"\").strip(),\n+                        \"cdn_base_url\": os.getenv(\"WEIXIN_CDN_BASE_URL\", \"\").strip(),\n+                    },\n+                )\n+            else:\n+                return tool_error(f\"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.\")\n+        else:\n+            return tool_error(f\"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.\")\n \n     from gateway.platforms.base import BasePlatformAdapter\n ","@@ -225,6 +245,12 @@ def _handle_send(args):\n     used_home_channel = False\n     if not chat_id:\n         home = config.get_home_channel(platform)\n+        if not home and platform_name == \"weixin\":\n+            import os\n+            wx_home = os.getenv(\"WEIXIN_HOME_CHANNEL\", \"\").strip()\n+            if wx_home:\n+                from gateway.config import HomeChannel\n+                home = HomeChannel(platform=platform, chat_id=wx_home, name=\"Weixin Home\")\n         if home:\n             chat_id = home.chat_id\n             used_home_channel = True\n"]}]},{"kind":"pr","number":12223,"title":"fix: report expired Weixin sessions","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":13422,"title":"fix(tools): reuse persistent loop in Home Assistant async bridge","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53315","number":53315}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":13520,"title":"fix(weixin): avoid cross-loop live session reuse","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a22465e07ab4b71019f711e7a6463f6590c50742","number":0},{"kind":"pr","value":"#13350","number":13350},{"kind":"pr","value":"#12810","number":12810}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15911,"title":"fix(weixin): avoid cross-event-loop ClientSession reuse in send_weixin_direct","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a22465e07ab4b71019f711e7a6463f6590c50742","number":0},{"kind":"pr","value":"#13520","number":13520},{"kind":"pr","value":"#14481","number":14481},{"kind":"pr","value":"#14873","number":14873}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16573,"title":"fix(model-tools): reuse persistent async bridge loop","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#8043","number":8043}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":17545,"title":"fix(weixin): route send_message media uploads through a fresh aiohttp session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a22465e07","number":0},{"kind":"commit","value":"a22465e07ab4b71019f711e7a6463f6590c50742","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":18040,"title":"fix(weixin): guard against event-loop mismatch in send_weixin_direct","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12810","number":12810}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":18714,"title":"fix: cross-channel send_message failures (WeChat event loop + Telegram proxy)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12370","number":12370}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21196,"title":"fix(weixin): replace aiohttp ClientTimeout with asyncio.wait_for (salvage #19050)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26916,"title":"fix(weixin): route cross-process send_message through gateway API server","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5ca52bae5b3faaf124f5d81d1b7562f5628edfbf","number":0},{"kind":"commit","value":"e105b7ac93adaa4c2a1c8124fcd73808742a5620","number":0},{"kind":"pr","value":"#14452","number":14452}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35239,"title":"fix(weixin): deduplicate session expired error log in poll loop","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":37599,"title":"fix(mattermost): use asyncio.wait_for instead of aiohttp ClientTimeout (#37005)","url":null,"state":"open","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38525,"title":"fix(tools): wrap coroutines in create_task() in _run_async() to fix aiohttp timeout error","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":39877,"title":"fix(mattermost): avoid aiohttp timeout context in sends","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61502,"title":"fix(plugins): use asyncio.wait_for instead of ClientTimeout in Matrix standalone send","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#80838","number":80838},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#61495","number":61495}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61503,"title":"fix(cron,desktop): pass gateway loop to run_one_job for Matrix cron delivery; discover UGit git binary (#61495, #61494)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":63586,"title":"fix(cron): deliver manual runs on gateway loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#80838","number":80838},{"kind":"pr","value":"#80807","number":80807},{"kind":"pr","value":"#61495","number":61495}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":71132,"title":"fix(cron): deliver manual runs on profile-owned gateway loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"51013268c","number":0},{"kind":"commit","value":"51013268cf9b0ccc1078c8534cafef48eab145e7","number":0},{"kind":"commit","value":"6777a6bd67ccabd92455845736b17150a96c6a14","number":0},{"kind":"commit","value":"da8c209e80fa","number":0},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#61503","number":61503},{"kind":"pr","value":"#7696","number":7696},{"kind":"pr","value":"#7958","number":7958},{"kind":"pr","value":"#17516","number":17516},{"kind":"pr","value":"#16612","number":16612},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#32448","number":32448},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#32427","number":32427},{"kind":"pr","value":"#33980","number":33980},{"kind":"pr","value":"#34173","number":34173},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#34255","number":34255},{"kind":"pr","value":"#34483","number":34483},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#37431","number":37431},{"kind":"pr","value":"#37859","number":37859},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#41130","number":41130},{"kind":"pr","value":"#41037","number":41037},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#41167","number":41167},{"kind":"pr","value":"#41363","number":41363},{"kind":"pr","value":"#41269","number":41269},{"kind":"pr","value":"#41037","number":41037},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#41307","number":41307},{"kind":"pr","value":"#41037","number":41037},{"kind":"pr","value":"#50062","number":50062},{"kind":"pr","value":"#41363","number":41363},{"kind":"pr","value":"#41167","number":41167},{"kind":"pr","value":"#43864","number":43864},{"kind":"pr","value":"#43864","number":43864},{"kind":"pr","value":"#41037","number":41037},{"kind":"pr","value":"#49792","number":49792},{"kind":"pr","value":"#49695","number":49695},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#41037","number":41037},{"kind":"pr","value":"#52720","number":52720},{"kind":"pr","value":"#52705","number":52705},{"kind":"pr","value":"#53246","number":53246},{"kind":"pr","value":"#34120","number":34120},{"kind":"pr","value":"#34255","number":34255},{"kind":"pr","value":"#53395","number":53395},{"kind":"pr","value":"#52705","number":52705},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#57331","number":57331},{"kind":"pr","value":"#57360","number":57360},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#57689","number":57689},{"kind":"pr","value":"#61502","number":61502},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#61503","number":61503},{"kind":"pr","value":"#61494","number":61494},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#63774","number":63774},{"kind":"pr","value":"#52705","number":52705},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#32448","number":32448},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#34173","number":34173},{"kind":"pr","value":"#34483","number":34483},{"kind":"pr","value":"#37859","number":37859},{"kind":"pr","value":"#32804","number":32804},{"kind":"pr","value":"#34255","number":34255},{"kind":"pr","value":"#53246","number":53246},{"kind":"pr","value":"#41130","number":41130},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#41269","number":41269},{"kind":"pr","value":"#50025","number":50025},{"kind":"pr","value":"#41167","number":41167},{"kind":"pr","value":"#41363","number":41363},{"kind":"pr","value":"#43864","number":43864},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#57360","number":57360},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#61503","number":61503},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#61502","number":61502},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#57360","number":57360},{"kind":"pr","value":"#57342","number":57342},{"kind":"pr","value":"#43864","number":43864},{"kind":"pr","value":"#52720","number":52720},{"kind":"pr","value":"#53395","number":53395},{"kind":"pr","value":"#57689","number":57689},{"kind":"pr","value":"#61502","number":61502},{"kind":"pr","value":"#61503","number":61503},{"kind":"pr","value":"#71132","number":71132},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#61495","number":61495},{"kind":"pr","value":"#61503","number":61503},{"kind":"pr","value":"#63586","number":63586},{"kind":"pr","value":"#71132","number":71132}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":77378,"title":"fix(gateway): skip cross-loop live adapter in _send_via_adapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62956","number":62956},{"kind":"pr","value":"#62956","number":62956}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":80807,"title":"fix: dispatch cronjob(action='run') to the background like delegate_task","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/tools/test_cronjob_run_background.py","tools/cronjob_tools.py"]},{"kind":"pr","number":80838,"title":"fix(cron): gateway-loop delivery, per-run prompt context, Matrix standalone send — run-action sweep salvage","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","plugins/platforms/matrix/adapter.py","tests/cron/test_cron_kanban_env_isolation.py","tests/cron/test_cron_workdir.py","tests/cron/test_execution_ledger.py","tests/cron/test_parallel_pool.py","tests/cron/test_run_one_job.py","tests/cron/test_scheduler.py","tests/tools/test_cronjob_run_background.py","tests/tools/test_cronjob_run_immediate.py","tools/cronjob_tools.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}