{"repo":"NousResearch/hermes-agent","public_id":"2557","display_label":"SimpleX Chat gateway adapter feature requests","summary":"These issues and pull requests all concern adding a SimpleX Chat platform adapter or plugin to the gateway. They represent related but potentially overlapping efforts toward the same integration goal.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":26232,"merged_at":"2026-05-15T08:41:30+00:00"},"resolution":{"state":"resolved","by":"pr-26232"},"freshness":{"state":"verified","last_verified_at":"2026-05-15T08:41:30+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":2557,"pr_number":26232,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-15T08:41:30+00:00"}],"members":[{"kind":"issue","number":2557,"title":"[Feature]: Add SimpleX Chat, Chat Adapter","url":null,"state":"closed"},{"kind":"pr","number":2558,"title":"feat: add SimpleX Chat gateway adapter (#2557)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26232","number":26232}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":26232,"title":"feat(gateway): add SimpleX Chat platform plugin (salvages #2558)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/simplex/__init__.py","plugins/platforms/simplex/adapter.py","plugins/platforms/simplex/plugin.yaml","scripts/release.py","tests/gateway/test_simplex_plugin.py","website/docs/user-guide/messaging/simplex.md","website/sidebars.ts"],"fix_hunks":[{"file":"plugins/platforms/simplex/__init__.py","hunks":["@@ -0,0 +1,3 @@\n+from .adapter import register\n+\n+__all__ = [\"register\"]"]},{"file":"plugins/platforms/simplex/adapter.py","hunks":["@@ -0,0 +1,746 @@\n+\"\"\"SimpleX Chat platform adapter (Hermes plugin).\n+\n+Connects to a simplex-chat daemon running in WebSocket mode.\n+Inbound messages arrive via a persistent WebSocket connection.\n+Outbound messages use the same WebSocket with JSON commands.\n+\n+This adapter ships as a Hermes platform plugin under\n+``plugins/platforms/simplex/``. The Hermes plugin loader scans the\n+directory at startup, calls ``register(ctx)``, and the platform\n+becomes available to ``gateway/run.py`` and ``tools/send_message_tool``\n+through the registry — no edits to core files are required.\n+\n+SimpleX chat daemon setup:\n+    simplex-chat -p 5225          # start daemon on port 5225\n+    # or via Docker:\n+    # docker run -p 5225:5225 simplexchat/simplex-chat-cli -p 5225\n+\n+Required environment variables:\n+    SIMPLEX_WS_URL             WebSocket URL of the daemon\n+                               (default: ws://127.0.0.1:5225)\n+\n+Optional environment variables:\n+    SIMPLEX_ALLOWED_USERS      Comma-separated contact IDs (allowlist)\n+    SIMPLEX_ALLOW_ALL_USERS    Set 'true' to allow all contacts\n+    SIMPLEX_HOME_CHANNEL       Default contact/group ID for cron delivery\n+    SIMPLEX_HOME_CHANNEL_NAME  Human label for the home channel\n+\n+The ``websockets`` Python package is imported lazily — the plugin is\n+discoverable and `hermes setup` can describe it even when websockets is\n+not installed. ``check_requirements()`` returns False until the package\n+is present, so the gateway will not attempt to instantiate the adapter.\n+\"\"\"\n+\n+import asyncio\n+import json\n+import logging\n+import os\n+import random\n+import time\n+from datetime import datetime, timezone\n+from typing import Any, Dict, List, Optional\n+\n+# Lazy import: BasePlatformAdapter and friends live in the main repo.\n+# Imported at module top because they're stdlib-only inside Hermes — no\n+# external dependency that would block the plugin from loading.\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+    SendResult,\n+    cache_image_from_bytes,\n+    cache_audio_from_bytes,\n+    cache_document_from_bytes,\n+)\n+\n+logger = logging.getLogger(__name__)\n+\n+# ---------------------------------------------------------------------------\n+# Constants\n+# ---------------------------------------------------------------------------\n+MAX_MESSAGE_LENGTH = 16_000  # SimpleX has no hard limit; keep chunking sane\n+TYPING_INTERVAL = 10.0\n+WS_RETRY_DELAY_INITIAL = 2.0\n+WS_RETRY_DELAY_MAX = 60.0\n+HEALTH_CHECK_INTERVAL = 30.0\n+HEALTH_CHECK_STALE_THRESHOLD = 120.0\n+\n+# Correlation ID prefix for requests we send so we can ignore our own echoes.\n+_CORR_PREFIX = \"hermes-\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _parse_comma_list(value: str) -> List[str]:\n+    \"\"\"Split a comma-separated string into a stripped list.\"\"\"\n+    return [v.strip() for v in value.split(\",\") if v.strip()]\n+\n+\n+def _guess_extension(data: bytes) -> str:\n+    \"\"\"Guess file extension from magic bytes.\"\"\"\n+    if data[:4] == b\"\\x89PNG\":\n+        return \".png\"\n+    if data[:2] == b\"\\xff\\xd8\":\n+        return \".jpg\"\n+    if data[:4] == b\"GIF8\":\n+        return \".gif\"\n+    if len(data) >= 12 and data[:4] == b\"RIFF\" and data[8:12] == b\"WEBP\":\n+        return \".webp\"\n+    if data[:4] == b\"%PDF\":\n+        return \".pdf\"\n+    if len(data) >= 8 and data[4:8] == b\"ftyp\":\n+        return \".mp4\"\n+    if data[:4] == b\"OggS\":\n+        return \".ogg\"\n+    if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:\n+        return \".mp3\"\n+    return \".bin\"\n+\n+\n+def _is_image_ext(ext: str) -> bool:\n+    return ext.lower() in (\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\")\n+\n+\n+def _is_audio_ext(ext: str) -> bool:\n+    return ext.lower() in (\".mp3\", \".wav\", \".ogg\", \".m4a\", \".aac\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# SimpleX Adapter\n+# ---------------------------------------------------------------------------\n+\n+class SimplexAdapter(BasePlatformAdapter):\n+    \"\"\"SimpleX Chat adapter using the simplex-chat daemon WebSocket API.\n+\n+    Instantiated by the ``adapter_factory`` passed to\n+    ``ctx.register_platform()`` in :func:`register`.\n+    \"\"\"\n+\n+    def __init__(self, config: PlatformConfig, **kwargs):\n+        platform = Platform(\"simplex\")\n+        super().__init__(config=config, platform=platform)\n+\n+        extra = getattr(config, \"extra\", {}) or {}\n+        self.ws_url = extra.get(\"ws_url\", \"ws://127.0.0.1:5225\").rstrip(\"/\")\n+\n+        # Running state\n+        self._ws = None  # websockets connection\n+        self._ws_task: Optional[asyncio.Task] = None\n+        self._health_task: Optional[asyncio.Task] = None\n+        self._typing_tasks: Dict[str, asyncio.Task] = {}\n+        self._running = False\n+        self._last_ws_activity = 0.0\n+\n+        # Track sent correlation IDs to filter echoes\n+        self._pending_corr_ids: set = set()\n+        self._max_pending_corr = 200\n+\n+        logger.info(\"SimpleX adapter initialized: url=%s\", self.ws_url)\n+\n+    # ------------------------------------------------------------------\n+    # Lifecycle\n+    # ------------------------------------------------------------------\n+\n+    async def connect(self) -> bool:\n+        \"\"\"Connect to the simplex-chat daemon and start the WebSocket listener.\"\"\"\n+        try:\n+            import websockets  # noqa: F401\n+        except ImportError:\n+            logger.error(\n+                \"SimpleX: 'websockets' package not installed. \"\n+                \"Run: pip install websockets\"\n+            )\n+            return False\n+\n+        if not self.ws_url:\n+            logger.error(\"SimpleX: SIMPLEX_WS_URL is required\")\n+            return False\n+\n+        # Quick connectivity check — try to open and immediately close\n+        try:\n+            import websockets as _wsclient\n+            async with _wsclient.connect(self.ws_url, open_timeout=10):\n+                pass\n+        except Exception as e:\n+            logger.error(\"SimpleX: cannot reach daemon at %s: %s\", self.ws_url, e)\n+            return False\n+\n+        self._running = True\n+        self._last_ws_activity = time.time()\n+        self._ws_task = asyncio.create_task(self._ws_listener())\n+        self._health_task = asyncio.create_task(self._health_monitor())\n+\n+        logger.info(\"SimpleX: connected to %s\", self.ws_url)\n+        return True\n+\n+    async def disconnect(self) -> None:\n+        \"\"\"Stop WebSocket listener and clean up.\"\"\"\n+        self._running = False\n+\n+        if self._ws_task:\n+            self._ws_task.cancel()\n+            try:\n+                await self._ws_task\n+            except asyncio.CancelledError:\n+                pass\n+\n+        if self._health_task:\n+            self._health_task.cancel()\n+            try:\n+                await self._health_task\n+            except asyncio.CancelledError:\n+                pass\n+\n+        for task in self._typing_tasks.values():\n+            task.cancel()\n+        self._typing_tasks.clear()\n+\n+        if self._ws:\n+            try:\n+                await self._ws.close()\n+            except Exception:\n+                pass\n+            self._ws = None\n+\n+        logger.info(\"SimpleX: disconnected\")\n+\n+    # ------------------------------------------------------------------\n+    # WebSocket listener\n+    # ------------------------------------------------------------------\n+\n+    async def _ws_listener(self) -> None:\n+        \"\"\"Maintain a persistent WebSocket connection to the daemon.\"\"\"\n+        import websockets as _wsclient\n+        import websockets as _wsexc\n+\n+        backoff = WS_RETRY_DELAY_INITIAL\n+\n+        while self._running:\n+            try:\n+                logger.debug(\"SimpleX WS: connecting to %s\", self.ws_url)\n+                async with _wsclient.connect(\n+                    self.ws_url,\n+                    ping_interval=20,\n+                    ping_timeout=20,\n+                ) as ws:\n+                    self._ws = ws\n+                    backoff = WS_RETRY_DELAY_INITIAL\n+                    self._last_ws_activity = time.time()\n+                    logger.info(\"SimpleX WS: connected\")\n+\n+                    async for raw in ws:\n+                        if not self._running:\n+                            break\n+                        self._last_ws_activity = time.time()\n+                        try:\n+                            msg = json.loads(raw)\n+                            await self._handle_event(msg)\n+                        except json.JSONDecodeError:\n+                            logger.debug(\"SimpleX WS: invalid JSON: %.100s\", raw)\n+                        except Exception:\n+                            logger.exception(\"SimpleX WS: error handling event\")\n+\n+            except asyncio.CancelledError:\n+                break\n+            except _wsexc.WebSocketException as e:\n+                if self._running:\n+                    logger.warning(\n+                        \"SimpleX WS: error: %s (reconnecting in %.0fs)\", e, backoff\n+                    )\n+            except Exception as e:\n+                if self._running:\n+                    logger.warning(\n+                        \"SimpleX WS: unexpected error: %s (reconnecting in %.0fs)\",\n+                        e, backoff,\n+                    )\n+            finally:\n+                self._ws = None\n+\n+            if self._running:\n+                jitter = backoff * 0.2 * random.random()\n+                await asyncio.sleep(backoff + jitter)\n+                backoff = min(backoff * 2, WS_RETRY_DELAY_MAX)\n+\n+    # ------------------------------------------------------------------\n+    # Health monitor\n+    # ------------------------------------------------------------------\n+\n+    async def _health_monitor(self) -> None:\n+        \"\"\"Force reconnect if the WebSocket has been idle too long.\"\"\"\n+        while self._running:\n+            await asyncio.sleep(HEALTH_CHECK_INTERVAL)\n+            if not self._running:\n+                break\n+\n+            elapsed = time.time() - self._last_ws_activity\n+            if elapsed > HEALTH_CHECK_STALE_THRESHOLD:\n+                logger.warning(\n+                    \"SimpleX: WS idle for %.0fs, forcing reconnect\", elapsed\n+                )\n+                self._last_ws_activity = time.time()\n+                if self._ws:\n+                    try:\n+                        await self._ws.close()\n+                    except Exception:\n+                        pass\n+\n+    # ------------------------------------------------------------------\n+    # Inbound event handling\n+    # ------------------------------------------------------------------\n+\n+    async def _handle_event(self, event: dict) -> None:\n+        \"\"\"Dispatch a daemon event to the appropriate handler.\"\"\"\n+        resp_type = event.get(\"type\") or event.get(\"resp\", {}).get(\"type\", \"\")\n+\n+        # Filter responses to our own commands (echoes)\n+        corr_id = event.get(\"corrId\", \"\")\n+        if corr_id and corr_id.startswith(_CORR_PREFIX):\n+            self._pending_corr_ids.discard(corr_id)\n+            return\n+\n+        if resp_type == \"newChatItem\":\n+            await self._handle_new_chat_item(event)\n+        elif resp_type == \"newChatItems\":\n+            # Batch variant — process each item\n+            items = event.get(\"chatItems\") or []\n+            for item_wrapper in items:\n+                await self._handle_new_chat_item(item_wrapper)\n+        # Ignore all other event types (delivery receipts, contact updates, etc.)\n+\n+    async def _handle_new_chat_item(self, wrapper: dict) -> None:\n+        \"\"\"Process a single newChatItem event into a MessageEvent.\"\"\"\n+        # The daemon wraps the chat item differently depending on version;\n+        # normalise both layouts.\n+        chat_info = wrapper.get(\"chatInfo\") or wrapper.get(\"chat\") or {}\n+        chat_item = wrapper.get(\"chatItem\") or wrapper.get(\"item\") or {}\n+\n+        # Only process messages (not calls, deleted items, etc.)\n+        item_content = chat_item.get(\"content\") or {}\n+        msg_content = item_content.get(\"msgContent\") or {}\n+        if not msg_content:\n+            return\n+\n+        # Filter out messages sent by us (direction == \"snd\")\n+        meta = chat_item.get(\"meta\") or {}\n+        direction = (meta.get(\"itemStatus\") or {}).get(\"type\", \"\")\n+        if direction in (\"sndSent\", \"sndSentDirect\", \"sndSentViaProxy\", \"sndNew\"):\n+            return\n+\n+        # Determine chat type and IDs\n+        chat_type_raw = chat_info.get(\"type\", \"\")\n+        is_group = chat_type_raw in (\"group\", \"groupInfo\")\n+\n+        if is_group:\n+            group_info = chat_info.get(\"groupInfo\") or chat_info.get(\"group\") or {}\n+            group_id = str(group_info.get(\"groupId\") or group_info.get(\"id\") or \"\")\n+            group_name = group_info.get(\"displayName\") or group_info.get(\"groupProfile\", {}).get(\"displayName\", \"\")\n+            chat_id = f\"group:{group_id}\" if group_id else \"\"\n+            chat_name = group_name\n+        else:\n+            contact_info = chat_info.get(\"contact\") or {}\n+            contact_id = str(contact_info.get(\"contactId\") or contact_info.get(\"id\") or \"\")\n+            contact_name = (\n+                contact_info.get(\"displayName\")\n+                or contact_info.get(\"localDisplayName\")\n+                or contact_id\n+            )\n+            chat_id = contact_id\n+            chat_name = contact_name\n+\n+        if not chat_id:\n+            logger.debug(\"SimpleX: ignoring event with no chat_id\")\n+            return\n+\n+        # Sender — for groups the message includes a chatItemMember sub-object\n+        member = chat_item.get(\"chatItemMember\") or {}\n+        if is_group and member:\n+            sender_id = str(member.get(\"memberId\") or member.get(\"id\") or chat_id)\n+            sender_name = (\n+                member.get(\"displayName\")\n+                or member.get(\"localDisplayName\")\n+                or sender_id\n+            )\n+        else:\n+            sender_id = chat_id\n+            sender_name = chat_name\n+\n+        # Extract text\n+        text = msg_content.get(\"text\") or \"\"\n+\n+        # Media attachments\n+        media_urls: List[str] = []\n+        media_types: List[str] = []\n+        file_info = chat_item.get(\"file\") or {}\n+        if file_info and file_info.get(\"fileStatus\") not in (\"cancelled\", \"error\"):\n+            file_id = file_info.get(\"fileId\")\n+            file_name = file_info.get(\"fileName\", \"file\")\n+            if file_id:\n+                try:\n+                    cached = await self._fetch_file(file_id, file_name)\n+                    if cached:\n+                        ext = cached.rsplit(\".\", 1)[-1]\n+                        if _is_image_ext(\".\" + ext):\n+                            media_types.append(\"image/\" + ext.replace(\"jpg\", \"jpeg\"))\n+                        elif _is_audio_ext(\".\" + ext):\n+                            media_types.append(\"audio/\" + ext)\n+                        else:\n+                            media_types.append(\"application/octet-stream\")\n+                        media_urls.append(cached)\n+                except Exception:\n+                    logger.exception(\"SimpleX: failed to fetch file %s\", file_id)\n+\n+        # Timestamp\n+        ts_str = meta.get(\"itemTs\") or meta.get(\"createdAt\") or \"\"\n+        try:\n+            timestamp = datetime.fromisoformat(ts_str.replace(\"Z\", \"+00:00\"))\n+        except (ValueError, AttributeError):\n+            timestamp = datetime.now(tz=timezone.utc)\n+\n+        # Build source\n+        source = self.build_source(\n+            chat_id=chat_id,\n+            chat_name=chat_name,\n+            chat_type=\"group\" if is_group else \"dm\",\n+            user_id=sender_id,\n+            user_name=sender_name,\n+        )\n+\n+        # Message type\n+        msg_type = MessageType.TEXT\n+        if media_types:\n+            if any(mt.startswith(\"audio/\") for mt in media_types):\n+                msg_type = MessageType.VOICE\n+            elif any(mt.startswith(\"image/\") for mt in media_types):\n+                msg_type = MessageType.PHOTO\n+\n+        event_obj = MessageEvent(\n+            source=source,\n+            text=text,\n+            message_type=msg_type,\n+            media_urls=media_urls,\n+            media_types=media_types,\n+            timestamp=timestamp,\n+            raw_message=wrapper,\n+        )\n+\n+        await self.handle_message(event_obj)\n+\n+    async def _fetch_file(self, file_id: Any, file_name: str) -> Optional[str]:\n+        \"\"\"Ask the daemon to receive and return a file attachment.\"\"\"\n+        # simplex-chat exposes `/api/v1/files/{fileId}` on an HTTP port\n+        # when started with --http-port. However, the canonical WebSocket API\n+        # does not have a direct binary download command; files are stored on\n+        # the local filesystem after the daemon accepts them.\n+        #\n+        # We request acceptance first, then read from the daemon's local path.\n+        corr_id = self._make_corr_id()\n+        cmd = {\n+            \"corrId\": corr_id,\n+            \"cmd\": f\"/freceive {file_id}\",\n+        }\n+        await self._send_ws(cmd)\n+        # The daemon will emit a chatItemUpdated event when the file lands;\n+        # for simplicity we just wait briefly and rely on the daemon's default path.\n+        await asyncio.sleep(2)\n+\n+        # simplex-chat stores received files in ~/Downloads or a configured path.\n+        # We try common locations.\n+        for search_dir in (\n+            os.path.expanduser(\"~/Downloads\"),\n+            os.path.expanduser(\"~/.simplex/files\"),\n+            \"/tmp/simplex_files\",\n+        ):\n+            candidate = os.path.join(search_dir, file_name)\n+            if os.path.exists(candidate):\n+                with open(candidate, \"rb\") as f:\n+                    data = f.read()\n+                ext = _guess_extension(data)\n+                if _is_image_ext(ext):\n+                    return cache_image_from_bytes(data, ext)\n+                elif _is_audio_ext(ext):\n+                    return cache_audio_from_bytes(data, ext)\n+                else:\n+                    return cache_document_from_bytes(data, file_name)\n+        return None\n+\n+    # ------------------------------------------------------------------\n+    # Outbound messages\n+    # ------------------------------------------------------------------\n+\n+    def _make_corr_id(self) -> str:\n+        \"\"\"Generate a unique correlation ID for a request.\"\"\"\n+        corr_id = f\"{_CORR_PREFIX}{int(time.time() * 1000)}-{random.randint(0, 9999)}\"\n+        self._pending_corr_ids.add(corr_id)\n+        if len(self._pending_corr_ids) > self._max_pending_corr:\n+            # Trim oldest — sets are unordered so just clear the oldest half\n+            to_remove = list(self._pending_corr_ids)[:self._max_pending_corr // 2]\n+            self._pending_corr_ids -= set(to_remove)\n+        return corr_id\n+\n+    async def _send_ws(self, payload: dict) -> None:\n+        \"\"\"Send a JSON payload over the WebSocket, queuing if not yet connected.\"\"\"\n+        import websockets as _wsexc\n+        ws = self._ws\n+        if not ws:\n+            logger.debug(\"SimpleX: WS not connected, dropping outbound command\")\n+            return\n+        try:\n+            await ws.send(json.dumps(payload))\n+        except _wsexc.ConnectionClosed:\n+            logger.warning(\"SimpleX: WS closed while sending\")\n+        except Exception as e:\n+            logger.warning(\"SimpleX: WS send error: %s\", e)\n+\n+    async def send(\n+        self,\n+        chat_id: str,\n+        content: str,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send a text message to a contact or group.\"\"\"\n+        corr_id = self._make_corr_id()\n+\n+        if chat_id.startswith(\"group:\"):\n+            group_id = chat_id[6:]\n+            cmd_str = f\"#[{group_id}] {content}\"\n+        else:\n+            cmd_str = f\"@[{chat_id}] {content}\"\n+\n+        payload = {\n+            \"corrId\": corr_id,\n+            \"cmd\": cmd_str,\n+        }\n+\n+        await self._send_ws(payload)\n+        return SendResult(success=True)\n+\n+    async def send_typing(self, chat_id: str, metadata=None) -> None:\n+        \"\"\"SimpleX does not expose a typing indicator API — no-op.\"\"\"\n+        pass\n+\n+    async def send_image(\n+        self,\n+        chat_id: str,\n+        image_url: str,\n+        caption: Optional[str] = None,\n+        reply_to: Optional[str] = None,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send an image (URL) as a message with optional caption.\n+\n+        SimpleX has no native ``send_image`` over the WebSocket API — file\n+        attachments require the daemon's filesystem-backed flow which is\n+        not driven from this adapter. Fall back to a plain text message\n+        containing the URL and caption.\n+        \"\"\"\n+        text = f\"{caption}\\n{image_url}\".strip() if caption else image_url\n+        return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)\n+\n+    async def get_chat_info(self, chat_id: str) -> dict:\n+        \"\"\"Return basic chat info.\"\"\"\n+        if chat_id.startswith(\"group:\"):\n+            return {\"chat_id\": chat_id, \"type\": \"group\", \"name\": chat_id[6:]}\n+        return {\"chat_id\": chat_id, \"type\": \"dm\", \"name\": chat_id}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Plugin entry-point hooks\n+# ---------------------------------------------------------------------------\n+\n+def check_requirements() -> bool:\n+    \"\"\"Plugin gate: require SIMPLEX_WS_URL AND the websockets package.\n+\n+    Returning False keeps the platform out of ``get_connected_platforms()``\n+    so the gateway never instantiates the adapter when the dependency is\n+    missing or no daemon URL is configured.\n+    \"\"\"\n+    if not os.getenv(\"SIMPLEX_WS_URL\"):\n+        return False\n+    try:\n+        import websockets  # noqa: F401\n+    except ImportError:\n+        return False\n+    return True\n+\n+\n+def validate_config(config) -> bool:\n+    \"\"\"Validate that the platform config has enough info to connect.\"\"\"\n+    extra = getattr(config, \"extra\", {}) or {}\n+    ws_url = os.getenv(\"SIMPLEX_WS_URL\") or extra.get(\"ws_url\", \"\")\n+    return bool(ws_url)\n+\n+\n+def is_connected(config) -> bool:\n+    \"\"\"Check whether SimpleX is configured (env or config.yaml).\"\"\"\n+    extra = getattr(config, \"extra\", {}) or {}\n+    ws_url = os.getenv(\"SIMPLEX_WS_URL\") or extra.get(\"ws_url\", \"\")\n+    return bool(ws_url)\n+\n+\n+def _env_enablement() -> dict | None:\n+    \"\"\"Seed ``PlatformConfig.extra`` from env vars during gateway config load.\n+\n+    Called by the platform registry's env-enablement hook BEFORE adapter\n+    construction, so ``gateway status`` and ``get_connected_platforms()``\n+    reflect env-only configuration without instantiating the WebSocket\n+    client. Returns ``None`` when SimpleX isn't minimally configured.\n+\n+    The special ``home_channel`` key in the returned dict is handled by\n+    the core hook — it becomes a proper ``HomeChannel`` dataclass on the\n+    ``PlatformConfig`` rather than being merged into ``extra``.\n+    \"\"\"\n+    ws_url = os.getenv(\"SIMPLEX_WS_URL\", \"\").strip()\n+    if not ws_url:\n+        return None\n+    seed: dict = {\"ws_url\": ws_url}\n+    home = os.getenv(\"SIMPLEX_HOME_CHANNEL\", \"\").strip()\n+    if home:\n+        seed[\"home_channel\"] = {\n+            \"chat_id\": home,\n+            \"name\": os.getenv(\"SIMPLEX_HOME_CHANNEL_NAME\", \"\").strip() or home,\n+        }\n+    return seed\n+\n+\n+async def _standalone_send(\n+    pconfig,\n+    chat_id: str,\n+    message: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    media_files: Optional[List[str]] = None,\n+    force_document: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"Open an ephemeral WebSocket to the daemon, send, and close.\n+\n+    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway\n+    runner is not in this process (e.g. ``hermes cron`` running as a\n+    separate process from ``hermes gateway``). Without this hook,\n+    ``deliver=simplex`` cron jobs fail with \"No live adapter for platform\".\n+\n+    ``thread_id`` and ``force_document`` are accepted for signature parity\n+    with other plugins but are not meaningful here. ``media_files`` is\n+    accepted but only the text body is delivered — SimpleX requires the\n+    daemon's filesystem-backed file flow which an ephemeral connection\n+    cannot drive safely.\n+    \"\"\"\n+    try:\n+        import websockets as _wsclient\n+    except ImportError:\n+        return {\"error\": \"websockets not installed. Run: pip install websockets\"}\n+\n+    extra = getattr(pconfig, \"extra\", {}) or {}\n+    ws_url = os.getenv(\"SIMPLEX_WS_URL\") or extra.get(\"ws_url\", \"ws://127.0.0.1:5225\")\n+    if not ws_url:\n+        return {\"error\": \"SimpleX standalone send: SIMPLEX_WS_URL is required\"}\n+\n+    try:\n+        if chat_id.startswith(\"group:\"):\n+            group_id = chat_id[6:]\n+            cmd_str = f\"#[{group_id}] {message}\"\n+        else:\n+            cmd_str = f\"@[{chat_id}] {message}\"\n+\n+        payload = {\n+            \"corrId\": f\"hermes-snd-{int(time.time() * 1000)}\",\n+            \"cmd\": cmd_str,\n+        }\n+\n+        async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws:\n+            await ws.send(json.dumps(payload))\n+            # Give the daemon a moment to process the command before closing.\n+            await asyncio.sleep(0.5)\n+\n+        return {\"success\": True, \"platform\": \"simplex\", \"chat_id\": chat_id}\n+    except Exception as e:\n+        return {\"error\": f\"SimpleX send failed: {e}\"}\n+\n+\n+def interactive_setup() -> None:\n+    \"\"\"Minimal stdin wizard for ``hermes setup gateway`` → SimpleX.\n+\n+    Prompts for the WebSocket URL and the optional allowlist / home channel.\n+    Writes to ``~/.hermes/.env`` via ``hermes_cli.config``.\n+    \"\"\"\n+    print()\n+    print(\"SimpleX Chat setup\")\n+    print(\"------------------\")\n+    print(\"Requirements:\")\n+    print(\"  1. simplex-chat daemon running (e.g. `simplex-chat -p 5225`).\")\n+    print(\"  2. Python package `websockets` installed (`pip install websockets`).\")\n+    print()\n+\n+    try:\n+        from hermes_cli.config import get_env_value, save_env_value\n+    except ImportError:\n+        print(\"hermes_cli.config not available; set SIMPLEX_* vars manually in ~/.hermes/.env\")\n+        return\n+\n+    def _prompt(var: str, prompt: str, *, secret: bool = False) -> None:\n+        existing = get_env_value(var) if callable(get_env_value) else None\n+        suffix = \" [keep current]\" if existing else \"\"\n+        try:\n+            if secret:\n+                import getpass\n+                value = getpass.getpass(f\"{prompt}{suffix}: \")\n+            else:\n+                value = input(f\"{prompt}{suffix}: \").strip()\n+        except (EOFError, KeyboardInterrupt):\n+            print()\n+            return\n+        if value:\n+            save_env_value(var, value)\n+\n+    _prompt(\"SIMPLEX_WS_URL\", \"Daemon WebSocket URL (default ws://127.0.0.1:5225)\")\n+    _prompt(\"SIMPLEX_ALLOWED_USERS\", \"Allowed contact IDs (comma-separated; blank=skip)\")\n+    _prompt(\"SIMPLEX_HOME_CHANNEL\", \"Home channel contact/group ID (or empty)\")\n+    print(\"Done. Make sure the simplex-chat daemon is running before starting the gateway.\")\n+\n+\n+def register(ctx) -> None:\n+    \"\"\"Plugin entry point — called by the Hermes plugin system at startup.\"\"\"\n+    ctx.register_platform(\n+        name=\"simplex\",\n+        label=\"SimpleX Chat\",\n+        adapter_factory=lambda cfg: SimplexAdapter(cfg),\n+        check_fn=check_requirements,\n+        validate_config=validate_config,\n+        is_connected=is_connected,\n+        required_env=[\"SIMPLEX_WS_URL\"],\n+        install_hint=\"pip install websockets   # SimpleX adapter requires the websockets package\",\n+        setup_fn=interactive_setup,\n+        # Env-driven auto-configuration: seeds PlatformConfig.extra so\n+        # env-only setups show up in `hermes gateway status` without\n+        # instantiating the adapter.\n+        env_enablement_fn=_env_enablement,\n+        # Cron home-channel delivery support — `deliver=simplex` cron jobs\n+        # route to SIMPLEX_HOME_CHANNEL when set.\n+        cron_deliver_env_var=\"SIMPLEX_HOME_CHANNEL\",\n+        # Out-of-process cron delivery. Without this hook, deliver=simplex\n+        # cron jobs fail with \"No live adapter\" when cron runs separately\n+        # from the gateway.\n+        standalone_sender_fn=_standalone_send,\n+        # Auth env vars for _is_user_authorized() integration\n+        allowed_users_env=\"SIMPLEX_ALLOWED_USERS\",\n+        allow_all_env=\"SIMPLEX_ALLOW_ALL_USERS\",\n+        # SimpleX has no hard line length; we still chunk for sanity.\n+        max_message_length=MAX_MESSAGE_LENGTH,\n+        # Display\n+        emoji=\"🔒\",\n+        # SimpleX uses opaque contact IDs only — no phone numbers or\n+        # email addresses to redact.\n+        pii_safe=True,\n+        allow_update_command=True,\n+        # LLM guidance\n+        platform_hint=(\n+            \"You are chatting via SimpleX Chat, a private decentralised \"\n+            \"messenger. Contacts are identified by opaque internal IDs, \"\n+            \"not phone numbers or usernames. SimpleX supports standard \"\n+            \"markdown formatting. There is no typing indicator and no \"\n+            \"hard message length limit, but keep responses conversational.\"\n+        ),\n+    )"]},{"file":"plugins/platforms/simplex/plugin.yaml","hunks":["@@ -0,0 +1,37 @@\n+name: simplex-platform\n+label: SimpleX Chat\n+kind: platform\n+version: 1.0.0\n+description: >\n+  SimpleX Chat gateway adapter for Hermes Agent.\n+  Connects to a local simplex-chat daemon via WebSocket and relays\n+  messages between SimpleX contacts/groups and the Hermes agent.\n+  SimpleX is decentralised and assigns no persistent user IDs —\n+  every contact is an opaque internal ID generated at connection\n+  time, making it one of the most private messengers available.\n+author: Mibayy\n+# ``requires_env`` and ``optional_env`` entries are surfaced in the\n+# ``hermes config`` UI via the platform-plugin env var injector in\n+# ``hermes_cli/config.py``.\n+requires_env:\n+  - name: SIMPLEX_WS_URL\n+    description: \"WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)\"\n+    prompt: \"SimpleX daemon WebSocket URL\"\n+    password: false\n+optional_env:\n+  - name: SIMPLEX_ALLOWED_USERS\n+    description: \"Comma-separated SimpleX contact IDs allowed to talk to the bot\"\n+    prompt: \"Allowed contact IDs (comma-separated)\"\n+    password: false\n+  - name: SIMPLEX_ALLOW_ALL_USERS\n+    description: \"Allow any contact to talk to the bot (dev only — disables allowlist)\"\n+    prompt: \"Allow all contacts? (true/false)\"\n+    password: false\n+  - name: SIMPLEX_HOME_CHANNEL\n+    description: \"Default contact/group ID for cron / notification delivery\"\n+    prompt: \"Home channel contact/group ID (or empty)\"\n+    password: false\n+  - name: SIMPLEX_HOME_CHANNEL_NAME\n+    description: \"Human label for the home channel (defaults to the ID)\"\n+    prompt: \"Home channel display name (or empty)\"\n+    password: false"]},{"file":"scripts/release.py","hunks":["@@ -397,6 +397,7 @@\n     \"Mibayy@users.noreply.github.com\": \"Mibayy\",\n     \"mibayy@users.noreply.github.com\": \"Mibayy\",\n     \"[redacted:email]\": \"Mibayy\",\n+    \"[redacted:email]\": \"Mibayy\",\n     \"135070653+sgaofen@users.noreply.github.com\": \"sgaofen\",\n     \"[redacted:email]\": \"zhiyanliu\",\n     \"[redacted:email]\": \"hhhonzik\","]},{"file":"tests/gateway/test_simplex_plugin.py","hunks":["@@ -0,0 +1,347 @@\n+\"\"\"Tests for the SimpleX Chat platform-plugin adapter.\n+\n+Loaded via the ``_plugin_adapter_loader`` helper so this lives under\n+``plugin_adapter_simplex`` in ``sys.modules`` and cannot collide with\n+sibling platform-plugin tests on the same xdist worker.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from tests.gateway._plugin_adapter_loader import load_plugin_adapter\n+\n+_simplex = load_plugin_adapter(\"simplex\")\n+\n+SimplexAdapter = _simplex.SimplexAdapter\n+check_requirements = _simplex.check_requirements\n+validate_config = _simplex.validate_config\n+is_connected = _simplex.is_connected\n+register = _simplex.register\n+_env_enablement = _simplex._env_enablement\n+_standalone_send = _simplex._standalone_send\n+_guess_extension = _simplex._guess_extension\n+_is_image_ext = _simplex._is_image_ext\n+_is_audio_ext = _simplex._is_audio_ext\n+_CORR_PREFIX = _simplex._CORR_PREFIX\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1. Platform enum (plugin-discovered, not bundled)\n+# ---------------------------------------------------------------------------\n+\n+def test_platform_enum_resolves_via_plugin_scan():\n+    \"\"\"The plugin filesystem scan should expose Platform(\"simplex\").\"\"\"\n+    from gateway.config import Platform\n+    p = Platform(\"simplex\")\n+    assert p.value == \"simplex\"\n+    # Identity stability — repeated lookups return the same pseudo-member\n+    assert Platform(\"simplex\") is p\n+\n+\n+# ---------------------------------------------------------------------------\n+# 2. check_requirements / validate_config / is_connected\n+# ---------------------------------------------------------------------------\n+\n+def test_check_requirements_needs_url(monkeypatch):\n+    monkeypatch.delenv(\"SIMPLEX_WS_URL\", raising=False)\n+    assert check_requirements() is False\n+\n+\n+def test_check_requirements_true_when_configured(monkeypatch):\n+    monkeypatch.setenv(\"SIMPLEX_WS_URL\", \"ws://127.0.0.1:5225\")\n+    # websockets is a dev dep in this repo via the test plugins; the\n+    # check_requirements() gate also asserts the package imports.\n+    websockets_present = True\n+    try:\n+        import websockets  # noqa: F401\n+    except ImportError:\n+        websockets_present = False\n+    assert check_requirements() is websockets_present\n+\n+\n+def test_validate_config_uses_env_or_extra():\n+    from gateway.config import PlatformConfig\n+    # Empty extra + no env → invalid\n+    cfg = PlatformConfig(enabled=True)\n+    assert validate_config(cfg) is False\n+    # extra-only path → valid\n+    cfg2 = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    assert validate_config(cfg2) is True\n+\n+\n+def test_is_connected_mirrors_validate(monkeypatch):\n+    from gateway.config import PlatformConfig\n+    monkeypatch.delenv(\"SIMPLEX_WS_URL\", raising=False)\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://x\"})\n+    assert is_connected(cfg) is True\n+    assert is_connected(PlatformConfig(enabled=True)) is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# 3. _env_enablement seeds PlatformConfig.extra\n+# ---------------------------------------------------------------------------\n+\n+def test_env_enablement_none_when_unset(monkeypatch):\n+    monkeypatch.delenv(\"SIMPLEX_WS_URL\", raising=False)\n+    assert _env_enablement() is None\n+\n+\n+def test_env_enablement_seeds_ws_url(monkeypatch):\n+    monkeypatch.setenv(\"SIMPLEX_WS_URL\", \"ws://127.0.0.1:5225\")\n+    monkeypatch.delenv(\"SIMPLEX_HOME_CHANNEL\", raising=False)\n+    seed = _env_enablement()\n+    assert seed == {\"ws_url\": \"ws://127.0.0.1:5225\"}\n+\n+\n+def test_env_enablement_seeds_home_channel(monkeypatch):\n+    monkeypatch.setenv(\"SIMPLEX_WS_URL\", \"ws://127.0.0.1:5225\")\n+    monkeypatch.setenv(\"SIMPLEX_HOME_CHANNEL\", \"42\")\n+    monkeypatch.setenv(\"SIMPLEX_HOME_CHANNEL_NAME\", \"Personal\")\n+    seed = _env_enablement()\n+    assert seed[\"home_channel\"] == {\"chat_id\": \"42\", \"name\": \"Personal\"}\n+\n+\n+def test_env_enablement_home_channel_defaults_name_to_id(monkeypatch):\n+    monkeypatch.setenv(\"SIMPLEX_WS_URL\", \"ws://127.0.0.1:5225\")\n+    monkeypatch.setenv(\"SIMPLEX_HOME_CHANNEL\", \"42\")\n+    monkeypatch.delenv(\"SIMPLEX_HOME_CHANNEL_NAME\", raising=False)\n+    seed = _env_enablement()\n+    assert seed[\"home_channel\"] == {\"chat_id\": \"42\", \"name\": \"42\"}\n+\n+\n+# ---------------------------------------------------------------------------\n+# 4. Adapter init\n+# ---------------------------------------------------------------------------\n+\n+def test_adapter_init_custom_url():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    assert adapter.ws_url == \"ws://localhost:5225\"\n+    assert adapter._running is False\n+    assert adapter._ws is None\n+\n+\n+def test_adapter_init_default_url():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True)\n+    adapter = SimplexAdapter(cfg)\n+    assert adapter.ws_url == \"ws://127.0.0.1:5225\"\n+\n+\n+def test_adapter_platform_identity():\n+    \"\"\"Adapter should expose Platform(\"simplex\") identity.\"\"\"\n+    from gateway.config import Platform, PlatformConfig\n+    cfg = PlatformConfig(enabled=True)\n+    adapter = SimplexAdapter(cfg)\n+    assert adapter.platform is Platform(\"simplex\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# 5. Helper functions (magic-byte detection)\n+# ---------------------------------------------------------------------------\n+\n+def test_guess_extension_png():\n+    assert _guess_extension(b\"\\x89PNG\\r\\n\\x1a\\n\") == \".png\"\n+\n+\n+def test_guess_extension_jpg():\n+    assert _guess_extension(b\"\\xff\\xd8\\xff\\xe0\") == \".jpg\"\n+\n+\n+def test_guess_extension_ogg():\n+    assert _guess_extension(b\"OggS\\x00\\x02\") == \".ogg\"\n+\n+\n+def test_guess_extension_unknown():\n+    assert _guess_extension(b\"\\x00\\x01\\x02\\x03\") == \".bin\"\n+\n+\n+def test_is_image_ext():\n+    assert _is_image_ext(\".png\") is True\n+    assert _is_image_ext(\".webp\") is True\n+    assert _is_image_ext(\".ogg\") is False\n+\n+\n+def test_is_audio_ext():\n+    assert _is_audio_ext(\".ogg\") is True\n+    assert _is_audio_ext(\".mp3\") is True\n+    assert _is_audio_ext(\".pdf\") is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# 6. Correlation IDs\n+# ---------------------------------------------------------------------------\n+\n+def test_corr_id_starts_with_prefix_and_tracks_pending():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    corr_id = adapter._make_corr_id()\n+    assert corr_id.startswith(_CORR_PREFIX)\n+    assert corr_id in adapter._pending_corr_ids\n+\n+\n+def test_corr_id_pending_set_self_trims():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    adapter._max_pending_corr = 4\n+    for _ in range(10):\n+        adapter._make_corr_id()\n+    # After many additions, the pending set should be bounded by the trim\n+    # logic — at most one trim window above the cap.\n+    assert len(adapter._pending_corr_ids) <= adapter._max_pending_corr + 1\n+\n+\n+# ---------------------------------------------------------------------------\n+# 7. Outbound send (mocked WS)\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_send_dm():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+\n+    mock_ws = AsyncMock()\n+    adapter._ws = mock_ws\n+\n+    result = await adapter.send(\"contact-42\", \"Hello, SimpleX!\")\n+    mock_ws.send.assert_called_once()\n+    payload = json.loads(mock_ws.send.call_args[0][0])\n+    assert payload[\"cmd\"] == \"@[contact-42] Hello, SimpleX!\"\n+    assert payload[\"corrId\"].startswith(_CORR_PREFIX)\n+    assert result.success is True\n+\n+\n[redacted:email]\n+async def test_send_group():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+\n+    mock_ws = AsyncMock()\n+    adapter._ws = mock_ws\n+\n+    result = await adapter.send(\"group:grp-99\", \"Hello, group!\")\n+    payload = json.loads(mock_ws.send.call_args[0][0])\n+    assert payload[\"cmd\"] == \"#[grp-99] Hello, group!\"\n+    assert result.success is True\n+\n+\n[redacted:email]\n+async def test_send_when_ws_not_connected_does_not_crash():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    # No _ws assigned — _send_ws should drop quietly\n+    result = await adapter.send(\"contact-42\", \"hi\")\n+    assert result.success is True  # send() always returns success — fire-and-forget\n+\n+\n+# ---------------------------------------------------------------------------\n+# 8. Inbound: filter own-echo by corrId prefix\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_handle_event_filters_own_corr_id():\n+    from gateway.config import PlatformConfig\n+    cfg = PlatformConfig(enabled=True, extra={\"ws_url\": \"ws://localhost:5225\"})\n+    adapter = SimplexAdapter(cfg)\n+    # Pretend we sent a command with this corrId\n+    own = adapter._make_corr_id()\n+    handler_mock = AsyncMock()\n+    adapter._handle_new_chat_item = handler_mock  # type: ignore\n+\n+    await adapter._handle_event({\"corrId\": own, \"type\": \"newChatItem\"})\n+    handler_mock.assert_not_called()\n+    assert own not in adapter._pending_corr_ids  # discarded\n+\n+\n+# ---------------------------------------------------------------------------\n+# 9. Standalone (out-of-process) send for cron\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_standalone_send_missing_websockets(monkeypatch):\n+    \"\"\"When websockets is unimportable, return a clean error dict.\n+\n+    Implementation detail: the standalone path does ``import websockets``\n+    inside the function body. We simulate the package being absent by\n+    pulling it out of ``sys.modules`` and pointing the finder at None.\n+    \"\"\"\n+    import sys\n+    saved_websockets = sys.modules.pop(\"websockets\", None)\n+    saved_meta = list(sys.meta_path)\n+\n+    class _Blocker:\n+        @staticmethod\n+        def find_spec(name, path=None, target=None):\n+            if name == \"websockets\" or name.startswith(\"websockets.\"):\n+                raise ImportError(\"websockets blocked for test\")\n+            return None\n+\n+    sys.meta_path.insert(0, _Blocker())\n+    try:\n+        pconfig = MagicMock()\n+        pconfig.extra = {\"ws_url\": \"ws://localhost:5225\"}\n+        result = await _standalone_send(pconfig, \"contact-42\", \"hi\")\n+        assert isinstance(result, dict)\n+        assert \"error\" in result\n+        assert \"websockets\" in result[\"error\"]\n+    finally:\n+        sys.meta_path[:] = saved_meta\n+        if saved_websockets is not None:\n+            sys.modules[\"websockets\"] = saved_websockets\n+\n+\n[redacted:email]\n+async def test_standalone_send_missing_url(monkeypatch):\n+    monkeypatch.delenv(\"SIMPLEX_WS_URL\", raising=False)\n+    pconfig = MagicMock()\n+    pconfig.extra = {}\n+    # We expect the URL fallback (extra+env both empty) to be empty string,\n+    # producing an error. We also need websockets to be importable for the\n+    # url-check branch to be reached, so skip when it's not.\n+    try:\n+        import websockets.client  # noqa: F401\n+    except ImportError:\n+        pytest.skip(\"websockets not installed\")\n+\n+    result = await _standalone_send(pconfig, \"contact-42\", \"hi\")\n+    assert isinstance(result, dict)\n+    # Either error about URL or a connection attempt failure — both are valid\n+    # signals that the standalone path requires configuration.\n+    assert \"error\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# 10. register() — plugin-side metadata\n+# ---------------------------------------------------------------------------\n+\n+def test_register_calls_register_platform():\n+    ctx = MagicMock()\n+    register(ctx)\n+    ctx.register_platform.assert_called_once()\n+    kwargs = ctx.register_platform.call_args.kwargs\n+    assert kwargs[\"name\"] == \"simplex\"\n+    assert kwargs[\"label\"] == \"SimpleX Chat\"\n+    assert kwargs[\"required_env\"] == [\"SIMPLEX_WS_URL\"]\n+    assert kwargs[\"allowed_users_env\"] == \"SIMPLEX_ALLOWED_USERS\"\n+    assert kwargs[\"allow_all_env\"] == \"SIMPLEX_ALLOW_ALL_USERS\"\n+    assert kwargs[\"cron_deliver_env_var\"] == \"SIMPLEX_HOME_CHANNEL\"\n+    assert callable(kwargs[\"check_fn\"])\n+    assert callable(kwargs[\"validate_config\"])\n+    assert callable(kwargs[\"is_connected\"])\n+    assert callable(kwargs[\"env_enablement_fn\"])\n+    assert callable(kwargs[\"standalone_sender_fn\"])\n+    assert callable(kwargs[\"adapter_factory\"])\n+    assert callable(kwargs[\"setup_fn\"])\n+    # SimpleX uses opaque IDs only — no PII to redact.\n+    assert kwargs[\"pii_safe\"] is True"]},{"file":"website/docs/user-guide/messaging/simplex.md","hunks":["@@ -0,0 +1,99 @@\n+# SimpleX Chat\n+\n+[SimpleX Chat](https://simplex.chat/) is a private, decentralised messaging platform where users own their contacts and groups. Unlike other platforms, SimpleX assigns no persistent user IDs — every contact is identified by an opaque internal ID generated at connection time, which makes it one of the most private messengers available.\n+\n+## Prerequisites\n+\n+- The **simplex-chat** CLI installed and running as a daemon\n+- Python package **websockets** (`pip install websockets`)\n+\n+## Install simplex-chat\n+\n+Download the latest release from the [simplex-chat GitHub releases](https://github.com/simplex-chat/simplex-chat/releases) page, or via Docker:\n+\n+```bash\n+# Linux / macOS binary\n+curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64 -o simplex-chat\n+chmod +x simplex-chat\n+\n+# Or Docker\n+docker run -p 5225:5225 simplexchat/simplex-chat -p 5225\n+```\n+\n+## Start the daemon\n+\n+```bash\n+simplex-chat -p 5225\n+```\n+\n+The daemon listens on WebSocket at `ws://127.0.0.1:5225` by default.\n+\n+## Configure Hermes\n+\n+### Via setup wizard\n+\n+```bash\n+hermes setup gateway\n+```\n+\n+Select **SimpleX Chat** and follow the prompts.\n+\n+### Via environment variables\n+\n+Add these to `~/.hermes/.env`:\n+\n+```\n+SIMPLEX_WS_URL=ws://127.0.0.1:5225\n+SIMPLEX_ALLOWED_USERS=<contact-id-1>,<contact-id-2>\n+SIMPLEX_HOME_CHANNEL=<contact-id>\n+```\n+\n+| Variable | Required | Description |\n+|---|---|---|\n+| `SIMPLEX_WS_URL` | Yes | WebSocket URL of the simplex-chat daemon |\n+| `SIMPLEX_ALLOWED_USERS` | Recommended | Comma-separated contact IDs allowed to use the agent |\n+| `SIMPLEX_ALLOW_ALL_USERS` | Optional | Set `true` to allow every contact (use carefully) |\n+| `SIMPLEX_HOME_CHANNEL` | Optional | Default contact ID for cron job delivery |\n+| `SIMPLEX_HOME_CHANNEL_NAME` | Optional | Human label for the home channel |\n+\n+## Find your contact ID\n+\n+After starting the daemon, open a conversation with your agent contact. The contact ID will appear in session logs or via `hermes send_message action=list`.\n+\n+## Authorization\n+\n+By default **all contacts are denied**. You must either:\n+\n+1. Set `SIMPLEX_ALLOWED_USERS` to a comma-separated list of contact IDs, or\n+2. Use **DM pairing** — send any message to the bot and it will reply with a pairing code. Enter that code via `hermes gateway pair`.\n+\n+## Using SimpleX with cron jobs\n+\n+```python\n+cronjob(\n+    action=\"create\",\n+    schedule=\"every 1h\",\n+    deliver=\"simplex\",          # uses SIMPLEX_HOME_CHANNEL\n+    prompt=\"Check for alerts and summarise.\"\n+)\n+```\n+\n+Or target a specific contact:\n+\n+```python\n+send_message(target=\"simplex:<contact-id>\", message=\"Done!\")\n+```\n+\n+## Privacy notes\n+\n+- SimpleX never reveals phone numbers or email addresses — contacts use opaque IDs\n+- The connection between Hermes and the daemon is local WebSocket (`ws://127.0.0.1:5225`) — no data leaves your machine\n+- Messages are end-to-end encrypted by the SimpleX protocol before reaching the daemon\n+\n+## Troubleshooting\n+\n+**\"Cannot reach daemon\"** — Ensure `simplex-chat -p 5225` is running and the port matches `SIMPLEX_WS_URL`.\n+\n+**\"websockets not installed\"** — Run `pip install websockets`.\n+\n+**Messages not received** — Check that the contact's ID is in `SIMPLEX_ALLOWED_USERS` or approve them via DM pairing."]},{"file":"website/sidebars.ts","hunks":["@@ -145,6 +145,7 @@ const sidebars: SidebarsConfig = {\n         'user-guide/messaging/teams-meetings',\n         'user-guide/messaging/msgraph-webhook',\n         'user-guide/messaging/line',\n+        'user-guide/messaging/simplex',\n         'user-guide/messaging/open-webui',\n         'user-guide/messaging/webhooks',\n       ],\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}