{"repo":"NousResearch/hermes-agent","public_id":"4915","display_label":"Telegram dependency missing in Docker and Nix installs","summary":"Multiple bug reports and fix attempts address python-telegram-bot not being available in Docker images and NixOS environments, alongside related permission, lazy-install, and dependency resolution issues across deployment targets.","subsystem":"environments","counts":{"members":15,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":8,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5288},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-17T18:51:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12076190476190476},"fix_edges":[{"issue_number":4820,"pr_number":5288,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4915,"pr_number":5288,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":4954,"pr_number":5288,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5046,"pr_number":5288,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24698,"pr_number":27558,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":4915,"title":"[Setup]: Docker image nousresearch/hermes-agent:latest is missing Telegram webhook dependency (python-telegram-bot[webhooks])","url":null,"state":"closed"},{"kind":"issue","number":24698,"title":"[Bug]: python-telegram-bot not installed in latest Docker image (v0.13.0)","url":null,"state":"closed"},{"kind":"issue","number":27047,"title":"[Bug]: NixOS: WARNING gateway.run: Telegram: python-telegram-bot not installed after update to v0.14.0","url":null,"state":"closed"},{"kind":"issue","number":27100,"title":"[Bug] v0.14.0 Docker image: Telegram bot fails on launch with --user flag","url":null,"state":"closed"},{"kind":"issue","number":29391,"title":"[Bug]: Updated to 0.14 in Docker. python-telegram-bot not installed","url":null,"state":"closed"},{"kind":"issue","number":34028,"title":"[Feature]: Pre-install python-telegram-bot in Umbrel Hermes Docker image","url":null,"state":"closed"},{"kind":"issue","number":48080,"title":"Fix Unraid permission denied errors on lazy-installs and dual-service execution","url":null,"state":"closed"},{"kind":"issue","number":57909,"title":"\"WARNING gateway.run: No adapter available for telegram\" showing suddenly after \"hermes update\"","url":null,"state":"closed"},{"kind":"pr","number":4961,"title":"fix(deps): include Telegram webhook extras in messaging installs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5288","number":5288}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5288,"title":"fix: salvage 9 community bugfixes — gateway, cron, deps, macOS launchd","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/skill_commands.py","cron/scheduler.py","gateway/channel_directory.py","gateway/platforms/api_server.py","gateway/platforms/base.py","gateway/platforms/telegram.py","gateway/run.py","hermes_cli/gateway.py","pyproject.toml","requirements.txt","tests/agent/test_skill_commands.py","tests/cron/test_scheduler.py","tests/gateway/test_channel_directory.py","tests/gateway/test_status_command.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_thread_fallback.py","tests/gateway/test_unknown_command.py","tests/hermes_cli/test_gateway_service.py","tests/hermes_cli/test_update_gateway_restart.py","tests/tools/test_send_message_tool.py"],"fix_hunks":[{"file":"agent/skill_commands.py","hunks":["@@ -217,6 +217,25 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:\n     return _skill_commands\n \n \n+def resolve_skill_command_key(command: str) -> Optional[str]:\n+    \"\"\"Resolve a user-typed /command to its canonical skill_cmds key.\n+\n+    Skills are always stored with hyphens — ``scan_skill_commands`` normalizes\n+    spaces and underscores to hyphens when building the key. Hyphens and\n+    underscores are treated interchangeably in user input: this matches\n+    ``_check_unavailable_skill`` and accommodates Telegram bot-command names\n+    (which disallow hyphens, so ``/claude-code`` is registered as\n+    ``/claude_code`` and comes back in the underscored form).\n+\n+    Returns the matching ``/slug`` key from ``get_skill_commands()`` or\n+    ``None`` if no match.\n+    \"\"\"\n+    if not command:\n+        return None\n+    cmd_key = f\"/{command.replace('_', '-')}\"\n+    return cmd_key if cmd_key in get_skill_commands() else None\n+\n+\n def build_skill_invocation_message(\n     cmd_key: str,\n     user_instruction: str = \"\","]},{"file":"cron/scheduler.py","hunks":["@@ -15,7 +15,6 @@\n import os\n import subprocess\n import sys\n-import traceback\n \n # fcntl is Unix-only; on Windows use msvcrt for file locking\n try:","@@ -99,24 +98,26 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:\n \n     if \":\" in deliver:\n         platform_name, rest = deliver.split(\":\", 1)\n-        # Check for thread_id suffix (e.g. \"telegram:-1003724596514:17\")\n-        if \":\" in rest:\n-            chat_id, thread_id = rest.split(\":\", 1)\n+        platform_key = platform_name.lower()\n+\n+        from tools.send_message_tool import _parse_target_ref\n+\n+        parsed_chat_id, parsed_thread_id, is_explicit = _parse_target_ref(platform_key, rest)\n+        if is_explicit:\n+            chat_id, thread_id = parsed_chat_id, parsed_thread_id\n         else:\n             chat_id, thread_id = rest, None\n \n         # Resolve human-friendly labels like \"Alice (dm)\" to real IDs.\n-        # send_message(action=\"list\") shows labels with display suffixes\n-        # that aren't valid platform IDs (e.g. WhatsApp JIDs).\n         try:\n             from gateway.channel_directory import resolve_channel_name\n-            target = chat_id\n-            # Strip display suffix like \" (dm)\" or \" (group)\"\n-            if target.endswith(\")\") and \" (\" in target:\n-                target = target.rsplit(\" (\", 1)[0].strip()\n-            resolved = resolve_channel_name(platform_name.lower(), target)\n+            resolved = resolve_channel_name(platform_key, chat_id)\n             if resolved:\n-                chat_id = resolved\n+                parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved)\n+                if resolved_is_explicit:\n+                    chat_id, thread_id = parsed_chat_id, parsed_thread_id\n+                else:\n+                    chat_id = resolved\n         except Exception:\n             pass\n ","@@ -628,7 +629,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n         \n     except Exception as e:\n         error_msg = f\"{type(e).__name__}: {str(e)}\"\n-        logger.error(\"Job '%s' failed: %s\", job_name, error_msg)\n+        logger.exception(\"Job '%s' failed: %s\", job_name, error_msg)\n         \n         output = f\"\"\"# Cron Job: {job_name} (FAILED)\n ","@@ -644,8 +645,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n \n ```\n {error_msg}\n-\n-{traceback.format_exc()}\n ```\n \"\"\"\n         return False, output, \"\", error_msg"]},{"file":"gateway/channel_directory.py","hunks":["@@ -18,6 +18,20 @@\n DIRECTORY_PATH = get_hermes_home() / \"channel_directory.json\"\n \n \n+def _normalize_channel_query(value: str) -> str:\n+    return value.lstrip(\"#\").strip().lower()\n+\n+\n+def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str:\n+    \"\"\"Return the human-facing target label shown to users for a channel entry.\"\"\"\n+    name = channel[\"name\"]\n+    if platform_name == \"discord\" and channel.get(\"guild\"):\n+        return f\"#{name}\"\n+    if platform_name != \"discord\" and channel.get(\"type\"):\n+        return f\"{name} ({channel['type']})\"\n+    return name\n+\n+\n def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:\n     chat_id = origin.get(\"chat_id\")\n     if not chat_id:","@@ -188,23 +202,25 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:\n     if not channels:\n         return None\n \n-    query = name.lstrip(\"#\").lower()\n+    query = _normalize_channel_query(name)\n \n-    # 1. Exact name match\n+    # 1. Exact name match, including the display labels shown by send_message(action=\"list\")\n     for ch in channels:\n-        if ch[\"name\"].lower() == query:\n+        if _normalize_channel_query(ch[\"name\"]) == query:\n+            return ch[\"id\"]\n+        if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query:\n             return ch[\"id\"]\n \n     # 2. Guild-qualified match for Discord (\"GuildName/channel\")\n     if \"/\" in query:\n         guild_part, ch_part = query.rsplit(\"/\", 1)\n         for ch in channels:\n-            guild = ch.get(\"guild\", \"\").lower()\n-            if guild == guild_part and ch[\"name\"].lower() == ch_part:\n+            guild = ch.get(\"guild\", \"\").strip().lower()\n+            if guild == guild_part and _normalize_channel_query(ch[\"name\"]) == ch_part:\n                 return ch[\"id\"]\n \n     # 3. Partial prefix match (only if unambiguous)\n-    matches = [ch for ch in channels if ch[\"name\"].lower().startswith(query)]\n+    matches = [ch for ch in channels if _normalize_channel_query(ch[\"name\"]).startswith(query)]\n     if len(matches) == 1:\n         return matches[0][\"id\"]\n ","@@ -239,17 +255,16 @@ def format_directory_for_display() -> str:\n             for guild_name, guild_channels in sorted(guilds.items()):\n                 lines.append(f\"Discord ({guild_name}):\")\n                 for ch in sorted(guild_channels, key=lambda c: c[\"name\"]):\n-                    lines.append(f\"  discord:#{ch['name']}\")\n+                    lines.append(f\"  discord:{_channel_target_name(plat_name, ch)}\")\n             if dms:\n                 lines.append(\"Discord (DMs):\")\n                 for ch in dms:\n-                    lines.append(f\"  discord:{ch['name']}\")\n+                    lines.append(f\"  discord:{_channel_target_name(plat_name, ch)}\")\n             lines.append(\"\")\n         else:\n             lines.append(f\"{plat_name.title()}:\")\n             for ch in channels:\n-                type_label = f\" ({ch['type']})\" if ch.get(\"type\") else \"\"\n-                lines.append(f\"  {plat_name}:{ch['name']}{type_label}\")\n+                lines.append(f\"  {plat_name}:{_channel_target_name(plat_name, ch)}\")\n             lines.append(\"\")\n \n     lines.append('Use these as the \"target\" parameter when sending.')"]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -421,6 +421,11 @@ def _create_agent(\n \n         max_iterations = int(os.getenv(\"HERMES_MAX_ITERATIONS\", \"90\"))\n \n+        # Load fallback provider chain so the API server platform has the\n+        # same fallback behaviour as Telegram/Discord/Slack (fixes #4954).\n+        from gateway.run import GatewayRunner\n+        fallback_model = GatewayRunner._load_fallback_model()\n+\n         agent = AIAgent(\n             model=model,\n             **runtime_kwargs,","@@ -434,6 +439,7 @@ def _create_agent(\n             stream_delta_callback=stream_delta_callback,\n             tool_progress_callback=tool_progress_callback,\n             session_db=self._ensure_session_db(),\n+            fallback_model=fallback_model,\n         )\n         return agent\n "]},{"file":"gateway/platforms/base.py","hunks":["@@ -1068,6 +1068,28 @@ async def handle_message(self, event: MessageEvent) -> None:\n                     logger.error(\"[%s] Approval dispatch failed: %s\", self.name, e, exc_info=True)\n                 return\n \n+            # /status must also bypass the active-session guard so it always\n+            # returns a system-generated response instead of being queued as\n+            # user text and passed to the agent (#5046).\n+            if cmd == \"status\":\n+                logger.debug(\n+                    \"[%s] Status command bypassing active-session guard for %s\",\n+                    self.name, session_key,\n+                )\n+                try:\n+                    _thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+                    response = await self._message_handler(event)\n+                    if response:\n+                        await self._send_with_retry(\n+                            chat_id=event.source.chat_id,\n+                            content=response,\n+                            reply_to=event.message_id,\n+                            metadata=_thread_meta,\n+                        )\n+                except Exception as e:\n+                    logger.error(\"[%s] Status dispatch failed: %s\", self.name, e, exc_info=True)\n+                return\n+\n             # Special case: photo bursts/albums frequently arrive as multiple near-\n             # simultaneous messages. Queue them without interrupting the active run,\n             # then process them immediately after the current task finishes."]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -601,6 +601,12 @@ async def connect(self) -> bool:\n                 )\n             else:\n                 # ── Polling mode (default) ───────────────────────────\n+                # Clear any stale webhook first so polling doesn't inherit a\n+                # previous webhook registration and silently stop receiving updates.\n+                delete_webhook = getattr(self._bot, \"delete_webhook\", None)\n+                if callable(delete_webhook):\n+                    await delete_webhook(drop_pending_updates=False)\n+\n                 loop = asyncio.get_running_loop()\n \n                 def _polling_error_callback(error: Exception) -> None:","@@ -856,6 +862,21 @@ async def send(\n                             await asyncio.sleep(wait)\n                         else:\n                             raise\n+                    except Exception as send_err:\n+                        retry_after = getattr(send_err, \"retry_after\", None)\n+                        if retry_after is not None or \"retry after\" in str(send_err).lower():\n+                            if _send_attempt < 2:\n+                                wait = float(retry_after) if retry_after is not None else 1.0\n+                                logger.warning(\n+                                    \"[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s\",\n+                                    self.name,\n+                                    _send_attempt + 1,\n+                                    wait,\n+                                    send_err,\n+                                )\n+                                await asyncio.sleep(wait)\n+                                continue\n+                        raise\n                 message_ids.append(str(msg.message_id))\n             \n             return SendResult("]},{"file":"gateway/run.py","hunks":["@@ -1266,6 +1266,8 @@ async def _session_expiry_watcher(self, interval: int = 300):\n         next message, so there's no blocking delay.\n         \"\"\"\n         await asyncio.sleep(60)  # initial delay — let the gateway fully start\n+        _flush_failures: dict[str, int] = {}  # session_id -> consecutive failure count\n+        _MAX_FLUSH_RETRIES = 3\n         while self._running:\n             try:\n                 self.session_store._ensure_loaded()","@@ -1298,8 +1300,25 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                             \"Pre-reset memory flush completed for session %s\",\n                             entry.session_id,\n                         )\n+                        _flush_failures.pop(entry.session_id, None)\n                     except Exception as e:\n-                        logger.debug(\"Proactive memory flush failed for %s: %s\", entry.session_id, e)\n+                        failures = _flush_failures.get(entry.session_id, 0) + 1\n+                        _flush_failures[entry.session_id] = failures\n+                        if failures >= _MAX_FLUSH_RETRIES:\n+                            logger.warning(\n+                                \"Proactive memory flush gave up after %d attempts for %s: %s. \"\n+                                \"Marking as flushed to prevent infinite retry loop.\",\n+                                failures, entry.session_id, e,\n+                            )\n+                            with self.session_store._lock:\n+                                entry.memory_flushed = True\n+                                self.session_store._save()\n+                            _flush_failures.pop(entry.session_id, None)\n+                        else:\n+                            logger.debug(\n+                                \"Proactive memory flush failed (%d/%d) for %s: %s\",\n+                                failures, _MAX_FLUSH_RETRIES, entry.session_id, e,\n+                            )\n             except Exception as e:\n                 logger.debug(\"Session expiry watcher error: %s\", e)\n             # Sleep in small increments so we can stop quickly","@@ -2093,7 +2112,10 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         if command:\n             try:\n                 from hermes_cli.plugins import get_plugin_command_handler\n-                plugin_handler = get_plugin_command_handler(command)\n+                # Normalize underscores to hyphens so Telegram's underscored\n+                # autocomplete form matches plugin commands registered with\n+                # hyphens. See hermes_cli/commands.py:_build_telegram_menu.\n+                plugin_handler = get_plugin_command_handler(command.replace(\"_\", \"-\"))\n                 if plugin_handler:\n                     user_args = event.get_command_args().strip()\n                     import asyncio as _aio","@@ -2104,13 +2126,20 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n             except Exception as e:\n                 logger.debug(\"Plugin command dispatch failed (non-fatal): %s\", e)\n \n-        # Skill slash commands: /skill-name loads the skill and sends to agent\n+        # Skill slash commands: /skill-name loads the skill and sends to agent.\n+        # resolve_skill_command_key() handles the Telegram underscore/hyphen\n+        # round-trip so /claude_code from Telegram autocomplete still resolves\n+        # to the claude-code skill.\n         if command:\n             try:\n-                from agent.skill_commands import get_skill_commands, build_skill_invocation_message\n+                from agent.skill_commands import (\n+                    get_skill_commands,\n+                    build_skill_invocation_message,\n+                    resolve_skill_command_key,\n+                )\n                 skill_cmds = get_skill_commands()\n-                cmd_key = f\"/{command}\"\n-                if cmd_key in skill_cmds:\n+                cmd_key = resolve_skill_command_key(command)\n+                if cmd_key is not None:\n                     # Check per-platform disabled status before executing.\n                     # get_skill_commands() only applies the *global* disabled\n                     # list at scan time; per-platform overrides need checking","@@ -2137,6 +2166,27 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                     _unavail_msg = _check_unavailable_skill(command)\n                     if _unavail_msg:\n                         return _unavail_msg\n+                    # Genuinely unrecognized /command: not a built-in, not a\n+                    # plugin, not a skill, not a known-inactive skill. Warn\n+                    # the user instead of silently forwarding it to the LLM\n+                    # as free text (which leads to silent-failure behavior\n+                    # like the model inventing a delegate_task call).\n+                    # Normalize to hyphenated form before checking known\n+                    # built-ins (command may be an alias target set by the\n+                    # quick-command block above, so _cmd_def can be stale).\n+                    if command.replace(\"_\", \"-\") not in GATEWAY_KNOWN_COMMANDS:\n+                        logger.warning(\n+                            \"Unrecognized slash command /%s from %s — \"\n+                            \"replying with unknown-command notice\",\n+                            command,\n+                            source.platform.value if source.platform else \"?\",\n+                        )\n+                        return (\n+                            f\"Unknown command `/{command}`. \"\n+                            f\"Type /commands to see what's available, \"\n+                            f\"or resend without the leading slash to send \"\n+                            f\"as a regular message.\"\n+                        )\n             except Exception as e:\n                 logger.debug(\"Skill command check failed (non-fatal): %s\", e)\n         ","@@ -6183,6 +6233,14 @@ def _status_callback_sync(event_type: str, message: str) -> None:\n                 logger.debug(\"status_callback error (%s): %s\", event_type, _e)\n \n         def run_sync():\n+            # The conditional re-assignment of `message` further below\n+            # (prepending model-switch notes) makes Python treat it as a\n+            # local variable in the entire function.  `nonlocal` lets us\n+            # read *and* reassign the outer `_run_agent` parameter without\n+            # triggering an UnboundLocalError on the earlier read at\n+            # `_resolve_turn_agent_config(message, …)`.\n+            nonlocal message\n+\n             # Pass session_key to process registry via env var so background\n             # processes can be mapped back to this gateway session\n             os.environ[\"HERMES_SESSION_KEY\"] = session_key or \"\""]},{"file":"hermes_cli/gateway.py","hunks":["@@ -873,6 +873,11 @@ def get_launchd_label() -> str:\n     return f\"ai.hermes.gateway-{suffix}\" if suffix else \"ai.hermes.gateway\"\n \n \n+def _launchd_domain() -> str:\n+    import os\n+    return f\"gui/{os.getuid()}\"\n+\n+\n def generate_launchd_plist() -> str:\n     python_path = get_python_path()\n     working_dir = str(PROJECT_ROOT)","@@ -963,18 +968,19 @@ def launchd_plist_is_current() -> bool:\n def refresh_launchd_plist_if_needed() -> bool:\n     \"\"\"Rewrite the installed launchd plist when the generated definition has changed.\n \n-    Unlike systemd, launchd picks up plist changes on the next ``launchctl stop``/\n-    ``launchctl start`` cycle — no daemon-reload is needed.  We still unload/reload\n-    to make launchd re-read the updated plist immediately.\n+    Unlike systemd, launchd picks up plist changes on the next ``launchctl kill``/\n+    ``launchctl kickstart`` cycle — no daemon-reload is needed. We still bootout/\n+    bootstrap to make launchd re-read the updated plist immediately.\n     \"\"\"\n     plist_path = get_launchd_plist_path()\n     if not plist_path.exists() or launchd_plist_is_current():\n         return False\n \n     plist_path.write_text(generate_launchd_plist(), encoding=\"utf-8\")\n-    # Unload/reload so launchd picks up the new definition\n-    subprocess.run([\"launchctl\", \"unload\", str(plist_path)], check=False)\n-    subprocess.run([\"launchctl\", \"load\", str(plist_path)], check=False)\n+    label = get_launchd_label()\n+    # Bootout/bootstrap so launchd picks up the new definition\n+    subprocess.run([\"launchctl\", \"bootout\", f\"{_launchd_domain()}/{label}\"], check=False)\n+    subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=False)\n     print(\"↻ Updated gateway launchd service definition to match the current Hermes install\")\n     return True\n ","@@ -996,7 +1002,7 @@ def launchd_install(force: bool = False):\n     print(f\"Installing launchd service to: {plist_path}\")\n     plist_path.write_text(generate_launchd_plist())\n     \n-    subprocess.run([\"launchctl\", \"load\", str(plist_path)], check=True)\n+    subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=True)\n     \n     print()\n     print(\"✓ Service installed and loaded!\")","@@ -1008,7 +1014,8 @@ def launchd_install(force: bool = False):\n \n def launchd_uninstall():\n     plist_path = get_launchd_plist_path()\n-    subprocess.run([\"launchctl\", \"unload\", str(plist_path)], check=False)\n+    label = get_launchd_label()\n+    subprocess.run([\"launchctl\", \"bootout\", f\"{_launchd_domain()}/{label}\"], check=False)\n     \n     if plist_path.exists():\n         plist_path.unlink()","@@ -1025,25 +1032,25 @@ def launchd_start():\n         print(\"↻ launchd plist missing; regenerating service definition\")\n         plist_path.parent.mkdir(parents=True, exist_ok=True)\n         plist_path.write_text(generate_launchd_plist(), encoding=\"utf-8\")\n-        subprocess.run([\"launchctl\", \"load\", str(plist_path)], check=True)\n-        subprocess.run([\"launchctl\", \"start\", label], check=True)\n+        subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=True)\n+        subprocess.run([\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"], check=True)\n         print(\"✓ Service started\")\n         return\n \n     refresh_launchd_plist_if_needed()\n     try:\n-        subprocess.run([\"launchctl\", \"start\", label], check=True)\n+        subprocess.run([\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"], check=True)\n     except subprocess.CalledProcessError as e:\n         if e.returncode != 3:\n             raise\n         print(\"↻ launchd job was unloaded; reloading service definition\")\n-        subprocess.run([\"launchctl\", \"load\", str(plist_path)], check=True)\n-        subprocess.run([\"launchctl\", \"start\", label], check=True)\n+        subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=True)\n+        subprocess.run([\"launchctl\", \"kickstart\", f\"{_launchd_domain()}/{label}\"], check=True)\n     print(\"✓ Service started\")\n \n def launchd_stop():\n     label = get_launchd_label()\n-    subprocess.run([\"launchctl\", \"stop\", label], check=True)\n+    subprocess.run([\"launchctl\", \"kill\", \"SIGTERM\", f\"{_launchd_domain()}/{label}\"], check=True)\n     print(\"✓ Service stopped\")\n \n def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):","@@ -1087,14 +1094,23 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):\n \n \n def launchd_restart():\n+    label = get_launchd_label()\n+    target = f\"{_launchd_domain()}/{label}\"\n+    # Use kickstart -k so launchd performs an atomic kill+restart.\n+    # A two-step stop/start from inside the gateway's own process tree\n+    # would kill the shell before the start command is reached.\n     try:\n-        launchd_stop()\n+        subprocess.run([\"launchctl\", \"kickstart\", \"-k\", target], check=True)\n+        print(\"✓ Service restarted\")\n     except subprocess.CalledProcessError as e:\n         if e.returncode != 3:\n             raise\n-        print(\"↻ launchd job was unloaded; skipping stop\")\n-    _wait_for_gateway_exit()\n-    launchd_start()\n+        # Job not loaded — bootstrap and start fresh\n+        print(\"↻ launchd job was unloaded; reloading\")\n+        plist_path = get_launchd_plist_path()\n+        subprocess.run([\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)], check=True)\n+        subprocess.run([\"launchctl\", \"kickstart\", target], check=True)\n+        print(\"✓ Service restarted\")\n \n def launchd_status(deep: bool = False):\n     plist_path = get_launchd_plist_path()"]},{"file":"pyproject.toml","hunks":["@@ -40,7 +40,7 @@ dependencies = [\n modal = [\"modal>=1.0.0,<2\"]\n daytona = [\"daytona>=0.148.0,<1\"]\n dev = [\"debugpy>=1.8.0,<2\", \"pytest>=9.0.2,<10\", \"pytest-asyncio>=1.3.0,<2\", \"pytest-xdist>=3.0,<4\", \"mcp>=1.2.0,<2\"]\n-messaging = [\"python-telegram-bot>=22.6,<23\", \"discord.py[voice]>=2.7.1,<3\", \"aiohttp>=3.13.3,<4\", \"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]\n+messaging = [\"python-telegram-bot[webhooks]>=22.6,<23\", \"discord.py[voice]>=2.7.1,<3\", \"aiohttp>=3.13.3,<4\", \"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]\n cron = [\"croniter>=6.0.0,<7\"]\n slack = [\"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]\n matrix = [\"matrix-nio[e2e]>=0.24.0,<1\", \"Markdown>=3.6,<4\"]"]},{"file":"requirements.txt","hunks":["@@ -31,6 +31,6 @@ edge-tts\n croniter\n \n # Optional: For messaging platform integrations (gateway)\n-python-telegram-bot>=20.0\n+python-telegram-bot[webhooks]>=22.6\n discord.py>=2.0\n aiohttp>=3.9.0"]},{"file":"tests/agent/test_skill_commands.py","hunks":["@@ -10,6 +10,7 @@\n     build_plan_path,\n     build_preloaded_skills_prompt,\n     build_skill_invocation_message,\n+    resolve_skill_command_key,\n     scan_skill_commands,\n )\n ","@@ -101,6 +102,53 @@ def test_excludes_disabled_skills(self, tmp_path):\n         assert \"/disabled-skill\" not in result\n \n \n+class TestResolveSkillCommandKey:\n+    \"\"\"Telegram bot-command names disallow hyphens, so the menu registers\n+    skills with hyphens swapped for underscores. When Telegram autocomplete\n+    sends the underscored form back, we need to find the hyphenated key.\n+    \"\"\"\n+\n+    def test_hyphenated_form_matches_directly(self, tmp_path):\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            _make_skill(tmp_path, \"claude-code\")\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"claude-code\") == \"/claude-code\"\n+\n+    def test_underscore_form_resolves_to_hyphenated_skill(self, tmp_path):\n+        \"\"\"/claude_code from Telegram autocomplete must resolve to /claude-code.\"\"\"\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            _make_skill(tmp_path, \"claude-code\")\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"claude_code\") == \"/claude-code\"\n+\n+    def test_single_word_command_resolves(self, tmp_path):\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            _make_skill(tmp_path, \"investigate\")\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"investigate\") == \"/investigate\"\n+\n+    def test_unknown_command_returns_none(self, tmp_path):\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            _make_skill(tmp_path, \"claude-code\")\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"does_not_exist\") is None\n+            assert resolve_skill_command_key(\"does-not-exist\") is None\n+\n+    def test_empty_command_returns_none(self, tmp_path):\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"\") is None\n+\n+    def test_hyphenated_command_is_not_mangled(self, tmp_path):\n+        \"\"\"A user-typed /foo-bar (hyphen) must not trigger the underscore fallback.\"\"\"\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            _make_skill(tmp_path, \"foo-bar\")\n+            scan_skill_commands()\n+            assert resolve_skill_command_key(\"foo-bar\") == \"/foo-bar\"\n+            # Underscore form also works (Telegram round-trip)\n+            assert resolve_skill_command_key(\"foo_bar\") == \"/foo-bar\"\n+\n+\n class TestBuildPreloadedSkillsPrompt:\n     def test_builds_prompt_for_multiple_named_skills(self, tmp_path):\n         with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -90,8 +90,9 @@ def test_human_friendly_label_resolved_via_channel_directory(self):\n         with patch(\n             \"gateway.channel_directory.resolve_channel_name\",\n             return_value=\"12345678901234@lid\",\n-        ):\n+        ) as resolve_mock:\n             result = _resolve_delivery_target(job)\n+        resolve_mock.assert_called_once_with(\"whatsapp\", \"Alice (dm)\")\n         assert result == {\n             \"platform\": \"whatsapp\",\n             \"chat_id\": \"12345678901234@lid\",","@@ -112,6 +113,20 @@ def test_human_friendly_label_without_suffix_resolved(self):\n             \"thread_id\": None,\n         }\n \n+    def test_human_friendly_topic_label_preserves_thread_id(self):\n+        \"\"\"Resolved Telegram topic labels should split chat_id and thread_id.\"\"\"\n+        job = {\"deliver\": \"telegram:Coaching Chat / topic 17585 (group)\"}\n+        with patch(\n+            \"gateway.channel_directory.resolve_channel_name\",\n+            return_value=\"-1009999:17585\",\n+        ):\n+            result = _resolve_delivery_target(job)\n+        assert result == {\n+            \"platform\": \"telegram\",\n+            \"chat_id\": \"-1009999\",\n+            \"thread_id\": \"17585\",\n+        }\n+\n     def test_raw_id_not_mangled_when_directory_returns_none(self):\n         \"\"\"deliver: 'whatsapp:12345@lid' passes through when directory has no match.\"\"\"\n         job = {\"deliver\": \"whatsapp:12345@lid\"}"]},{"file":"tests/gateway/test_channel_directory.py","hunks":["@@ -119,6 +119,19 @@ def test_topic_name_resolves_to_composite_id(self, tmp_path):\n         with self._setup(tmp_path, platforms):\n             assert resolve_channel_name(\"telegram\", \"Coaching Chat / topic 17585\") == \"-1001:17585\"\n \n+    def test_display_label_with_type_suffix_resolves(self, tmp_path):\n+        platforms = {\n+            \"telegram\": [\n+                {\"id\": \"123\", \"name\": \"Alice\", \"type\": \"dm\"},\n+                {\"id\": \"456\", \"name\": \"Dev Group\", \"type\": \"group\"},\n+                {\"id\": \"-1001:17585\", \"name\": \"Coaching Chat / topic 17585\", \"type\": \"group\"},\n+            ]\n+        }\n+        with self._setup(tmp_path, platforms):\n+            assert resolve_channel_name(\"telegram\", \"Alice (dm)\") == \"123\"\n+            assert resolve_channel_name(\"telegram\", \"Dev Group (group)\") == \"456\"\n+            assert resolve_channel_name(\"telegram\", \"Coaching Chat / topic 17585 (group)\") == \"-1001:17585\"\n+\n \n class TestBuildFromSessions:\n     def _write_sessions(self, tmp_path, sessions_data):"]},{"file":"tests/gateway/test_status_command.py","hunks":["@@ -128,3 +128,61 @@ async def test_handle_message_persists_agent_token_counts(monkeypatch):\n         session_entry.session_key,\n         last_prompt_tokens=80,\n     )\n+\n+\n+\n[redacted:email]\n+async def test_status_command_bypasses_active_session_guard():\n+    \"\"\"When an agent is running, /status must be dispatched immediately via\n+    base.handle_message — not queued or treated as an interrupt (#5046).\"\"\"\n+    import asyncio\n+    from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType\n+    from gateway.session import build_session_key\n+    from gateway.config import Platform, PlatformConfig, GatewayConfig\n+\n+    source = _make_source()\n+    session_key = build_session_key(source)\n+\n+    handler_called_with = []\n+\n+    async def fake_handler(event):\n+        handler_called_with.append(event)\n+        return \"📊 **Hermes Gateway Status**\\n**Agent Running:** Yes ⚡\"\n+\n+    # Concrete subclass to avoid abstract method errors\n+    class _ConcreteAdapter(BasePlatformAdapter):\n+        platform = Platform.TELEGRAM\n+\n+        async def connect(self): pass\n+        async def disconnect(self): pass\n+        async def send(self, chat_id, content, **kwargs): pass\n+        async def get_chat_info(self, chat_id): return {}\n+\n+    platform_config = PlatformConfig(enabled=True, token=\"***\")\n+    adapter = _ConcreteAdapter(platform_config, Platform.TELEGRAM)\n+    adapter.set_message_handler(fake_handler)\n+\n+    sent = []\n+\n+    async def fake_send_with_retry(chat_id, content, reply_to=None, metadata=None):\n+        sent.append(content)\n+\n+    adapter._send_with_retry = fake_send_with_retry\n+\n+    # Simulate an active session\n+    interrupt_event = asyncio.Event()\n+    adapter._active_sessions[session_key] = interrupt_event\n+\n+    event = MessageEvent(\n+        text=\"/status\",\n+        source=source,\n+        message_id=\"m1\",\n+        message_type=MessageType.COMMAND,\n+    )\n+    await adapter.handle_message(event)\n+\n+    assert handler_called_with, \"/status handler was never called (event was queued or dropped)\"\n+    assert sent, \"/status response was never sent\"\n+    assert \"Agent Running\" in sent[0]\n+    assert not interrupt_event.is_set(), \"/status incorrectly triggered an agent interrupt\"\n+    assert session_key not in adapter._pending_messages, \"/status was incorrectly queued\""]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -80,7 +80,7 @@ async def fake_start_polling(**kwargs):\n         stop=AsyncMock(),\n         running=True,\n     )\n-    bot = SimpleNamespace(set_my_commands=AsyncMock())\n+    bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())\n     app = SimpleNamespace(\n         bot=bot,\n         updater=updater,","@@ -99,6 +99,7 @@ async def fake_start_polling(**kwargs):\n     ok = await adapter.connect()\n \n     assert ok is True\n+    bot.delete_webhook.assert_awaited_once_with(drop_pending_updates=False)\n     assert callable(captured[\"error_callback\"])\n \n     conflict = type(\"Conflict\", (Exception,), {})","@@ -153,7 +154,7 @@ async def failing_start_polling(**kwargs):\n         stop=AsyncMock(),\n         running=True,\n     )\n-    bot = SimpleNamespace(set_my_commands=AsyncMock())\n+    bot = SimpleNamespace(set_my_commands=AsyncMock(), delete_webhook=AsyncMock())\n     app = SimpleNamespace(\n         bot=bot,\n         updater=updater,","@@ -208,7 +209,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m\n     builder = MagicMock()\n     builder.token.return_value = builder\n     app = SimpleNamespace(\n-        bot=SimpleNamespace(),\n+        bot=SimpleNamespace(delete_webhook=AsyncMock(), set_my_commands=AsyncMock()),\n         updater=SimpleNamespace(),\n         add_handler=MagicMock(),\n         initialize=AsyncMock(side_effect=RuntimeError(\"Temporary failure in name resolution\")),","@@ -225,6 +226,49 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m\n     assert \"Temporary failure in name resolution\" in adapter.fatal_error_message\n \n \n[redacted:email]\n+async def test_connect_clears_webhook_before_polling(monkeypatch):\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))\n+\n+    monkeypatch.setattr(\n+        \"gateway.status.acquire_scoped_lock\",\n+        lambda scope, identity, metadata=None: (True, None),\n+    )\n+    monkeypatch.setattr(\n+        \"gateway.status.release_scoped_lock\",\n+        lambda scope, identity: None,\n+    )\n+\n+    updater = SimpleNamespace(\n+        start_polling=AsyncMock(),\n+        stop=AsyncMock(),\n+        running=True,\n+    )\n+    bot = SimpleNamespace(\n+        delete_webhook=AsyncMock(),\n+        set_my_commands=AsyncMock(),\n+    )\n+    app = SimpleNamespace(\n+        bot=bot,\n+        updater=updater,\n+        add_handler=MagicMock(),\n+        initialize=AsyncMock(),\n+        start=AsyncMock(),\n+    )\n+    builder = MagicMock()\n+    builder.token.return_value = builder\n+    builder.build.return_value = app\n+    monkeypatch.setattr(\n+        \"gateway.platforms.telegram.Application\",\n+        SimpleNamespace(builder=MagicMock(return_value=builder)),\n+    )\n+\n+    ok = await adapter.connect()\n+\n+    assert ok is True\n+    bot.delete_webhook.assert_awaited_once_with(drop_pending_updates=False)\n+\n+\n @pytest.mark.asyncio\n async def test_disconnect_skips_inactive_updater_and_app(monkeypatch):\n     adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\"))"]},{"file":"tests/gateway/test_telegram_thread_fallback.py","hunks":["@@ -37,6 +37,12 @@ class FakeTimedOut(FakeNetworkError):\n     pass\n \n \n+class FakeRetryAfter(Exception):\n+    def __init__(self, seconds):\n+        super().__init__(f\"Retry after {seconds}\")\n+        self.retry_after = seconds\n+\n+\n # Build a fake telegram module tree so the adapter's internal imports work\n _fake_telegram = types.ModuleType(\"telegram\")\n _fake_telegram_error = types.ModuleType(\"telegram.error\")","@@ -230,3 +236,25 @@ async def mock_send_message(**kwargs):\n     # Second chunk: should use thread_id=None directly (effective_thread_id\n     # was cleared per-chunk but the metadata doesn't change between chunks)\n     # The key point: the message was delivered despite the invalid thread\n+\n+\n[redacted:email]\n+async def test_send_retries_retry_after_errors():\n+    \"\"\"Telegram flood control should back off and retry instead of failing fast.\"\"\"\n+    adapter = _make_adapter()\n+\n+    attempt = [0]\n+\n+    async def mock_send_message(**kwargs):\n+        attempt[0] += 1\n+        if attempt[0] == 1:\n+            raise FakeRetryAfter(2)\n+        return SimpleNamespace(message_id=300)\n+\n+    adapter._bot = SimpleNamespace(send_message=mock_send_message)\n+\n+    result = await adapter.send(chat_id=\"123\", content=\"test message\")\n+\n+    assert result.success is True\n+    assert result.message_id == \"300\"\n+    assert attempt[0] == 2"]},{"file":"tests/gateway/test_unknown_command.py","hunks":["@@ -0,0 +1,166 @@\n+\"\"\"Tests for gateway warning when an unrecognized /command is dispatched.\n+\n+Without this warning, unknown slash commands get forwarded to the LLM as plain\n+text, which often leads to silent failure (e.g. the model inventing a bogus\n+delegate_task call instead of telling the user the command doesn't exist).\n+\"\"\"\n+\n+from datetime import datetime\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource, build_session_key\n+\n+\n+def _make_source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        user_id=\"u1\",\n+        chat_id=\"c1\",\n+        user_name=\"tester\",\n+        chat_type=\"dm\",\n+    )\n+\n+\n+def _make_event(text: str) -> MessageEvent:\n+    return MessageEvent(text=text, source=_make_source(), message_id=\"m1\")\n+\n+\n+def _make_runner():\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    adapter = MagicMock()\n+    adapter.send = AsyncMock()\n+    runner.adapters = {Platform.TELEGRAM: adapter}\n+    runner._voice_mode = {}\n+    runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)\n+\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner.session_store = MagicMock()\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.load_transcript.return_value = []\n+    runner.session_store.has_any_sessions.return_value = True\n+    runner.session_store.append_to_transcript = MagicMock()\n+    runner.session_store.rewrite_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+    runner._running_agents = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._session_db = None\n+    runner._reasoning_config = None\n+    runner._provider_routing = {}\n+    runner._fallback_model = None\n+    runner._show_reasoning = False\n+    runner._is_user_authorized = lambda _source: True\n+    runner._set_session_env = lambda _context: None\n+    runner._should_send_voice_reply = lambda *_args, **_kwargs: False\n+    runner._send_voice_reply = AsyncMock()\n+    runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None\n+    runner._emit_gateway_run_progress = AsyncMock()\n+    return runner\n+\n+\n[redacted:email]\n+async def test_unknown_slash_command_returns_guidance(monkeypatch):\n+    \"\"\"A genuinely unknown /foobar should return user-facing guidance, not\n+    silently drop through to the LLM.\"\"\"\n+    import gateway.run as gateway_run\n+\n+    runner = _make_runner()\n+    # If the LLM were called, this would fail: the guard must short-circuit\n+    # before _run_agent is invoked.\n+    runner._run_agent = AsyncMock(\n+        side_effect=AssertionError(\n+            \"unknown slash command leaked through to the agent\"\n+        )\n+    )\n+\n+    monkeypatch.setattr(\n+        gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"}\n+    )\n+\n+    result = await runner._handle_message(_make_event(\"/definitely-not-a-command\"))\n+\n+    assert result is not None\n+    assert \"Unknown command\" in result\n+    assert \"/definitely-not-a-command\" in result\n+    assert \"/commands\" in result\n+    runner._run_agent.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_unknown_slash_command_underscored_form_also_guarded(monkeypatch):\n+    \"\"\"Telegram may send /foo_bar — same guard must trigger for underscored\n+    commands that normalize to unknown hyphenated names.\"\"\"\n+    import gateway.run as gateway_run\n+\n+    runner = _make_runner()\n+    runner._run_agent = AsyncMock(\n+        side_effect=AssertionError(\n+            \"unknown slash command leaked through to the agent\"\n+        )\n+    )\n+\n+    monkeypatch.setattr(\n+        gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"}\n+    )\n+\n+    result = await runner._handle_message(_make_event(\"/made_up_thing\"))\n+\n+    assert result is not None\n+    assert \"Unknown command\" in result\n+    assert \"/made_up_thing\" in result\n+    runner._run_agent.assert_not_called()\n+\n+\n[redacted:email]\n+async def test_known_slash_command_not_flagged_as_unknown(monkeypatch):\n+    \"\"\"A real built-in like /status must NOT hit the unknown-command guard.\"\"\"\n+    runner = _make_runner()\n+    # Make _handle_status_command exist via the normal path by running a real\n+    # dispatch. If the guard fires, the return string will mention \"Unknown\".\n+    runner._running_agents[build_session_key(_make_source())] = MagicMock()\n+\n+    result = await runner._handle_message(_make_event(\"/status\"))\n+\n+    assert result is not None\n+    assert \"Unknown command\" not in result\n+\n+\n[redacted:email]\n+async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch):\n+    \"\"\"Telegram autocomplete sends /reload_mcp for the /reload-mcp built-in.\n+    That must NOT be flagged as unknown.\"\"\"\n+    import gateway.run as gateway_run\n+\n+    runner = _make_runner()\n+    # Prevent real MCP work; we only care that the unknown guard doesn't fire.\n+    async def _noop_reload(*_a, **_kw):\n+        return \"mcp reloaded\"\n+\n+    runner._handle_reload_mcp_command = _noop_reload  # type: ignore[attr-defined]\n+\n+    monkeypatch.setattr(\n+        gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"}\n+    )\n+\n+    result = await runner._handle_message(_make_event(\"/reload_mcp\"))\n+\n+    # Whatever /reload_mcp returns, it must not be the unknown-command guard.\n+    if result is not None:\n+        assert \"Unknown command\" not in result"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -171,10 +171,12 @@ def fake_run(cmd, check=False, **kwargs):\n \n         gateway_cli.launchd_install()\n \n+        label = gateway_cli.get_launchd_label()\n+        domain = gateway_cli._launchd_domain()\n         assert \"--replace\" in plist_path.read_text(encoding=\"utf-8\")\n         assert calls[:2] == [\n-            [\"launchctl\", \"unload\", str(plist_path)],\n-            [\"launchctl\", \"load\", str(plist_path)],\n+            [\"launchctl\", \"bootout\", f\"{domain}/{label}\"],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n         ]\n \n     def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):","@@ -183,10 +185,12 @@ def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypa\n         label = gateway_cli.get_launchd_label()\n \n         calls = []\n+        domain = gateway_cli._launchd_domain()\n+        target = f\"{domain}/{label}\"\n \n         def fake_run(cmd, check=False, **kwargs):\n             calls.append(cmd)\n-            if cmd == [\"launchctl\", \"start\", label] and calls.count(cmd) == 1:\n+            if cmd == [\"launchctl\", \"kickstart\", target] and calls.count(cmd) == 1:\n                 raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr=\"Could not find service\")\n             return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n ","@@ -196,9 +200,9 @@ def fake_run(cmd, check=False, **kwargs):\n         gateway_cli.launchd_start()\n \n         assert calls == [\n-            [\"launchctl\", \"start\", label],\n-            [\"launchctl\", \"load\", str(plist_path)],\n-            [\"launchctl\", \"start\", label],\n+            [\"launchctl\", \"kickstart\", target],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n+            [\"launchctl\", \"kickstart\", target],\n         ]\n \n     def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):","@@ -293,7 +297,7 @@ def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fa\n             gateway_cli,\n             \"launchd_restart\",\n             lambda: (_ for _ in ()).throw(\n-                gateway_cli.subprocess.CalledProcessError(5, [\"launchctl\", \"start\", \"ai.hermes.gateway\"])\n+                gateway_cli.subprocess.CalledProcessError(5, [\"launchctl\", \"kickstart\", \"-k\", \"gui/501/ai.hermes.gateway\"])\n             ),\n         )\n "]},{"file":"tests/hermes_cli/test_update_gateway_restart.py","hunks":["@@ -218,9 +218,9 @@ def fake_run(cmd, check=False, **kwargs):\n         assert result is True\n         # Plist should now contain the generated content (which includes --replace)\n         assert \"--replace\" in plist_path.read_text()\n-        # Should have unloaded then reloaded\n-        assert any(\"unload\" in str(c) for c in calls)\n-        assert any(\"load\" in str(c) for c in calls)\n+        # Should have booted out then bootstrapped\n+        assert any(\"bootout\" in str(c) for c in calls)\n+        assert any(\"bootstrap\" in str(c) for c in calls)\n \n     def test_refresh_skips_when_current(self, tmp_path, monkeypatch):\n         plist_path = tmp_path / \"ai.hermes.gateway.plist\"","@@ -262,10 +262,10 @@ def fake_run(cmd, check=False, **kwargs):\n \n         gateway_cli.launchd_start()\n \n-        # First calls should be refresh (unload/load), then start\n+        # First calls should be refresh (bootout/bootstrap), then kickstart\n         cmd_strs = [\" \".join(c) for c in calls]\n-        assert any(\"unload\" in s for s in cmd_strs)\n-        assert any(\"start\" in s for s in cmd_strs)\n+        assert any(\"bootout\" in s for s in cmd_strs)\n+        assert any(\"kickstart\" in s for s in cmd_strs)\n \n     def test_launchd_start_recreates_missing_plist_and_loads_service(self, tmp_path, monkeypatch):\n         \"\"\"launchd_start self-heals when the plist file is missing entirely.\"\"\"","@@ -288,11 +288,11 @@ def fake_run(cmd, check=False, **kwargs):\n         assert \"--replace\" in plist_path.read_text()\n \n         cmd_strs = [\" \".join(c) for c in calls]\n-        # Should load the new plist, then start\n-        assert any(\"load\" in s for s in cmd_strs)\n-        assert any(\"start\" in s for s in cmd_strs)\n-        # Should NOT call unload (nothing to unload)\n-        assert not any(\"unload\" in s for s in cmd_strs)\n+        # Should bootstrap the new plist, then kickstart\n+        assert any(\"bootstrap\" in s for s in cmd_strs)\n+        assert any(\"kickstart\" in s for s in cmd_strs)\n+        # Should NOT call bootout (nothing to bootout)\n+        assert not any(\"bootout\" in s for s in cmd_strs)\n \n \n class TestCmdUpdateLaunchdRestart:"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -203,6 +203,44 @@ def test_resolved_telegram_topic_name_preserves_thread_id(self):\n             media_files=[],\n         )\n \n+    def test_display_label_target_resolves_via_channel_directory(self, tmp_path):\n+        config, telegram_cfg = _make_config()\n+        cache_file = tmp_path / \"channel_directory.json\"\n+        cache_file.write_text(json.dumps({\n+            \"updated_at\": \"2026-01-01T00:00:00\",\n+            \"platforms\": {\n+                \"telegram\": [\n+                    {\"id\": \"-1001:17585\", \"name\": \"Coaching Chat / topic 17585\", \"type\": \"group\"}\n+                ]\n+            },\n+        }))\n+\n+        with patch(\"gateway.channel_directory.DIRECTORY_PATH\", cache_file), \\\n+             patch(\"gateway.config.load_gateway_config\", return_value=config), \\\n+             patch(\"tools.interrupt.is_interrupted\", return_value=False), \\\n+             patch(\"model_tools._run_async\", side_effect=_run_async_immediately), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})) as send_mock, \\\n+             patch(\"gateway.mirror.mirror_to_session\", return_value=True):\n+            result = json.loads(\n+                send_message_tool(\n+                    {\n+                        \"action\": \"send\",\n+                        \"target\": \"telegram:Coaching Chat / topic 17585 (group)\",\n+                        \"message\": \"hello\",\n+                    }\n+                )\n+            )\n+\n+        assert result[\"success\"] is True\n+        send_mock.assert_awaited_once_with(\n+            Platform.TELEGRAM,\n+            telegram_cfg,\n+            \"-1001\",\n+            \"hello\",\n+            thread_id=\"17585\",\n+            media_files=[],\n+        )\n+\n     def test_media_only_message_uses_placeholder_for_mirroring(self):\n         config, telegram_cfg = _make_config()\n "]},{"file":"uv.lock","hunks":["@@ -1689,7 +1689,7 @@ all = [\n     { name = \"pytest\" },\n     { name = \"pytest-asyncio\" },\n     { name = \"pytest-xdist\" },\n-    { name = \"python-telegram-bot\" },\n+    { name = \"python-telegram-bot\", extra = [\"webhooks\"] },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32'\" },\n     { name = \"simple-term-menu\" },\n     { name = \"slack-bolt\" },","@@ -1733,7 +1733,7 @@ mcp = [\n messaging = [\n     { name = \"aiohttp\" },\n     { name = \"discord-py\", extra = [\"voice\"] },\n-    { name = \"python-telegram-bot\" },\n+    { name = \"python-telegram-bot\", extra = [\"webhooks\"] },\n     { name = \"slack-bolt\" },\n     { name = \"slack-sdk\" },\n ]","@@ -1827,7 +1827,7 @@ requires-dist = [\n     { name = \"pytest-asyncio\", marker = \"extra == 'dev'\", specifier = \">=1.3.0,<2\" },\n     { name = \"pytest-xdist\", marker = \"extra == 'dev'\", specifier = \">=3.0,<4\" },\n     { name = \"python-dotenv\", specifier = \">=1.2.1,<2\" },\n-    { name = \"python-telegram-bot\", marker = \"extra == 'messaging'\", specifier = \">=22.6,<23\" },\n+    { name = \"python-telegram-bot\", extras = [\"webhooks\"], marker = \"extra == 'messaging'\", specifier = \">=22.6,<23\" },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32' and extra == 'pty'\", specifier = \">=2.0.0,<3\" },\n     { name = \"pyyaml\", specifier = \">=6.0.2,<7\" },\n     { name = \"requests\", specifier = \">=2.33.0,<3\" },","@@ -3964,6 +3964,11 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl\", hash = \"sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0\", size = 737267, upload-time = \"2026-01-24T13:56:58.06Z\" },\n ]\n \n+[package.optional-dependencies]\n+webhooks = [\n+    { name = \"tornado\" },\n+]\n+\n [[package]]\n name = \"pytz\"\n version = \"2025.2\"\n"]}]},{"kind":"pr","number":24735,"title":"fix(lazy_deps): use sys.prefix for venv_root in Docker overlay environments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24698","number":24698}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26394,"title":"fix(docker): preload messaging gateway deps","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a2cc30544","number":0},{"kind":"pr","value":"#27558","number":27558}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":27056,"title":"fix(nix): include messaging dependencies by default","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":27115,"title":"fix(docker): bake messaging extras into the image (#27100)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27100","number":27100}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27558,"title":"fix(docker): preload messaging gateway deps via --extra messaging (salvage #26394)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","scripts/release.py","tests/tools/test_dockerfile_pid1_reaping.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}