{"repo":"NousResearch/hermes-agent","public_id":"23620","display_label":"Kanban column label undefined aria-label bug fixes","summary":"Multiple reports and fix attempts address an undefined COLUMN_LABEL variable causing rendering errors and broken aria-labels on kanban column select-all checkboxes. One unrelated member covers SPA auto-reload on stale-token 401 responses.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23791},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-11T13:44:58+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":23180,"pr_number":23791,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23216,"pr_number":23791,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":23620,"pr_number":23791,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":23620,"title":"[Bug]: Kanban dashboard: COLUMN_LABEL undefined in aria-label at column select-all checkbox","url":null,"state":"closed"},{"kind":"issue","number":24188,"title":"Kanban dashboard tab — rendering error: COLUMN_LABEL is not defined","url":null,"state":"closed"},{"kind":"pr","number":23637,"title":"fix(kanban): use FALLBACK_COLUMN_LABEL in column select-all aria-label","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23791","number":23791},{"kind":"pr","value":"#23659","number":23659},{"kind":"pr","value":"#23659","number":23659}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23659,"title":"fix(kanban): use localized column label in select-all aria label","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23791","number":23791}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23791,"title":"fix(kanban): batch-salvage 7 community kanban bug fixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","hermes_cli/kanban.py","hermes_cli/kanban_db.py","plugins/kanban/dashboard/dist/index.js","run_agent.py","scripts/release.py","tests/hermes_cli/test_kanban_cli.py","tests/hermes_cli/test_kanban_notify.py","tests/run_agent/test_run_agent.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -8329,6 +8329,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n         \"\"\"\n         import asyncio\n         import re\n+        import shlex\n         from hermes_cli.kanban import run_slash\n \n         text = (event.text or \"\").strip()","@@ -8338,7 +8339,26 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n         if text.startswith(\"kanban\"):\n             text = text[len(\"kanban\"):].lstrip()\n \n-        is_create = text.split(None, 1)[:1] == [\"create\"]\n+        tokens = [redacted:secret-assignment] if text else []\n+        requested_board = None\n+        action = None\n+        i = 0\n+        while i < len(tokens):\n+            tok = tokens[i]\n+            if tok == \"--board\":\n+                if i + 1 >= len(tokens):\n+                    break\n+                requested_board = tokens[i + 1]\n+                i += 2\n+                continue\n+            if tok.startswith(\"--board=\"):\n+                requested_board = tok.split(\"=\", 1)[1]\n+                i += 1\n+                continue\n+            action = tok\n+            break\n+\n+        is_create = action == \"create\"\n \n         try:\n             output = await asyncio.to_thread(run_slash, text)","@@ -8365,7 +8385,7 @@ async def _handle_kanban_command(self, event: MessageEvent) -> str:\n                     if platform_str and chat_id:\n                         def _sub():\n                             from hermes_cli import kanban_db as _kb\n-                            conn = _kb.connect()\n+                            conn = _kb.connect(board=requested_board)\n                             try:\n                                 _kb.add_notify_sub(\n                                     conn, task_id=task_id,"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -652,6 +652,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n     # keeps the patch small and inherits the exact same resolution the\n     # dispatcher uses for workers — consistency is a feature here.\n     board_override = getattr(args, \"board\", None)\n+    prev_board_env = os.environ.get(\"HERMES_KANBAN_BOARD\")\n+    restore_board_env = False\n+\n+    def _restore_board_env() -> None:\n+        if not restore_board_env:\n+            return\n+        if prev_board_env is None:\n+            os.environ.pop(\"HERMES_KANBAN_BOARD\", None)\n+        else:\n+            os.environ[\"HERMES_KANBAN_BOARD\"] = prev_board_env\n     if board_override:\n         try:\n             normed = kb._normalize_board_slug(board_override)","@@ -671,12 +681,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n             )\n             return 1\n         os.environ[\"HERMES_KANBAN_BOARD\"] = normed\n+        restore_board_env = True\n \n     # Boards management doesn't touch the DB at all — dispatch early so\n     # fresh installs that haven't initialized any DB can still use\n     # `hermes kanban boards create …`.\n     if action == \"boards\":\n-        return _dispatch_boards(args)\n+        try:\n+            return _dispatch_boards(args)\n+        finally:\n+            _restore_board_env()\n \n     # Auto-initialize the DB before dispatching any subcommand. init_db\n     # is idempotent, so running it every invocation is cheap (one","@@ -689,6 +703,7 @@ def kanban_command(args: argparse.Namespace) -> int:\n         kb.init_db()\n     except Exception as exc:\n         print(f\"kanban: could not initialize database: {exc}\", file=sys.stderr)\n+        _restore_board_env()\n         return 1\n \n     handlers = {","@@ -730,12 +745,16 @@ def kanban_command(args: argparse.Namespace) -> int:\n     handler = handlers.get(action)\n     if not handler:\n         print(f\"kanban: unknown action {action!r}\", file=sys.stderr)\n+        _restore_board_env()\n         return 2\n     try:\n         return int(handler(args) or 0)\n     except (ValueError, RuntimeError) as exc:\n         print(f\"kanban: {exc}\", file=sys.stderr)\n+        _restore_board_env()\n         return 1\n+    finally:\n+        _restore_board_env()\n \n \n # ---------------------------------------------------------------------------"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -1826,7 +1826,7 @@ def _synthesize_ended_run(\n # ---------------------------------------------------------------------------\n \n def recompute_ready(conn: sqlite3.Connection) -> int:\n-    \"\"\"Promote ``todo`` tasks to ``ready`` when all parents are ``done``.\n+    \"\"\"Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.\n \n     Returns the number of tasks promoted.  Safe to call inside or outside\n     an existing transaction; it opens its own IMMEDIATE txn.","@@ -1844,7 +1844,7 @@ def recompute_ready(conn: sqlite3.Connection) -> int:\n                 \"WHERE l.child_id = ?\",\n                 (task_id,),\n             ).fetchall()\n-            if all(p[\"status\"] == \"done\" for p in parents):\n+            if all(p[\"status\"] in (\"done\", \"archived\") for p in parents):\n                 conn.execute(\n                     \"UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'\",\n                     (task_id,),","@@ -1885,7 +1885,7 @@ def claim_task(\n         undone = conn.execute(\n             \"SELECT 1 FROM task_links l \"\n             \"JOIN tasks p ON p.id = l.parent_id \"\n-            \"WHERE l.child_id = ? AND p.status != 'done' LIMIT 1\",\n+            \"WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1\",\n             (task_id,),\n         ).fetchone()\n         if undone:","@@ -3930,6 +3930,25 @@ def _default_spawn(\n \n     prompt = f\"work kanban task {task.id}\"\n     env = dict(os.environ)\n+\n+    # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml\n+    # (fallback_providers, toolsets, agent settings, etc.) instead of the root\n+    # config.  Without this, `env = dict(os.environ)` copies only the parent's\n+    # env, and when the child process starts `hermes -p <name>` the\n+    # _apply_profile_override() runs *before* hermes_constants is imported.\n+    # If HERMES_HOME is absent from the child's env, get_hermes_home() falls\n+    # back to Path.home() / \".hermes\" (the DEFAULT profile root), ignoring the\n+    # profile-specific config entirely.  Fixes profile-scoped fallback_providers\n+    # being invisible to kanban workers.\n+    from hermes_cli.profiles import resolve_profile_env\n+    try:\n+        env[\"HERMES_HOME\"] = resolve_profile_env(profile_arg)\n+    except FileNotFoundError:\n+        # Profile dir doesn't exist — defer resolution to the CLI's\n+        # _apply_profile_override() via HERMES_PROFILE (set below).\n+        # This only happens in test fixtures where the isolated\n+        # HERMES_HOME never had profiles created.\n+        pass\n     if task.tenant:\n         env[\"HERMES_TENANT\"] = task.tenant\n     env[\"HERMES_KANBAN_TASK\"] = task.id"]},{"file":"plugins/kanban/dashboard/dist/index.js","hunks":["@@ -1898,7 +1898,7 @@\n           type: \"checkbox\",\n           className: \"hermes-kanban-col-check\",\n           title: \"Select all tasks in this column\",\n-          \"aria-label\": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`,\n+          \"aria-label\": `Select all tasks in ${colLabel || props.column.name}`,\n           checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }),\n           onChange: function (e) {\n             e.stopPropagation();"]},{"file":"run_agent.py","hunks":["@@ -14987,7 +14987,41 @@ def _stop_spinner():\n                     \"— requesting summary...\"\n                 )\n             final_response = self._handle_max_iterations(messages, api_call_count)\n-        \n+\n+            # If running as a kanban worker, block the task so the dispatcher\n+            # knows the worker could not complete (rather than treating it as a\n+            # protocol violation).  The agent loop strips tools before calling\n+            # _handle_max_iterations, so the model cannot call kanban_block\n+            # itself — we must do it on its behalf.\n+            _kanban_task = os.environ.get(\"HERMES_KANBAN_TASK\")\n+            if _kanban_task:\n+                try:\n+                    handle_function_call(\n+                        \"kanban_block\",\n+                        {\n+                            \"task_id\": _kanban_task,\n+                            \"reason\": (\n+                                f\"Iteration budget exhausted \"\n+                                f\"({api_call_count}/{self.max_iterations}) — \"\n+                                \"task could not complete within the allowed \"\n+                                \"iterations\"\n+                            ),\n+                        },\n+                        task_id=effective_task_id,\n+                    )\n+                    logger.info(\n+                        \"kanban_block called for task %s after iteration \"\n+                        \"exhaustion (%d/%d)\",\n+                        _kanban_task, api_call_count, self.max_iterations,\n+                    )\n+                except Exception:\n+                    logger.warning(\n+                        \"Failed to call kanban_block after iteration \"\n+                        \"exhaustion for task %s\",\n+                        _kanban_task,\n+                        exc_info=True,\n+                    )\n+\n         # Determine if conversation completed successfully\n         completed = final_response is not None and api_call_count < self.max_iterations\n "]},{"file":"scripts/release.py","hunks":["@@ -971,6 +971,12 @@\n     \"[redacted:email]\": \"leehack\",  # PR #22053 salvage (telegram DM topic reply fallback)\n     # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan\n     \"[redacted:email]\": \"A-kamal\",  # PR #18678 (xAI image resolution fix)\n+    # Kanban bug-fix batch salvage (May 2026)\n+    \"[redacted:email]\": \"Frowtek\",  # salvage of #23206 (gateway --board auto-subscribe)\n+    \"[redacted:email]\": \"Sylw3ster\",  # salvage of #23252 (HERMES_KANBAN_BOARD restore)\n+    \"[redacted:email]\": \"dmnkhorvath\",  # salvage of #23358 (kanban worker send_message)\n+    \"[redacted:email]\": \"smwbev\",  # salvage of #23659 (aria-label colLabel)\n+    \"[redacted:email]\": \"TurgutKural\",  # salvage of #23356 (HERMES_HOME inject)\n }\n \n "]},{"file":"tests/hermes_cli/test_kanban_cli.py","hunks":["@@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):\n     out = kc.run_slash(\"show\")\n     assert \"/kanban show\" in out\n     assert \"task_id\" in out\n+\n+\n+def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):\n+    kb.create_board(\"alpha\")\n+    kb.create_board(\"beta\")\n+    monkeypatch.setenv(\"HERMES_KANBAN_BOARD\", \"beta\")\n+\n+    kc.run_slash(\"--board alpha list\")\n+\n+    assert os.environ.get(\"HERMES_KANBAN_BOARD\") == \"beta\""]},{"file":"tests/hermes_cli/test_kanban_notify.py","hunks":["@@ -2,6 +2,7 @@\n import pytest\n \n from pathlib import Path\n+from types import SimpleNamespace\n from hermes_cli import kanban_db as kb\n from unittest.mock import AsyncMock, MagicMock, patch\n ","@@ -429,3 +430,52 @@ async def _fast_sleep(_):\n     finally:\n         conn.close()\n     assert subs == []\n+\n+\n[redacted:email]\n+async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):\n+    \"\"\"`/kanban --board <slug> create ...` must subscribe on that board.\n+\n+    The gateway handler currently auto-subscribes after `/kanban create`,\n+    but the create detection must still work when the shared `--board`\n+    flag appears before the subcommand, and the subscription must land in\n+    that board's DB rather than the ambient/default board.\n+    \"\"\"\n+    from gateway.run import GatewayRunner\n+    from gateway.config import Platform\n+\n+    kb.create_board(\"projx\")\n+\n+    runner = object.__new__(GatewayRunner)\n+    source = SimpleNamespace(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"chat1\",\n+        thread_id=\"th1\",\n+        user_id=\"u1\",\n+    )\n+    event = SimpleNamespace(\n+        text='/kanban --board projx create \"hello\" --assignee alice',\n+        source=source,\n+    )\n+\n+    out = await GatewayRunner._handle_kanban_command(runner, event)\n+\n+    assert \"subscribed\" in out.lower()\n+\n+    conn = kb.connect(board=\"projx\")\n+    try:\n+        subs = kb.list_notify_subs(conn)\n+        tasks = kb.list_tasks(conn)\n+    finally:\n+        conn.close()\n+\n+    assert [t.title for t in tasks] == [\"hello\"]\n+    assert len(subs) == 1\n+    assert subs[0][\"chat_id\"] == \"chat1\"\n+    assert subs[0][\"thread_id\"] == \"th1\"\n+\n+    conn = kb.connect(board=\"default\")\n+    try:\n+        assert kb.list_notify_subs(conn) == []\n+    finally:\n+        conn.close()"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -3344,6 +3344,88 @@ def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent)\n         assert \"truncated due to output length limit\" in result[\"error\"]\n         mock_handle_function_call.assert_not_called()\n \n+    def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch):\n+        \"\"\"Regression: kanban worker must call kanban_block when iteration\n+        budget is exhausted, otherwise the dispatcher sees a protocol\n+        violation and gives up after 1 failure (issue #23216).\"\"\"\n+        self._setup_agent(agent)\n+        agent.max_iterations = 2\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_test_task_123\")\n+\n+        # Return a tool call for every iteration to exhaust the budget.\n+        tc = _mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")\n+        tool_resp = _mock_response(\n+            content=\"\", finish_reason=\"tool_calls\", tool_calls=[tc],\n+        )\n+        # Final summary response from _handle_max_iterations.\n+        summary_resp = _mock_response(\n+            content=\"Could not finish — budget exhausted.\", finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [\n+            tool_resp, tool_resp, summary_resp,\n+        ]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"ok\") as mock_hfc,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"do the kanban work\")\n+\n+        # The agent should have reported the task as not completed.\n+        assert result[\"completed\"] is False\n+\n+        # Among all handle_function_call invocations, one must be\n+        # kanban_block with the correct task_id and a reason mentioning\n+        # iteration exhaustion.\n+        kanban_block_calls = [\n+            c for c in mock_hfc.call_args_list\n+            if c[0][0] == \"kanban_block\"\n+        ]\n+        assert len(kanban_block_calls) == 1, (\n+            f\"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. \"\n+            f\"All calls: {mock_hfc.call_args_list}\"\n+        )\n+        call = kanban_block_calls[0]\n+        assert call[0][1][\"task_id\"] == \"t_test_task_123\"\n+        assert \"Iteration budget exhausted\" in call[0][1][\"reason\"]\n+\n+    def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch):\n+        \"\"\"kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.\"\"\"\n+        self._setup_agent(agent)\n+        agent.max_iterations = 2\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        tc = _mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")\n+        tool_resp = _mock_response(\n+            content=\"\", finish_reason=\"tool_calls\", tool_calls=[tc],\n+        )\n+        summary_resp = _mock_response(\n+            content=\"Summary.\", finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [\n+            tool_resp, tool_resp, summary_resp,\n+        ]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"ok\") as mock_hfc,\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            agent.run_conversation(\"do stuff\")\n+\n+        kanban_block_calls = [\n+            c for c in mock_hfc.call_args_list\n+            if c[0][0] == \"kanban_block\"\n+        ]\n+        assert len(kanban_block_calls) == 0, (\n+            \"kanban_block should not be called outside kanban mode\"\n+        )\n+\n \n class TestRetryExhaustion:\n     \"\"\"Regression: retry_count > max_retries was dead code (off-by-one)."]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -2229,3 +2229,106 @@ async def fake_send(pconfig, chat_id, message, **kwargs):\n         assert result[\"success\"] is True\n         assert result[\"message_id\"] == \"abc-123\"\n         assert result[\"extra_field\"] == \"preserved\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# _check_send_message — availability gating\n+# ---------------------------------------------------------------------------\n+\n+class TestCheckSendMessage:\n+    \"\"\"The tool's check_fn governs whether the model sees ``send_message`` as\n+    callable for a given session. The four passing conditions are:\n+\n+    1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher\n+       — parent gateway is by definition running, but the worker's\n+       ``HERMES_HOME`` may be a profile dir without a ``gateway.pid``).\n+    2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value\n+       (the session is wired to a messaging platform like Telegram).\n+    3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with\n+       a live gateway colocated under the same ``HERMES_HOME``).\n+    4. None of the above → False, tool is hidden.\n+    \"\"\"\n+\n+    def test_kanban_task_env_grants_access(self, monkeypatch):\n+        \"\"\"Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be\n+        allowed regardless of session_platform / gateway-pid state.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_abc12345\")\n+        monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is True\n+\n+    def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch):\n+        \"\"\"Honoring HERMES_KANBAN_TASK must not depend on importing or calling\n+        gateway.status — the worker may run with a HERMES_HOME that has no\n+        gateway.pid, and we don't want that import path to be load-bearing.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_abc12345\")\n+\n+        with patch(\"gateway.session_context.get_session_env\",\n+                   side_effect=AssertionError(\"session_context not consulted \"\n+                                              \"when HERMES_KANBAN_TASK is set\")), \\\n+             patch(\"gateway.status.is_gateway_running\",\n+                   side_effect=AssertionError(\"gateway.status not consulted \"\n+                                              \"when HERMES_KANBAN_TASK is set\")):\n+            assert _check_send_message() is True\n+\n+    def test_messaging_platform_session_grants_access(self, monkeypatch):\n+        \"\"\"Telegram/Discord/etc. sessions pass via the platform branch even\n+        without HERMES_KANBAN_TASK.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"telegram\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is True\n+\n+    def test_local_platform_falls_through_to_gateway_check(self, monkeypatch):\n+        \"\"\"``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to\n+        is_gateway_running() rather than auto-grant.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"local\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=True) as gw_mock:\n+            assert _check_send_message() is True\n+            gw_mock.assert_called_once()\n+\n+    def test_running_gateway_grants_access(self, monkeypatch):\n+        \"\"\"Plain CLI session (no kanban task, empty platform) with a live\n+        gateway: tool is callable.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=True):\n+            assert _check_send_message() is True\n+\n+    def test_no_signals_means_unavailable(self, monkeypatch):\n+        \"\"\"No kanban task, no platform, no gateway: tool is hidden.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\", return_value=False):\n+            assert _check_send_message() is False\n+\n+    def test_gateway_status_import_error_is_swallowed(self, monkeypatch):\n+        \"\"\"If gateway.status can't be imported (unusual deployment / partial\n+        install), the check returns False rather than raising.\"\"\"\n+        from tools.send_message_tool import _check_send_message\n+\n+        monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+\n+        with patch(\"gateway.session_context.get_session_env\", return_value=\"\"), \\\n+             patch(\"gateway.status.is_gateway_running\",\n+                   side_effect=ImportError(\"simulated\")):\n+            assert _check_send_message() is False"]},{"file":"tools/send_message_tool.py","hunks":["@@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No\n \n \n def _check_send_message():\n-    \"\"\"Gate send_message on gateway running (always available on messaging platforms).\"\"\"\n+    \"\"\"Gate send_message on gateway running (always available on messaging platforms).\n+\n+    Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK``\n+    on every spawned worker, but those workers run with the assignee profile's\n+    ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check\n+    would fail even though the parent gateway is alive. Honoring the env var\n+    lets workers call ``send_message`` to deliver rich content directly to the\n+    originating chat (paired with ``kanban_complete`` for the short notifier\n+    summary), which is the canonical pattern for any worker that needs to\n+    reply with more than the ~200-char first-line truncation the kanban\n+    notifier applies.\n+    \"\"\"\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return True\n     from gateway.session_context import get_session_env\n     platform = get_session_env(\"HERMES_SESSION_PLATFORM\", \"\")\n     if platform and platform != \"local\":\n"]}]},{"kind":"pr","number":33861,"title":"fix(dashboard): auto-reload SPA on stale-token 401 in loopback mode","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}