{"repo":"NousResearch/hermes-agent","public_id":"11602","display_label":"Cron multi-target delivery and Weixin session fixes","summary":"These items cover adding multi-target and comma-separated delivery support to the cron module, plus fixes for Weixin session splitting to prevent message loss. A batch salvage item combines origin fallback and these two themes together.","subsystem":"cron","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11594},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-17T13:26:43+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"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":11602,"pr_number":11594,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":11602,"title":"feat(cron): support comma-separated multi-target deliver values","url":null,"state":"closed"},{"kind":"issue","number":13469,"title":"Cron: support multi-target delivery","url":null,"state":"closed"},{"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":11601,"title":"feat(cron): support comma-separated multi-target deliver values","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5ca52bae5","number":0},{"kind":"pr","value":"#11594","number":11594}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}