{"repo":"NousResearch/hermes-agent","public_id":"1143","display_label":"Background task notifications routed to wrong session","summary":"These issues and fixes consistently address a problem where async delegate task results, background process notifications, and related events are delivered to the wrong session, thread, or channel. Several fixes involve replacing process-level environment state with context variables to correctly preserve session identity across concurrent operations.","subsystem":"agent","counts":{"members":44,"open_issues":1,"open_issues_distinct":1,"open_prs":4,"closed_issues":15,"merged_prs":9,"attempt_prs":19},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1706},"resolution":{"state":"partially_resolved","by":"pr-1706,pr-7454,pr-10460,pr-60869,pr-60871,pr-64593,pr-65419"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T06:51:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04456659619450315},"fix_edges":[{"issue_number":1143,"pr_number":1706,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7358,"pr_number":7454,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7390,"pr_number":7454,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9532,"pr_number":10460,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35652,"pr_number":37099,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35652,"pr_number":60863,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35652,"pr_number":65419,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42674,"pr_number":65419,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57498,"pr_number":60871,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57576,"pr_number":60863,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":58684,"pr_number":60869,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":64484,"pr_number":64593,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1143,"title":"Telegram background process notifications ignore forum topic and post to General","url":null,"state":"closed"},{"kind":"issue","number":7358,"title":"Bug: Concurrent gateway messages cause cross-thread misrouting of background task notifications (os.environ state leakage)","url":null,"state":"closed"},{"kind":"issue","number":9532,"title":"[Bug]: background process watch notifications can leak into the wrong threaded session","url":null,"state":"closed"},{"kind":"issue","number":9736,"title":"Bug: Background process completion notifications can route to the wrong session","url":null,"state":"closed"},{"kind":"issue","number":10411,"title":"[Bug]: Background watch_match notifications can be injected into the wrong Discord thread","url":null,"state":"closed"},{"kind":"issue","number":18101,"title":"[Bug]: Background process updates can leak into the wrong Slack thread","url":null,"state":"closed"},{"kind":"issue","number":35652,"title":"TUI: background process completion notifications can route to the wrong live session","url":null,"state":"closed"},{"kind":"issue","number":42674,"title":"[Bug]: Background process notify_on_complete leaks into wrong TUI session (cross-session bleed)","url":null,"state":"closed"},{"kind":"issue","number":52447,"title":"Background process notifications route to active session instead of originating session","url":null,"state":"closed"},{"kind":"issue","number":57498,"title":"delegate_task background results routed to wrong session (cross-channel contamination)","url":null,"state":"closed"},{"kind":"issue","number":57502,"title":"Bug: delegate_task sub-agent results routed to wrong session (parent session ID lost on write-back)","url":null,"state":"closed"},{"kind":"issue","number":57576,"title":"Async delegation result delivered to wrong TUI session after context compression","url":null,"state":"closed"},{"kind":"issue","number":58684,"title":"bug: delegate_task async results delivered to wrong session/thread","url":null,"state":"closed"},{"kind":"issue","number":60426,"title":"Background process notifications routed to wrong session","url":null,"state":"closed"},{"kind":"issue","number":64484,"title":"[Bug]: New CLI session adopts a dead session's async delegation completions (durable restore bypasses CLI drain ownership)","url":null,"state":"closed"},{"kind":"issue","number":65407,"title":"[Bug]: residual HERMES_SESSION_* readers prefer process env over task ContextVars","url":null,"state":"open"},{"kind":"pr","number":1573,"title":"fix(gateway): persist watcher metadata in checkpoint for crash recovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1706","number":1706}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1706,"title":"fix(gateway): persist watcher metadata in checkpoint for crash recovery (salvaged #1573)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_background_process_notifications.py","tests/tools/test_process_registry.py","tools/process_registry.py","tools/terminal_tool.py"]},{"kind":"pr","number":7391,"title":"fix(gateway): replace os.environ session state with contextvars to prevent concurrent message cross-contamination","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7454","number":7454}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7454,"title":"fix(gateway): replace os.environ session state with contextvars + fix skill frontmatter truncation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_builder.py","agent/skill_utils.py","gateway/run.py","gateway/session_context.py","tests/gateway/test_session_env.py","tools/cronjob_tools.py","tools/send_message_tool.py","tools/skills_tool.py","tools/terminal_tool.py","tools/tts_tool.py"],"fix_hunks":[{"file":"agent/prompt_builder.py","hunks":["@@ -487,7 +487,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:\n     (True, {}, \"\") to err on the side of showing the skill.\n     \"\"\"\n     try:\n-        raw = skill_file.read_text(encoding=\"utf-8\")[:2000]\n+        raw = skill_file.read_text(encoding=\"utf-8\")\n         frontmatter, _ = parse_frontmatter(raw)\n \n         if not skill_matches_platform(frontmatter):","@@ -495,7 +495,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:\n \n         return True, frontmatter, extract_skill_description(frontmatter)\n     except Exception as e:\n-        logger.debug(\"Failed to parse skill file %s: %s\", skill_file, e)\n+        logger.warning(\"Failed to parse skill file %s: %s\", skill_file, e)\n         return True, {}, \"\"\n \n ","@@ -558,9 +558,10 @@ def build_skills_system_prompt(\n     # ── Layer 1: in-process LRU cache ─────────────────────────────────\n     # Include the resolved platform so per-platform disabled-skill lists\n     # produce distinct cache entries (gateway serves multiple platforms).\n+    from gateway.session_context import get_session_env\n     _platform_hint = (\n         os.environ.get(\"HERMES_PLATFORM\")\n-        or os.environ.get(\"HERMES_SESSION_PLATFORM\")\n+        or get_session_env(\"HERMES_SESSION_PLATFORM\")\n         or \"\"\n     )\n     cache_key = ("]},{"file":"agent/skill_utils.py","hunks":["@@ -145,10 +145,11 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:\n     if not isinstance(skills_cfg, dict):\n         return set()\n \n+    from gateway.session_context import get_session_env\n     resolved_platform = (\n         platform\n         or os.getenv(\"HERMES_PLATFORM\")\n-        or os.getenv(\"HERMES_SESSION_PLATFORM\")\n+        or get_session_env(\"HERMES_SESSION_PLATFORM\")\n     )\n     if resolved_platform:\n         platform_disabled = (skills_cfg.get(\"platform_disabled\") or {}).get("]},{"file":"gateway/run.py","hunks":["@@ -2400,8 +2400,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n         # Build session context\n         context = build_session_context(source, self.config, session_entry)\n         \n-        # Set environment variables for tools\n-        self._set_session_env(context)\n+        # Set session context variables for tools (task-local, concurrency-safe)\n+        _session_env_tokens = [redacted:secret-assignment]\n         \n         # Read privacy.redact_pii from config (re-read per message)\n         _redact_pii = False","@@ -3234,8 +3234,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                 \"Try again or use /reset to start a fresh session.\"\n             )\n         finally:\n-            # Clear session env\n-            self._clear_session_env()\n+            # Restore session context variables to their pre-handler state\n+            self._clear_session_env(_session_env_tokens)\n     \n     def _format_session_info(self) -> str:\n         \"\"\"Resolve current model config and return a formatted info block.","@@ -6120,20 +6120,27 @@ async def _send_update_notification(self) -> bool:\n \n         return True\n \n-    def _set_session_env(self, context: SessionContext) -> None:\n-        \"\"\"Set environment variables for the current session.\"\"\"\n-        os.environ[\"HERMES_SESSION_PLATFORM\"] = context.source.platform.value\n-        os.environ[\"HERMES_SESSION_CHAT_ID\"] = context.source.chat_id\n-        if context.source.chat_name:\n-            os.environ[\"HERMES_SESSION_CHAT_NAME\"] = context.source.chat_name\n-        if context.source.thread_id:\n-            os.environ[\"HERMES_SESSION_THREAD_ID\"] = str(context.source.thread_id)\n-    \n-    def _clear_session_env(self) -> None:\n-        \"\"\"Clear session environment variables.\"\"\"\n-        for var in [\"HERMES_SESSION_PLATFORM\", \"HERMES_SESSION_CHAT_ID\", \"HERMES_SESSION_CHAT_NAME\", \"HERMES_SESSION_THREAD_ID\"]:\n-            if var in os.environ:\n-                del os.environ[var]\n+    def _set_session_env(self, context: SessionContext) -> list:\n+        \"\"\"Set session context variables for the current async task.\n+\n+        Uses ``contextvars`` instead of ``os.environ`` so that concurrent\n+        gateway messages cannot overwrite each other's session state.\n+\n+        Returns a list of reset tokens; pass them to ``_clear_session_env``\n+        in a ``finally`` block.\n+        \"\"\"\n+        from gateway.session_context import set_session_vars\n+        return set_session_vars(\n+            platform=context.source.platform.value,\n+            chat_id=context.source.chat_id,\n+            chat_name=context.source.chat_name or \"\",\n+            thread_id=str(context.source.thread_id) if context.source.thread_id else \"\",\n+        )\n+\n+    def _clear_session_env(self, tokens: list) -> None:\n+        \"\"\"Restore session context variables to their pre-handler values.\"\"\"\n+        from gateway.session_context import clear_session_vars\n+        clear_session_vars(tokens)\n     \n     async def _enrich_message_with_vision(\n         self,"]},{"file":"gateway/session_context.py","hunks":["@@ -0,0 +1,113 @@\n+\"\"\"\n+Session-scoped context variables for the Hermes gateway.\n+\n+Replaces the previous ``os.environ``-based session state\n+(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with\n+Python's ``contextvars.ContextVar``.\n+\n+**Why this matters**\n+\n+The gateway processes messages concurrently via ``asyncio``.  When two\n+messages arrive at the same time the old code did:\n+\n+    os.environ[\"HERMES_SESSION_THREAD_ID\"] = str(context.source.thread_id)\n+\n+Because ``os.environ`` is *process-global*, Message A's value was\n+silently overwritten by Message B before Message A's agent finished\n+running.  Background-task notifications and tool calls therefore routed\n+to the wrong thread.\n+\n+``contextvars.ContextVar`` values are *task-local*: each ``asyncio``\n+task (and any ``run_in_executor`` thread it spawns) gets its own copy,\n+so concurrent messages never interfere.\n+\n+**Backward compatibility**\n+\n+The public helper ``get_session_env(name, default=\"\")`` mirrors the old\n+``os.getenv(\"HERMES_SESSION_*\", ...)`` calls.  Existing tool code only\n+needs to replace the import + call site:\n+\n+    # before\n+    import os\n+    platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\")\n+\n+    # after\n+    from gateway.session_context import get_session_env\n+    platform = get_session_env(\"HERMES_SESSION_PLATFORM\", \"\")\n+\"\"\"\n+\n+from contextvars import ContextVar\n+\n+# ---------------------------------------------------------------------------\n+# Per-task session variables\n+# ---------------------------------------------------------------------------\n+\n+_SESSION_PLATFORM: ContextVar[str] = ContextVar(\"HERMES_SESSION_PLATFORM\", default=\"\")\n+_SESSION_CHAT_ID: ContextVar[str] = ContextVar(\"HERMES_SESSION_CHAT_ID\", default=\"\")\n+_SESSION_CHAT_NAME: ContextVar[str] = ContextVar(\"HERMES_SESSION_CHAT_NAME\", default=\"\")\n+_SESSION_THREAD_ID: ContextVar[str] = ContextVar(\"HERMES_SESSION_THREAD_ID\", default=\"\")\n+\n+_VAR_MAP = {\n+    \"HERMES_SESSION_PLATFORM\": _SESSION_PLATFORM,\n+    \"HERMES_SESSION_CHAT_ID\": _SESSION_CHAT_ID,\n+    \"HERMES_SESSION_CHAT_NAME\": _SESSION_CHAT_NAME,\n+    \"HERMES_SESSION_THREAD_ID\": _SESSION_THREAD_ID,\n+}\n+\n+\n+def set_session_vars(\n+    platform: str = \"\",\n+    chat_id: str = \"\",\n+    chat_name: str = \"\",\n+    thread_id: str = \"\",\n+) -> list:\n+    \"\"\"Set all session context variables and return reset tokens.\n+\n+    Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore\n+    the previous values when the handler exits.\n+\n+    Returns a list of ``Token`` objects (one per variable) that can be\n+    passed to ``clear_session_vars``.\n+    \"\"\"\n+    tokens = [\n+        _SESSION_PLATFORM.set(platform),\n+        _SESSION_CHAT_ID.set(chat_id),\n+        _SESSION_CHAT_NAME.set(chat_name),\n+        _SESSION_THREAD_ID.set(thread_id),\n+    ]\n+    return tokens\n+\n+\n+def clear_session_vars(tokens: list) -> None:\n+    \"\"\"Restore session context variables to their pre-handler values.\"\"\"\n+    if not tokens:\n+        return\n+    vars_in_order = [\n+        _SESSION_PLATFORM,\n+        _SESSION_CHAT_ID,\n+        _SESSION_CHAT_NAME,\n+        _SESSION_THREAD_ID,\n+    ]\n+    for var, token in zip(vars_in_order, tokens):\n+        var.reset(token)\n+\n+\n+def get_session_env(name: str, default: str = \"\") -> str:\n+    \"\"\"Read a session context variable by its legacy ``HERMES_SESSION_*`` name.\n+\n+    Drop-in replacement for ``os.getenv(\"HERMES_SESSION_*\", default)``.\n+\n+    Resolution order:\n+    1. Context variable (set by the gateway for concurrency-safe access)\n+    2. ``os.environ`` (used by CLI, cron scheduler, and tests)\n+    3. *default*\n+    \"\"\"\n+    import os\n+\n+    var = _VAR_MAP.get(name)\n+    if var is not None:\n+        value = var.get()\n+        if value:\n+            return value\n+    # Fall back to os.environ for CLI, cron, and test compatibility\n+    return os.getenv(name, default)"]},{"file":"tests/gateway/test_session_env.py","hunks":["@@ -3,9 +3,15 @@\n from gateway.config import Platform\n from gateway.run import GatewayRunner\n from gateway.session import SessionContext, SessionSource\n+from gateway.session_context import (\n+    get_session_env,\n+    set_session_vars,\n+    clear_session_vars,\n+)\n \n \n-def test_set_session_env_includes_thread_id(monkeypatch):\n+def test_set_session_env_sets_contextvars(monkeypatch):\n+    \"\"\"_set_session_env should populate contextvars, not os.environ.\"\"\"\n     runner = object.__new__(GatewayRunner)\n     source = SessionSource(\n         platform=Platform.TELEGRAM,","@@ -21,25 +27,93 @@ def test_set_session_env_includes_thread_id(monkeypatch):\n     monkeypatch.delenv(\"HERMES_SESSION_CHAT_NAME\", raising=False)\n     monkeypatch.delenv(\"HERMES_SESSION_THREAD_ID\", raising=False)\n \n-    runner._set_session_env(context)\n+    tokens = [redacted:secret-assignment]\n \n-    assert os.getenv(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n-    assert os.getenv(\"HERMES_SESSION_CHAT_ID\") == \"-1001\"\n-    assert os.getenv(\"HERMES_SESSION_CHAT_NAME\") == \"Group\"\n-    assert os.getenv(\"HERMES_SESSION_THREAD_ID\") == \"17585\"\n+    # Values should be readable via get_session_env (contextvar path)\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_ID\") == \"-1001\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_NAME\") == \"Group\"\n+    assert get_session_env(\"HERMES_SESSION_THREAD_ID\") == \"17585\"\n \n+    # os.environ should NOT be touched\n+    assert os.getenv(\"HERMES_SESSION_PLATFORM\") is None\n+    assert os.getenv(\"HERMES_SESSION_THREAD_ID\") is None\n+\n+    # Clean up\n+    runner._clear_session_env(tokens)\n \n-def test_clear_session_env_removes_thread_id(monkeypatch):\n+\n+def test_clear_session_env_restores_previous_state(monkeypatch):\n+    \"\"\"_clear_session_env should restore contextvars to their pre-handler values.\"\"\"\n     runner = object.__new__(GatewayRunner)\n \n-    monkeypatch.setenv(\"HERMES_SESSION_PLATFORM\", \"telegram\")\n-    monkeypatch.setenv(\"HERMES_SESSION_CHAT_ID\", \"-1001\")\n-    monkeypatch.setenv(\"HERMES_SESSION_CHAT_NAME\", \"Group\")\n-    monkeypatch.setenv(\"HERMES_SESSION_THREAD_ID\", \"17585\")\n+    monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_CHAT_ID\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_CHAT_NAME\", raising=False)\n+    monkeypatch.delenv(\"HERMES_SESSION_THREAD_ID\", raising=False)\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_name=\"Group\",\n+        chat_type=\"group\",\n+        thread_id=\"17585\",\n+    )\n+    context = SessionContext(source=source, connected_platforms=[], home_channels={})\n+\n+    tokens = [redacted:secret-assignment]\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n \n-    runner._clear_session_env()\n+    runner._clear_session_env(tokens)\n \n-    assert os.getenv(\"HERMES_SESSION_PLATFORM\") is None\n-    assert os.getenv(\"HERMES_SESSION_CHAT_ID\") is None\n-    assert os.getenv(\"HERMES_SESSION_CHAT_NAME\") is None\n-    assert os.getenv(\"HERMES_SESSION_THREAD_ID\") is None\n+    # After clear, contextvars should return to defaults (empty)\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_ID\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_NAME\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_THREAD_ID\") == \"\"\n+\n+\n+def test_get_session_env_falls_back_to_os_environ(monkeypatch):\n+    \"\"\"get_session_env should fall back to os.environ when contextvar is unset.\"\"\"\n+    monkeypatch.setenv(\"HERMES_SESSION_PLATFORM\", \"discord\")\n+\n+    # No contextvar set — should read from os.environ\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"discord\"\n+\n+    # Now set a contextvar — should prefer it\n+    tokens = [redacted:secret-assignment]\"telegram\")\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n+\n+    # Restore — should fall back to os.environ again\n+    clear_session_vars(tokens)\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"discord\"\n+\n+\n+def test_get_session_env_default_when_nothing_set(monkeypatch):\n+    \"\"\"get_session_env returns default when neither contextvar nor env is set.\"\"\"\n+    monkeypatch.delenv(\"HERMES_SESSION_PLATFORM\", raising=False)\n+\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\", \"fallback\") == \"fallback\"\n+\n+\n+def test_set_session_env_handles_missing_optional_fields():\n+    \"\"\"_set_session_env should handle None chat_name and thread_id gracefully.\"\"\"\n+    runner = object.__new__(GatewayRunner)\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_name=None,\n+        chat_type=\"private\",\n+        thread_id=None,\n+    )\n+    context = SessionContext(source=source, connected_platforms=[], home_channels={})\n+\n+    tokens = [redacted:secret-assignment]\n+\n+    assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_ID\") == \"-1001\"\n+    assert get_session_env(\"HERMES_SESSION_CHAT_NAME\") == \"\"\n+    assert get_session_env(\"HERMES_SESSION_THREAD_ID\") == \"\"\n+\n+    runner._clear_session_env(tokens)"]},{"file":"tools/cronjob_tools.py","hunks":["@@ -64,14 +64,15 @@ def _scan_cron_prompt(prompt: str) -> str:\n \n \n def _origin_from_env() -> Optional[Dict[str, str]]:\n-    origin_platform = os.getenv(\"HERMES_SESSION_PLATFORM\")\n-    origin_chat_id = os.getenv(\"HERMES_SESSION_CHAT_ID\")\n+    from gateway.session_context import get_session_env\n+    origin_platform = get_session_env(\"HERMES_SESSION_PLATFORM\")\n+    origin_chat_id = get_session_env(\"HERMES_SESSION_CHAT_ID\")\n     if origin_platform and origin_chat_id:\n         return {\n             \"platform\": origin_platform,\n             \"chat_id\": origin_chat_id,\n-            \"chat_name\": os.getenv(\"HERMES_SESSION_CHAT_NAME\"),\n-            \"thread_id\": os.getenv(\"HERMES_SESSION_THREAD_ID\"),\n+            \"chat_name\": get_session_env(\"HERMES_SESSION_CHAT_NAME\") or None,\n+            \"thread_id\": get_session_env(\"HERMES_SESSION_THREAD_ID\") or None,\n         }\n     return None\n "]},{"file":"tools/send_message_tool.py","hunks":["@@ -212,7 +212,8 @@ def _handle_send(args):\n         if isinstance(result, dict) and result.get(\"success\") and mirror_text:\n             try:\n                 from gateway.mirror import mirror_to_session\n-                source_label = os.getenv(\"HERMES_SESSION_PLATFORM\", \"cli\")\n+                from gateway.session_context import get_session_env\n+                source_label = get_session_env(\"HERMES_SESSION_PLATFORM\", \"cli\")\n                 if mirror_to_session(platform_name, chat_id, mirror_text, source_label=source_label, thread_id=thread_id):\n                     result[\"mirrored\"] = True\n             except Exception:","@@ -1020,7 +1021,8 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No\n \n def _check_send_message():\n     \"\"\"Gate send_message on gateway running (always available on messaging platforms).\"\"\"\n-    platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\")\n+    from gateway.session_context import get_session_env\n+    platform = get_session_env(\"HERMES_SESSION_PLATFORM\", \"\")\n     if platform and platform != \"local\":\n         return True\n     try:"]},{"file":"tools/skills_tool.py","hunks":["@@ -347,7 +347,8 @@ def _capture_required_environment_variables(\n def _is_gateway_surface() -> bool:\n     if os.getenv(\"HERMES_GATEWAY_SESSION\"):\n         return True\n-    return bool(os.getenv(\"HERMES_SESSION_PLATFORM\"))\n+    from gateway.session_context import get_session_env\n+    return bool(get_session_env(\"HERMES_SESSION_PLATFORM\"))\n \n \n def _get_terminal_backend_name() -> str:"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1420,10 +1420,11 @@ def terminal_tool(\n                     # In gateway mode, auto-register a fast watcher so the\n                     # gateway can detect completion and trigger a new agent\n                     # turn.  CLI mode uses the completion_queue directly.\n-                    _gw_platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\")\n+                    from gateway.session_context import get_session_env as _gse\n+                    _gw_platform = _gse(\"HERMES_SESSION_PLATFORM\", \"\")\n                     if _gw_platform and not check_interval:\n-                        _gw_chat_id = os.getenv(\"HERMES_SESSION_CHAT_ID\", \"\")\n-                        _gw_thread_id = os.getenv(\"HERMES_SESSION_THREAD_ID\", \"\")\n+                        _gw_chat_id = _gse(\"HERMES_SESSION_CHAT_ID\", \"\")\n+                        _gw_thread_id = _gse(\"HERMES_SESSION_THREAD_ID\", \"\")\n                         proc_session.watcher_platform = _gw_platform\n                         proc_session.watcher_chat_id = _gw_chat_id\n                         proc_session.watcher_thread_id = _gw_thread_id","@@ -1445,9 +1446,10 @@ def terminal_tool(\n                         result_data[\"check_interval_note\"] = (\n                             f\"Requested {check_interval}s raised to minimum 30s\"\n                         )\n-                    watcher_platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\")\n-                    watcher_chat_id = os.getenv(\"HERMES_SESSION_CHAT_ID\", \"\")\n-                    watcher_thread_id = os.getenv(\"HERMES_SESSION_THREAD_ID\", \"\")\n+                    from gateway.session_context import get_session_env as _gse2\n+                    watcher_platform = _gse2(\"HERMES_SESSION_PLATFORM\", \"\")\n+                    watcher_chat_id = _gse2(\"HERMES_SESSION_CHAT_ID\", \"\")\n+                    watcher_thread_id = _gse2(\"HERMES_SESSION_THREAD_ID\", \"\")\n \n                     # Store on session for checkpoint persistence\n                     proc_session.watcher_platform = watcher_platform"]},{"file":"tools/tts_tool.py","hunks":["@@ -480,7 +480,8 @@ def text_to_speech_tool(\n     # Telegram voice bubbles require Opus (.ogg); OpenAI and ElevenLabs can\n     # produce Opus natively (no ffmpeg needed).  Edge TTS always outputs MP3\n     # and needs ffmpeg for conversion.\n-    platform = os.getenv(\"HERMES_SESSION_PLATFORM\", \"\").lower()\n+    from gateway.session_context import get_session_env\n+    platform = get_session_env(\"HERMES_SESSION_PLATFORM\", \"\").lower()\n     want_opus = (platform == \"telegram\")\n \n     # Determine output path\n"]}]},{"kind":"pr","number":9537,"title":"fix(gateway): route synthetic background events to their originating session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10460","number":10460},{"kind":"pr","value":"#10446","number":10446}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10446,"title":"fix(gateway): route watch-pattern notifications to originating session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10460","number":10460},{"kind":"pr","value":"#9537","number":9537}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10460,"title":"fix(gateway): route watch-pattern notifications to originating session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_background_process_notifications.py","tests/gateway/test_internal_event_bypass_pairing.py","tests/tools/test_watch_patterns.py","tools/process_registry.py","tools/terminal_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -482,6 +482,27 @@ def _resolve_hermes_bin() -> Optional[list[str]]:\n     return None\n \n \n+def _parse_session_key(session_key: str) -> \"dict | None\":\n+    \"\"\"Parse a session key into its component parts.\n+\n+    Session keys follow the format\n+    ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``.\n+    Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and\n+    optionally ``thread_id`` keys, or None if the key doesn't match.\n+    \"\"\"\n+    parts = session_key.split(\":\")\n+    if len(parts) >= 5 and parts[0] == \"agent\" and parts[1] == \"main\":\n+        result = {\n+            \"platform\": parts[2],\n+            \"chat_type\": parts[3],\n+            \"chat_id\": parts[4],\n+        }\n+        if len(parts) > 5:\n+            result[\"thread_id\"] = parts[5]\n+        return result\n+    return None\n+\n+\n def _format_gateway_process_notification(evt: dict) -> \"str | None\":\n     \"\"\"Format a watch pattern event from completion_queue into a [SYSTEM:] message.\"\"\"\n     evt_type = evt.get(\"type\", \"completion\")","@@ -1489,12 +1510,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n         notified: set = set()\n         for session_key in active:\n             # Parse platform + chat_id from the session key.\n-            # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]\n-            parts = session_key.split(\":\")\n-            if len(parts) < 5:\n+            _parsed = _parse_session_key(session_key)\n+            if not _parsed:\n                 continue\n-            platform_str = parts[2]\n-            chat_id = parts[4]\n+            platform_str = _parsed[\"platform\"]\n+            chat_id = _parsed[\"chat_id\"]\n \n             # Deduplicate: one notification per chat, even if multiple\n             # sessions (different users/threads) share the same chat.","@@ -1510,7 +1530,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n \n                 # Include thread_id if present so the message lands in the\n                 # correct forum topic / thread.\n-                thread_id = parts[5] if len(parts) > 5 else None\n+                thread_id = _parsed.get(\"thread_id\")\n                 metadata = {\"thread_id\": thread_id} if thread_id else None\n \n                 await adapter.send(chat_id, msg, metadata=metadata)","@@ -3958,7 +3978,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                     synth_text = _format_gateway_process_notification(evt)\n                     if synth_text:\n                         try:\n-                            await self._inject_watch_notification(synth_text, event)\n+                            await self._inject_watch_notification(synth_text, evt)\n                         except Exception as e2:\n                             logger.error(\"Watch notification injection error: %s\", e2)\n             except Exception as e:","@@ -7452,14 +7472,75 @@ async def _enrich_message_with_transcription(\n             return prefix\n         return user_text\n \n-    async def _inject_watch_notification(self, synth_text: str, original_event) -> None:\n+    def _build_process_event_source(self, evt: dict):\n+        \"\"\"Resolve the canonical source for a synthetic background-process event.\n+\n+        Prefer the persisted session-store origin for the event's session key.\n+        Falling back to the currently active foreground event is what causes\n+        cross-topic bleed, so don't do that.\n+        \"\"\"\n+        from gateway.session import SessionSource\n+\n+        session_key = str(evt.get(\"session_key\") or \"\").strip()\n+        derived_platform = \"\"\n+        derived_chat_type = \"\"\n+        derived_chat_id = \"\"\n+\n+        if session_key:\n+            try:\n+                self.session_store._ensure_loaded()\n+                entry = self.session_store._entries.get(session_key)\n+                if entry and getattr(entry, \"origin\", None):\n+                    return entry.origin\n+            except Exception as exc:\n+                logger.debug(\n+                    \"Synthetic process-event session-store lookup failed for %s: %s\",\n+                    session_key,\n+                    exc,\n+                )\n+\n+            _parsed = _parse_session_key(session_key)\n+            if _parsed:\n+                derived_platform = _parsed[\"platform\"]\n+                derived_chat_type = _parsed[\"chat_type\"]\n+                derived_chat_id = _parsed[\"chat_id\"]\n+\n+        platform_name = str(evt.get(\"platform\") or derived_platform or \"\").strip().lower()\n+        chat_type = str(evt.get(\"chat_type\") or derived_chat_type or \"\").strip().lower()\n+        chat_id = str(evt.get(\"chat_id\") or derived_chat_id or \"\").strip()\n+        if not platform_name or not chat_type or not chat_id:\n+            return None\n+\n+        try:\n+            platform = Platform(platform_name)\n+        except Exception:\n+            logger.warning(\n+                \"Synthetic process event has invalid platform metadata: %r\",\n+                platform_name,\n+            )\n+            return None\n+\n+        return SessionSource(\n+            platform=platform,\n+            chat_id=chat_id,\n+            chat_type=chat_type,\n+            thread_id=str(evt.get(\"thread_id\") or \"\").strip() or None,\n+            user_id=str(evt.get(\"user_id\") or \"\").strip() or None,\n+            user_name=str(evt.get(\"user_name\") or \"\").strip() or None,\n+        )\n+\n+    async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None:\n         \"\"\"Inject a watch-pattern notification as a synthetic message event.\n \n-        Uses the source from the original user event to route the notification\n-        back to the correct chat/adapter.\n+        Routing must come from the queued watch event itself, not from whatever\n+        foreground message happened to be active when the queue was drained.\n         \"\"\"\n-        source = getattr(original_event, \"source\", None)\n+        source = self._build_process_event_source(evt)\n         if not source:\n+            logger.warning(\n+                \"Dropping watch notification with no routing metadata for process %s\",\n+                evt.get(\"session_id\", \"unknown\"),\n+            )\n             return\n         platform_name = source.platform.value if hasattr(source.platform, \"value\") else str(source.platform)\n         adapter = None","@@ -7477,7 +7558,12 @@ async def _inject_watch_notification(self, synth_text: str, original_event) -> N\n                 source=source,\n                 internal=True,\n             )\n-            logger.info(\"Watch pattern notification — injecting for %s\", platform_name)\n+            logger.info(\n+                \"Watch pattern notification — injecting for %s chat=%s thread=%s\",\n+                platform_name,\n+                source.chat_id,\n+                source.thread_id,\n+            )\n             await adapter.handle_message(synth_event)\n         except Exception as e:\n             logger.error(\"Watch notification injection error: %s\", e)","@@ -7547,33 +7633,42 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n                         f\"Command: {session.command}\\n\"\n                         f\"Output:\\n{_out}]\"\n                     )\n+                    source = self._build_process_event_source({\n+                        \"session_id\": session_id,\n+                        \"session_key\": session_key,\n+                        \"platform\": platform_name,\n+                        \"chat_id\": chat_id,\n+                        \"thread_id\": thread_id,\n+                        \"user_id\": user_id,\n+                        \"user_name\": user_name,\n+                    })\n+                    if not source:\n+                        logger.warning(\n+                            \"Dropping completion notification with no routing metadata for process %s\",\n+                            session_id,\n+                        )\n+                        break\n+\n                     adapter = None\n                     for p, a in self.adapters.items():\n-                        if p.value == platform_name:\n+                        if p == source.platform:\n                             adapter = a\n                             break\n-                    if adapter and chat_id:\n+                    if adapter and source.chat_id:\n                         try:\n                             from gateway.platforms.base import MessageEvent, MessageType\n-                            from gateway.session import SessionSource\n-                            from gateway.config import Platform\n-                            _platform_enum = Platform(platform_name)\n-                            _source = SessionSource(\n-                                platform=_platform_enum,\n-                                chat_id=chat_id,\n-                                thread_id=thread_id or None,\n-                                user_id=user_id or None,\n-                                user_name=user_name or None,\n-                            )\n                             synth_event = MessageEvent(\n                                 text=synth_text,\n                                 message_type=MessageType.TEXT,\n-                                source=_source,\n+                                source=source,\n                                 internal=True,\n                             )\n                             logger.info(\n-                                \"Process %s finished — injecting agent notification for session %s\",\n-                                session_id, session_key,\n+                                \"Process %s finished — injecting agent notification for session %s chat=%s thread=%s\",\n+                                session_id,\n+                                session_key,\n+                                source.chat_id,\n+                                source.thread_id,\n                             )\n                             await adapter.handle_message(synth_event)\n                         except Exception as e:"]},{"file":"tests/gateway/test_background_process_notifications.py","hunks":["@@ -14,7 +14,7 @@\n import pytest\n \n from gateway.config import GatewayConfig, Platform\n-from gateway.run import GatewayRunner\n+from gateway.run import GatewayRunner, _parse_session_key\n \n \n # ---------------------------------------------------------------------------","@@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:\n     monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n \n     runner = GatewayRunner(GatewayConfig())\n-    adapter = SimpleNamespace(send=AsyncMock())\n+    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())\n     runner.adapters[Platform.TELEGRAM] = adapter\n     return runner\n ","@@ -243,3 +243,162 @@ async def _instant_sleep(*_a, **_kw):\n     assert adapter.send.await_count == 1\n     _, kwargs = adapter.send.call_args\n     assert kwargs[\"metadata\"] is None\n+\n+\n[redacted:email]\n+async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path):\n+    from gateway.session import SessionSource\n+\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"123\",\n+            user_name=\"Emiliyan\",\n+        )\n+    )\n+\n+    evt = {\n+        \"session_id\": \"proc_watch\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+    }\n+\n+    await runner._inject_watch_notification(\"[SYSTEM: Background process matched]\", evt)\n+\n+    adapter.handle_message.assert_awaited_once()\n+    synth_event = adapter.handle_message.await_args.args[0]\n+    assert synth_event.internal is True\n+    assert synth_event.source.platform == Platform.TELEGRAM\n+    assert synth_event.source.chat_id == \"-100\"\n+    assert synth_event.source.chat_type == \"group\"\n+    assert synth_event.source.thread_id == \"42\"\n+    assert synth_event.source.user_id == \"123\"\n+    assert synth_event.source.user_name == \"Emiliyan\"\n+\n+\n+def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path):\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_watch\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+        \"platform\": \"telegram\",\n+        \"chat_id\": \"-100\",\n+        \"thread_id\": \"42\",\n+        \"user_id\": \"123\",\n+        \"user_name\": \"Emiliyan\",\n+    }\n+\n+    source = runner._build_process_event_source(evt)\n+\n+    assert source is not None\n+    assert source.platform == Platform.TELEGRAM\n+    assert source.chat_id == \"-100\"\n+    assert source.chat_type == \"group\"\n+    assert source.thread_id == \"42\"\n+    assert source.user_id == \"123\"\n+    assert source.user_name == \"Emiliyan\"\n+\n+\n[redacted:email]\n+async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path):\n+    \"\"\"Negative test: watch notification must NOT route to the foreground thread.\"\"\"\n+    from gateway.session import SessionSource\n+\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+    adapter = runner.adapters[Platform.TELEGRAM]\n+\n+    # Session store has the process's original thread (thread 42)\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"proc_owner\",\n+            user_name=\"alice\",\n+        )\n+    )\n+\n+    # The evt dict carries the correct session_key — NOT a foreground event\n+    evt = {\n+        \"session_id\": \"proc_cross_thread\",\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+    }\n+\n+    await runner._inject_watch_notification(\"[SYSTEM: watch match]\", evt)\n+\n+    adapter.handle_message.assert_awaited_once()\n+    synth_event = adapter.handle_message.await_args.args[0]\n+    # Must route to thread 42 (process origin), NOT some other thread\n+    assert synth_event.source.thread_id == \"42\"\n+    assert synth_event.source.user_id == \"proc_owner\"\n+\n+\n+def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path):\n+    \"\"\"Missing session_key and no platform metadata → None (drop notification).\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    source = runner._build_process_event_source({\"session_id\": \"proc_orphan\"})\n+    assert source is None\n+\n+\n+def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path):\n+    \"\"\"Invalid platform string → None.\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_bad\",\n+        \"platform\": \"not_a_real_platform\",\n+        \"chat_type\": \"dm\",\n+        \"chat_id\": \"123\",\n+    }\n+    source = runner._build_process_event_source(evt)\n+    assert source is None\n+\n+\n+def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path):\n+    \"\"\"Session key with <5 parts doesn't parse, falls through to empty metadata → None.\"\"\"\n+    runner = _build_runner(monkeypatch, tmp_path, \"all\")\n+\n+    evt = {\n+        \"session_id\": \"proc_short\",\n+        \"session_key\": \"agent:main:telegram\",  # Too few parts\n+    }\n+    source = runner._build_process_event_source(evt)\n+    assert source is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# _parse_session_key helper\n+# ---------------------------------------------------------------------------\n+\n+def test_parse_session_key_valid():\n+    result = _parse_session_key(\"agent:main:telegram:group:-100\")\n+    assert result == {\"platform\": \"telegram\", \"chat_type\": \"group\", \"chat_id\": \"-100\"}\n+\n+\n+def test_parse_session_key_with_extra_parts():\n+    \"\"\"Thread ID (6th part) is extracted; further parts are ignored.\"\"\"\n+    result = _parse_session_key(\"agent:main:discord:group:chan123:thread456\")\n+    assert result == {\"platform\": \"discord\", \"chat_type\": \"group\", \"chat_id\": \"chan123\", \"thread_id\": \"thread456\"}\n+\n+\n+def test_parse_session_key_with_user_id_part():\n+    \"\"\"7th part (user_id) is ignored — only up to thread_id is extracted.\"\"\"\n+    result = _parse_session_key(\"agent:main:telegram:group:chat1:thread42:user99\")\n+    assert result == {\"platform\": \"telegram\", \"chat_type\": \"group\", \"chat_id\": \"chat1\", \"thread_id\": \"thread42\"}\n+\n+\n+def test_parse_session_key_too_short():\n+    assert _parse_session_key(\"agent:main:telegram\") is None\n+    assert _parse_session_key(\"\") is None\n+\n+\n+def test_parse_session_key_wrong_prefix():\n+    assert _parse_session_key(\"cron:main:telegram:dm:123\") is None\n+    assert _parse_session_key(\"agent:cron:telegram:dm:123\") is None"]},{"file":"tests/gateway/test_internal_event_bypass_pairing.py","hunks":["@@ -230,6 +230,59 @@ async def _instant_sleep(*_a, **_kw):\n     assert event.source.user_name == \"alice\"\n \n \n[redacted:email]\n+async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path):\n+    import tools.process_registry as pr_module\n+    from gateway.session import SessionSource\n+\n+    sessions = [\n+        SimpleNamespace(\n+            output_buffer=\"done\\n\", exited=True, exit_code=0, command=\"echo test\"\n+        ),\n+    ]\n+    monkeypatch.setattr(pr_module, \"process_registry\", _FakeRegistry(sessions))\n+\n+    async def _instant_sleep(*_a, **_kw):\n+        pass\n+    monkeypatch.setattr(asyncio, \"sleep\", _instant_sleep)\n+\n+    runner = GatewayRunner(GatewayConfig())\n+    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())\n+    runner.adapters[Platform.TELEGRAM] = adapter\n+    runner.session_store._entries[\"agent:main:telegram:group:-100:42\"] = SimpleNamespace(\n+        origin=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"-100\",\n+            chat_type=\"group\",\n+            thread_id=\"42\",\n+            user_id=\"user-42\",\n+            user_name=\"alice\",\n+        )\n+    )\n+\n+    watcher = {\n+        \"session_id\": \"proc_test_internal\",\n+        \"check_interval\": 0,\n+        \"session_key\": \"agent:main:telegram:group:-100:42\",\n+        \"platform\": \"telegram\",\n+        \"chat_id\": \"-100\",\n+        \"thread_id\": \"42\",\n+        \"notify_on_complete\": True,\n+    }\n+\n+    await runner._run_process_watcher(watcher)\n+\n+    assert adapter.handle_message.await_count == 1\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.internal is True\n+    assert event.source.platform == Platform.TELEGRAM\n+    assert event.source.chat_id == \"-100\"\n+    assert event.source.chat_type == \"group\"\n+    assert event.source.thread_id == \"42\"\n+    assert event.source.user_id == \"user-42\"\n+    assert event.source.user_name == \"alice\"\n+\n+\n @pytest.mark.asyncio\n async def test_none_user_id_skips_pairing(monkeypatch, tmp_path):\n     \"\"\"A non-internal event with user_id=None should be silently dropped.\"\"\""]},{"file":"tests/tools/test_watch_patterns.py","hunks":["@@ -92,6 +92,25 @@ def test_basic_match(self, registry):\n         assert \"disk full\" in evt[\"output\"]\n         assert evt[\"session_id\"] == \"proc_test_watch\"\n \n+    def test_match_carries_session_key_and_watcher_routing_metadata(self, registry):\n+        session = _make_session(watch_patterns=[\"ERROR\"])\n+        session.session_key = \"agent:main:telegram:group:-100:42\"\n+        session.watcher_platform = \"telegram\"\n+        session.watcher_chat_id = \"-100\"\n+        session.watcher_user_id = \"u123\"\n+        session.watcher_user_name = \"alice\"\n+        session.watcher_thread_id = \"42\"\n+\n+        registry._check_watch_patterns(session, \"ERROR: disk full\\n\")\n+        evt = registry.completion_queue.get_nowait()\n+\n+        assert evt[\"session_key\"] == \"agent:main:telegram:group:-100:42\"\n+        assert evt[\"platform\"] == \"telegram\"\n+        assert evt[\"chat_id\"] == \"-100\"\n+        assert evt[\"user_id\"] == \"u123\"\n+        assert evt[\"user_name\"] == \"alice\"\n+        assert evt[\"thread_id\"] == \"42\"\n+\n     def test_multiple_patterns(self, registry):\n         \"\"\"First matching pattern is reported.\"\"\"\n         session = _make_session(watch_patterns=[\"WARN\", \"ERROR\"])"]},{"file":"tools/process_registry.py","hunks":["@@ -191,9 +191,15 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:\n                     session._watch_disabled = True\n                     self.completion_queue.put({\n                         \"session_id\": session.id,\n+                        \"session_key\": session.session_key,\n                         \"command\": session.command,\n                         \"type\": \"watch_disabled\",\n                         \"suppressed\": session._watch_suppressed,\n+                        \"platform\": session.watcher_platform,\n+                        \"chat_id\": session.watcher_chat_id,\n+                        \"user_id\": session.watcher_user_id,\n+                        \"user_name\": session.watcher_user_name,\n+                        \"thread_id\": session.watcher_thread_id,\n                         \"message\": (\n                             f\"Watch patterns disabled for process {session.id} — \"\n                             f\"too many matches ({session._watch_suppressed} suppressed). \"","@@ -219,11 +225,17 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:\n \n         self.completion_queue.put({\n             \"session_id\": session.id,\n+            \"session_key\": session.session_key,\n             \"command\": session.command,\n             \"type\": \"watch_match\",\n             \"pattern\": matched_pattern,\n             \"output\": output,\n             \"suppressed\": suppressed,\n+            \"platform\": session.watcher_platform,\n+            \"chat_id\": session.watcher_chat_id,\n+            \"user_id\": session.watcher_user_id,\n+            \"user_name\": session.watcher_user_name,\n+            \"thread_id\": session.watcher_thread_id,\n         })\n \n     @staticmethod"]},{"file":"tools/terminal_tool.py","hunks":["@@ -1384,14 +1384,10 @@ def terminal_tool(\n                 if pty_disabled_reason:\n                     result_data[\"pty_note\"] = pty_disabled_reason\n \n-                # Mark for agent notification on completion\n-                if notify_on_complete and background:\n-                    proc_session.notify_on_complete = True\n-                    result_data[\"notify_on_complete\"] = True\n-\n-                    # In gateway mode, auto-register a fast watcher so the\n-                    # gateway can detect completion and trigger a new agent\n-                    # turn.  CLI mode uses the completion_queue directly.\n+                # Populate routing metadata on the session so that\n+                # watch-pattern and completion notifications can be\n+                # routed back to the correct chat/thread.\n+                if background and (notify_on_complete or watch_patterns):\n                     from gateway.session_context import get_session_env as _gse\n                     _gw_platform = _gse(\"HERMES_SESSION_PLATFORM\", \"\")\n                     if _gw_platform:","@@ -1404,16 +1400,26 @@ def terminal_tool(\n                         proc_session.watcher_user_id = _gw_user_id\n                         proc_session.watcher_user_name = _gw_user_name\n                         proc_session.watcher_thread_id = _gw_thread_id\n+\n+                # Mark for agent notification on completion\n+                if notify_on_complete and background:\n+                    proc_session.notify_on_complete = True\n+                    result_data[\"notify_on_complete\"] = True\n+\n+                    # In gateway mode, auto-register a fast watcher so the\n+                    # gateway can detect completion and trigger a new agent\n+                    # turn.  CLI mode uses the completion_queue directly.\n+                    if proc_session.watcher_platform:\n                         proc_session.watcher_interval = 5\n                         process_registry.pending_watchers.append({\n                             \"session_id\": proc_session.id,\n                             \"check_interval\": 5,\n                             \"session_key\": session_key,\n-                            \"platform\": _gw_platform,\n-                            \"chat_id\": _gw_chat_id,\n-                            \"user_id\": _gw_user_id,\n-                            \"user_name\": _gw_user_name,\n-                            \"thread_id\": _gw_thread_id,\n+                            \"platform\": proc_session.watcher_platform,\n+                            \"chat_id\": proc_session.watcher_chat_id,\n+                            \"user_id\": proc_session.watcher_user_id,\n+                            \"user_name\": proc_session.watcher_user_name,\n+                            \"thread_id\": proc_session.watcher_thread_id,\n                             \"notify_on_complete\": True,\n                         })\n \n"]}]},{"kind":"pr","number":12918,"title":"fix(gateway): route watch notifications to correct Discord thread (#10411)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"dee592a0b","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":18337,"title":"fix: resolve 7 identified issues [automated]","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"88bd1c01e","number":0},{"kind":"commit","value":"9caa12f4e","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26945,"title":"fix(telegram): preserve DM topic routing for synthetic background process events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#27212","number":27212},{"kind":"pr","value":"#9532","number":9532},{"kind":"pr","value":"#27212","number":27212},{"kind":"pr","value":"#27937","number":27937},{"kind":"pr","value":"#9532","number":9532},{"kind":"pr","value":"#27212","number":27212}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":27212,"title":"fix(telegram): reply-anchor background-process notifications into DM topics","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26945","number":26945}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35667,"title":"fix(tui): route completion notifications to owner live session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60863","number":60863}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":35672,"title":"fix(tui): route process notifications to owner session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"85b65e29f","number":0},{"kind":"pr","value":"#37099","number":37099},{"kind":"pr","value":"#35667","number":35667}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37099,"title":"feat(desktop): session hygiene, archive, media streaming + connecting overlay","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs","apps/desktop/src/app/chat/index.tsx","apps/desktop/src/app/chat/sidebar/index.tsx","apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx","apps/desktop/src/app/chat/sidebar/session-row.tsx","apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx","apps/desktop/src/app/command-center/index.tsx","apps/desktop/src/app/desktop-controller.tsx","apps/desktop/src/app/session/hooks/use-session-actions.ts","apps/desktop/src/app/settings/constants.ts","apps/desktop/src/app/settings/index.tsx","apps/desktop/src/app/settings/sessions-settings.tsx","apps/desktop/src/app/settings/types.ts","apps/desktop/src/components/assistant-ui/markdown-text.tsx","apps/desktop/src/components/gateway-connecting-overlay.tsx","apps/desktop/src/hermes.ts","apps/desktop/src/lib/icons.ts","apps/desktop/src/lib/media.ts","apps/desktop/src/types/hermes.ts","hermes_cli/web_server.py","hermes_state.py","tests/hermes_cli/test_web_server.py","tests/test_hermes_state.py","tests/test_tui_gateway_server.py","tools/process_registry.py","tui_gateway/server.py"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -8,6 +8,8 @@ const {\n   ipcMain,\n   nativeImage,\n   nativeTheme,\n+  net: electronNet,\n+  protocol,\n   safeStorage,\n   session,\n   shell,","@@ -364,6 +366,66 @@ app.setAboutPanelOptions({\n   copyright: 'Copyright © 2026 Nous Research'\n })\n \n+// Custom scheme for streaming local media (video/audio) into the renderer.\n+// Reading large media through `readFileDataUrl` failed: it base64-loads the\n+// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),\n+// so any non-trivial video silently refused to load. Streaming via a protocol\n+// handler removes the size cap and gives the <video> element seekable,\n+// range-aware playback. Must be registered before the app is ready.\n+const MEDIA_PROTOCOL = 'hermes-media'\n+// Only audio/video may be streamed. Without this the handler would read any\n+// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.\n+const STREAMABLE_MEDIA_EXTS = new Set([\n+  '.avi',\n+  '.flac',\n+  '.m4a',\n+  '.mkv',\n+  '.mov',\n+  '.mp3',\n+  '.mp4',\n+  '.ogg',\n+  '.opus',\n+  '.wav',\n+  '.webm'\n+])\n+\n+protocol.registerSchemesAsPrivileged([\n+  {\n+    scheme: MEDIA_PROTOCOL,\n+    privileges: {\n+      secure: true,\n+      standard: true,\n+      stream: true,\n+      supportFetchAPI: true\n+    }\n+  }\n+])\n+\n+function registerMediaProtocol() {\n+  protocol.handle(MEDIA_PROTOCOL, async request => {\n+    let resolvedPath\n+    try {\n+      const url = new URL(request.url)\n+      const filePath = decodeURIComponent(url.pathname.replace(/^\\/+/, ''))\n+      ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))\n+    } catch {\n+      return new Response('Media not found', { status: 404 })\n+    }\n+\n+    if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {\n+      return new Response('Unsupported media type', { status: 415 })\n+    }\n+\n+    // Delegate to Electron's net stack on a file:// URL — it resolves the\n+    // content-type and honors Range requests so seeking works. Forward the\n+    // renderer's headers (notably Range) and skip custom-protocol re-entry.\n+    return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {\n+      bypassCustomProtocolHandlers: true,\n+      headers: request.headers\n+    })\n+  })\n+}\n+\n let mainWindow = null\n let hermesProcess = null\n let connectionPromise = null","@@ -3654,6 +3716,7 @@ app.whenReady().then(() => {\n     Menu.setApplicationMenu(null)\n   }\n   installMediaPermissions()\n+  registerMediaProtocol()\n   ensureWslWindowsFonts()\n   createWindow()\n "]},{"file":"apps/desktop/src/app/chat/index.tsx","hunks":["@@ -97,7 +97,7 @@ function ChatHeader({\n   const sessions = useStore($sessions)\n   const pinnedSessionIds = useStore($pinnedSessionIds)\n   const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null\n-  const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'\n+  const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'\n   const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false\n \n   return ("]},{"file":"apps/desktop/src/app/chat/sidebar/index.tsx","hunks":["@@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'\n const VIRTUALIZE_THRESHOLD = 25\n \n const SIDEBAR_NAV: SidebarNavItem[] = [\n-  { id: 'new-session', label: 'New agent', icon: props => <Codicon name=\"robot\" {...props} />, action: 'new-session' },\n+  {\n+    id: 'new-session',\n+    label: 'New session',\n+    icon: props => <Codicon name=\"robot\" {...props} />,\n+    action: 'new-session'\n+  },\n   { id: 'skills', label: 'Skills', icon: props => <Codicon name=\"symbol-misc\" {...props} />, route: SKILLS_ROUTE },\n   { id: 'messaging', label: 'Messaging', icon: props => <Codicon name=\"comment\" {...props} />, route: MESSAGING_ROUTE },\n   { id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name=\"files\" {...props} />, route: ARTIFACTS_ROUTE }","@@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {\n   onLoadMoreSessions: () => void\n   onResumeSession: (sessionId: string) => void\n   onDeleteSession: (sessionId: string) => void\n+  onArchiveSession: (sessionId: string) => void\n+  onNewSessionInWorkspace: (path: null | string) => void\n }\n \n export function ChatSidebar({","@@ -156,7 +163,9 @@ export function ChatSidebar({\n   onNavigate,\n   onLoadMoreSessions,\n   onResumeSession,\n-  onDeleteSession\n+  onDeleteSession,\n+  onArchiveSession,\n+  onNewSessionInWorkspace\n }: ChatSidebarProps) {\n   const sidebarOpen = useStore($sidebarOpen)\n   const agentsGrouped = useStore($sidebarAgentsGrouped)","@@ -328,6 +337,7 @@ export function ChatSidebar({\n             dndSensors={dndSensors}\n             emptyState={<SidebarPinnedEmptyState />}\n             label=\"Pinned\"\n+            onArchiveSession={onArchiveSession}\n             onDeleteSession={onDeleteSession}\n             onReorder={handlePinnedDragEnd}\n             onResumeSession={onResumeSession}","@@ -361,9 +371,9 @@ export function ChatSidebar({\n             groups={agentsGrouped ? agentGroups : undefined}\n             headerAction={\n               <Button\n-                aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}\n+                aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}\n                 className={cn(\n-                  'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',\n+                  'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',\n                   agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'\n                 )}\n                 onClick={event => {","@@ -372,15 +382,17 @@ export function ChatSidebar({\n                   setSidebarAgentsGrouped(!agentsGrouped)\n                 }}\n                 size=\"icon-xs\"\n-                title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}\n+                title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}\n                 variant=\"ghost\"\n               >\n                 <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size=\"0.75rem\" />\n               </Button>\n             }\n-            label=\"Agents\"\n+            label=\"Sessions\"\n             labelMeta={countLabel(agentSessions.length, knownSessionTotal)}\n+            onArchiveSession={onArchiveSession}\n             onDeleteSession={onDeleteSession}\n+            onNewSessionInWorkspace={onNewSessionInWorkspace}\n             onReorder={handleAgentDragEnd}\n             onResumeSession={onResumeSession}\n             onToggle={() => setSidebarRecentsOpen(!agentsOpen)}","@@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps {\n   workingSessionIdSet: Set<string>\n   onResumeSession: (sessionId: string) => void\n   onDeleteSession: (sessionId: string) => void\n+  onArchiveSession: (sessionId: string) => void\n   onTogglePin: (sessionId: string) => void\n+  onNewSessionInWorkspace?: (path: null | string) => void\n   pinned: boolean\n   rootClassName?: string\n   contentClassName?: string","@@ -496,7 +510,9 @@ function SidebarSessionsSection({\n   workingSessionIdSet,\n   onResumeSession,\n   onDeleteSession,\n+  onArchiveSession,\n   onTogglePin,\n+  onNewSessionInWorkspace,\n   pinned,\n   rootClassName,\n   contentClassName,","@@ -518,6 +534,7 @@ function SidebarSessionsSection({\n       isPinned: pinned,\n       isSelected: session.id === activeSessionId,\n       isWorking: workingSessionIdSet.has(session.id),\n+      onArchive: () => onArchiveSession(session.id),\n       onDelete: () => onDeleteSession(session.id),\n       onPin: () => onTogglePin(session.id),\n       onResume: () => onResumeSession(session.id),","@@ -551,9 +568,19 @@ function SidebarSessionsSection({\n   } else if (groups?.length) {\n     const groupNodes = groups.map(group =>\n       dndActive ? (\n-        <SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />\n+        <SortableSidebarWorkspaceGroup\n+          group={group}\n+          key={group.id}\n+          onNewSession={onNewSessionInWorkspace}\n+          renderRows={renderSessionList}\n+        />\n       ) : (\n-        <SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />\n+        <SidebarWorkspaceGroup\n+          group={group}\n+          key={group.id}\n+          onNewSession={onNewSessionInWorkspace}\n+          renderRows={renderSessionList}\n+        />\n       )\n     )\n ","@@ -568,6 +595,7 @@ function SidebarSessionsSection({\n     inner = (\n       <VirtualSessionList\n         activeSessionId={activeSessionId}\n+        onArchiveSession={onArchiveSession}\n         onDeleteSession={onDeleteSession}\n         onResumeSession={onResumeSession}\n         onTogglePin={onTogglePin}","@@ -610,6 +638,7 @@ function SidebarSessionsSection({\n interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {\n   group: SidebarSessionGroup\n   renderRows: (sessions: SessionInfo[]) => React.ReactNode\n+  onNewSession?: (path: null | string) => void\n   reorderable?: boolean\n   dragging?: boolean\n   dragHandleProps?: React.HTMLAttributes<HTMLElement>","@@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {\n function SidebarWorkspaceGroup({\n   group,\n   renderRows,\n+  onNewSession,\n   reorderable = false,\n   dragging = false,\n   dragHandleProps,","@@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({\n \n   return (\n     <div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>\n-      <button\n-        className=\"group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)\"\n-        onClick={() => setOpen(value => !value)}\n-        title={group.path ?? undefined}\n-        type=\"button\"\n-      >\n-        <span className=\"truncate\">{group.label}</span>\n-        <SidebarCount>{group.sessions.length}</SidebarCount>\n-        <DisclosureCaret\n-          className=\"text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100\"\n-          open={open}\n-        />\n+      <div className=\"group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)\">\n+        <button\n+          className=\"flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)\"\n+          onClick={() => setOpen(value => !value)}\n+          title={group.path ?? undefined}\n+          type=\"button\"\n+        >\n+          <span className=\"truncate\">{group.label}</span>\n+          <SidebarCount>{group.sessions.length}</SidebarCount>\n+          <DisclosureCaret\n+            className=\"text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100\"\n+            open={open}\n+          />\n+        </button>\n+        {onNewSession && (\n+          <button\n+            aria-label={`New session in ${group.label}`}\n+            className=\"grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100\"\n+            onClick={() => onNewSession(group.path)}\n+            title={`New session in ${group.label}`}\n+            type=\"button\"\n+          >\n+            <Codicon name=\"add\" size=\"0.75rem\" />\n+          </button>\n+        )}\n         {reorderable && (\n           <span\n             {...dragHandleProps}","@@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({\n             />\n           </span>\n         )}\n-      </button>\n+      </div>\n       {open && (\n         <>\n           {renderRows(visibleSessions)}","@@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({\n interface SortableWorkspaceProps {\n   group: SidebarSessionGroup\n   renderRows: (sessions: SessionInfo[]) => React.ReactNode\n+  onNewSession?: (path: null | string) => void\n }\n \n function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {","@@ -702,6 +746,7 @@ interface SortableSessionRowProps {\n   isPinned: boolean\n   isSelected: boolean\n   isWorking: boolean\n+  onArchive: () => void\n   onDelete: () => void\n   onPin: () => void\n   onResume: () => void"]},{"file":"apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx","hunks":["@@ -26,6 +26,7 @@ interface SessionActions {\n   title: string\n   pinned?: boolean\n   onPin?: () => void\n+  onArchive?: () => void\n   onDelete?: () => void\n }\n ","@@ -40,7 +41,7 @@ interface ItemSpec {\n   variant?: 'destructive'\n }\n \n-function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {\n+function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {\n   const [renameOpen, setRenameOpen] = useState(false)\n \n   const items: ItemSpec[] = [","@@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }\n         setRenameOpen(true)\n       }\n     },\n+    {\n+      disabled: !onArchive,\n+      icon: 'archive',\n+      label: 'Archive',\n+      onSelect: () => {\n+        triggerHaptic('selection')\n+        onArchive?.()\n+      }\n+    },\n     {\n       className: 'text-destructive focus:text-destructive',\n       disabled: !onDelete,"]},{"file":"apps/desktop/src/app/chat/sidebar/session-row.tsx","hunks":["@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {\n   isPinned: boolean\n   isSelected: boolean\n   isWorking: boolean\n+  onArchive: () => void\n   onDelete: () => void\n   onPin: () => void\n   onResume: () => void","@@ -45,6 +46,7 @@ export function SidebarSessionRow({\n   isPinned,\n   isSelected,\n   isWorking,\n+  onArchive,\n   onDelete,\n   onPin,\n   onResume,","@@ -61,7 +63,14 @@ export function SidebarSessionRow({\n   const handleLabel = `Reorder ${title}`\n \n   return (\n-    <SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>\n+    <SessionContextMenu\n+      onArchive={onArchive}\n+      onDelete={onDelete}\n+      onPin={onPin}\n+      pinned={isPinned}\n+      sessionId={session.id}\n+      title={title}\n+    >\n       <div\n         className={cn(\n           'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',","@@ -88,6 +97,15 @@ export function SidebarSessionRow({\n               return\n             }\n \n+            if (event.metaKey || event.ctrlKey) {\n+              event.preventDefault()\n+              event.stopPropagation()\n+              triggerHaptic('selection')\n+              onArchive()\n+\n+              return\n+            }\n+\n             onResume()\n           }}\n           type=\"button\"","@@ -127,7 +145,14 @@ export function SidebarSessionRow({\n               {age}\n             </span>\n           )}\n-          <SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>\n+          <SessionActionsMenu\n+            onArchive={onArchive}\n+            onDelete={onDelete}\n+            onPin={onPin}\n+            pinned={isPinned}\n+            sessionId={session.id}\n+            title={title}\n+          >\n             <Button\n               aria-label={`Actions for ${title}`}\n               className=\"size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!\""]},{"file":"apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx","hunks":["@@ -12,6 +12,7 @@ interface SessionRowCommonProps {\n   isPinned: boolean\n   isSelected: boolean\n   isWorking: boolean\n+  onArchive: () => void\n   onDelete: () => void\n   onPin: () => void\n   onResume: () => void","@@ -20,6 +21,7 @@ interface SessionRowCommonProps {\n interface VirtualSessionListProps {\n   activeSessionId: null | string\n   className?: string\n+  onArchiveSession: (sessionId: string) => void\n   onDeleteSession: (sessionId: string) => void\n   onResumeSession: (sessionId: string) => void\n   onTogglePin: (sessionId: string) => void","@@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12\n export const VirtualSessionList: FC<VirtualSessionListProps> = ({\n   activeSessionId,\n   className,\n+  onArchiveSession,\n   onDeleteSession,\n   onResumeSession,\n   onTogglePin,","@@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({\n       isPinned: pinned,\n       isSelected: session.id === activeSessionId,\n       isWorking: workingSessionIdSet.has(session.id),\n+      onArchive: () => onArchiveSession(session.id),\n       onDelete: () => onDeleteSession(session.id),\n       onPin: () => onTogglePin(session.id),\n       onResume: () => onResumeSession(session.id)"]},{"file":"apps/desktop/src/app/command-center/index.tsx","hunks":["@@ -113,7 +113,7 @@ interface SectionSearchEntry {\n }\n \n const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [\n-  { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },\n+  { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },\n   { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },\n   { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },\n   {"]},{"file":"apps/desktop/src/app/desktop-controller.tsx","hunks":["@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're\n import { BootFailureOverlay } from '@/components/boot-failure-overlay'\n import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'\n import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'\n+import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'\n import { Pane, PaneMain } from '@/components/pane-shell'\n import { useSkinCommand } from '@/themes/use-skin-command'\n ","@@ -33,6 +34,8 @@ import {\n   $selectedStoredSessionId,\n   setAwaitingResponse,\n   setBusy,\n+  setCurrentBranch,\n+  setCurrentCwd,\n   setCurrentModel,\n   setCurrentProvider,\n   setMessages,","@@ -122,6 +125,7 @@ export function DesktopController() {\n     settingsOpen,\n     toggleCommandCenter\n   } = useOverlayRouting()\n+\n   const terminalTakeoverActive = chatOpen && terminalTakeover\n \n   const titlebarToolGroups = useGroupRegistry<TitlebarTool>()","@@ -192,7 +196,10 @@ export function DesktopController() {\n \n     try {\n       const limit = $sessionsLimit.get()\n-      const result = await listSessions(limit)\n+      // Require at least one message so abandoned/empty \"Untitled\" drafts (one\n+      // was created per TUI/desktop launch before the lazy-create fix) don't\n+      // clutter the sidebar.\n+      const result = await listSessions(limit, 1)\n \n       if (refreshSessionsRequestRef.current === requestId) {\n         setSessions(result.sessions)","@@ -324,6 +331,7 @@ export function DesktopController() {\n   })\n \n   const {\n+    archiveSession,\n     branchCurrentSession,\n     createBackendSessionForSend,\n     openSettings,","@@ -392,6 +400,29 @@ export function DesktopController() {\n     [branchCurrentSession, refreshSessions]\n   )\n \n+  const startSessionInWorkspace = useCallback(\n+    (path: null | string) => {\n+      startFreshSessionDraft()\n+\n+      const target = path?.trim()\n+\n+      if (!target) {\n+        return\n+      }\n+\n+      // The next message creates the backend session in $currentCwd, so seed\n+      // it (and the branch) from the workspace the user clicked the + on.\n+      setCurrentCwd(target)\n+      void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })\n+        .then(info => {\n+          setCurrentCwd(info.cwd || target)\n+          setCurrentBranch(info.branch || '')\n+        })\n+        .catch(() => undefined)\n+    },\n+    [requestGateway, startFreshSessionDraft]\n+  )\n+\n   const handleSkinCommand = useSkinCommand()\n \n   const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =","@@ -461,9 +492,11 @@ export function DesktopController() {\n   const sidebar = (\n     <ChatSidebar\n       currentView={currentView}\n+      onArchiveSession={sessionId => void archiveSession(sessionId)}\n       onDeleteSession={sessionId => void removeSession(sessionId)}\n       onLoadMoreSessions={loadMoreSessions}\n       onNavigate={selectSidebarItem}\n+      onNewSessionInWorkspace={startSessionInWorkspace}\n       onResumeSession={sessionId => navigate(sessionRoute(sessionId))}\n     />\n   )","@@ -485,6 +518,7 @@ export function DesktopController() {\n       />\n       <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />\n       <UpdatesOverlay />\n+      <GatewayConnectingOverlay />\n       <BootFailureOverlay />\n \n       {settingsOpen && (","@@ -575,10 +609,10 @@ export function DesktopController() {\n       titlebarTools={titlebarToolGroups.flat.right}\n     >\n       <Pane\n+        disabled={terminalTakeoverActive}\n         id=\"chat-sidebar\"\n         maxWidth={SIDEBAR_MAX_WIDTH}\n         minWidth={SIDEBAR_DEFAULT_WIDTH}\n-        disabled={terminalTakeoverActive}\n         resizable\n         side=\"left\"\n         width={`${SIDEBAR_DEFAULT_WIDTH}px`}"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.ts","hunks":["@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'\n import { useCallback, useRef } from 'react'\n import type { NavigateFunction } from 'react-router-dom'\n \n-import { deleteSession, getSessionMessages } from '@/hermes'\n+import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'\n import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'\n import { normalizePersonalityValue } from '@/lib/chat-runtime'\n import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'","@@ -751,7 +751,39 @@ export function useSessionActions({\n     ]\n   )\n \n+  const archiveSession = useCallback(\n+    async (storedSessionId: string) => {\n+      clearNotifications()\n+\n+      const archived = $sessions.get().find(s => s.id === storedSessionId)\n+      const wasSelected = selectedStoredSessionId === storedSessionId\n+      const previousPinned = $pinnedSessionIds.get()\n+\n+      // Soft-hide: drop from the sidebar immediately, keep the data.\n+      setSessions(prev => prev.filter(s => s.id !== storedSessionId))\n+      $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))\n+\n+      if (wasSelected) {\n+        startFreshSessionDraft(true)\n+      }\n+\n+      try {\n+        await setSessionArchived(storedSessionId, true)\n+        notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })\n+      } catch (err) {\n+        if (archived) {\n+          setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])\n+        }\n+\n+        $pinnedSessionIds.set(previousPinned)\n+        notifyError(err, 'Archive failed')\n+      }\n+    },\n+    [selectedStoredSessionId, startFreshSessionDraft]\n+  )\n+\n   return {\n+    archiveSession,\n     branchCurrentSession,\n     closeSettings,\n     createBackendSessionForSend,"]},{"file":"apps/desktop/src/app/settings/constants.ts","hunks":["@@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [\n   { id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }\n ]\n \n-export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {\n+export const SEARCH_PLACEHOLDER: Record<\n+  'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',\n+  string\n+> = {\n   about: 'About Hermes Desktop',\n   config: 'Search settings...',\n   gateway: 'Gateway connection...',\n   keys: 'Search API keys...',\n   mcp: 'Search MCP servers...',\n+  sessions: 'Search archived sessions...',\n   tools: 'Search skills and tools...'\n }"]},{"file":"apps/desktop/src/app/settings/index.tsx","hunks":["@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'\n \n import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'\n import { triggerHaptic } from '@/lib/haptics'\n-import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'\n+import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'\n import { notifyError } from '@/store/notifications'\n \n import { useRouteEnumParam } from '../hooks/use-route-enum-param'","@@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'\n import { GatewaySettings } from './gateway-settings'\n import { KeysSettings } from './keys-settings'\n import { McpSettings } from './mcp-settings'\n+import { SessionsSettings } from './sessions-settings'\n import { ToolsSettings } from './tools-settings'\n import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'\n ","@@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [\n   'gateway',\n   'keys',\n   'mcp',\n+  'sessions',\n   'tools',\n   'about'\n ]","@@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr\n     gateway: '',\n     keys: '',\n     mcp: '',\n+    sessions: '',\n     tools: ''\n   })\n ","@@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr\n             label=\"MCP\"\n             onClick={() => setActiveView('mcp')}\n           />\n+          <OverlayNavItem\n+            active={activeView === 'sessions'}\n+            icon={Archive}\n+            label=\"Archived Chats\"\n+            onClick={() => setActiveView('sessions')}\n+          />\n           <div className=\"my-2 h-px bg-border/30\" />\n           <OverlayNavItem\n             active={activeView === 'about'}","@@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr\n             <KeysSettings query={queries.keys} />\n           ) : activeView === 'mcp' ? (\n             <McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />\n+          ) : activeView === 'sessions' ? (\n+            <SessionsSettings query={queries.sessions} />\n           ) : (\n             <ToolsSettings query={queries.tools} />\n           )}"]},{"file":"apps/desktop/src/app/settings/sessions-settings.tsx","hunks":["@@ -0,0 +1,168 @@\n+import { useCallback, useEffect, useMemo, useState } from 'react'\n+\n+import { Button } from '@/components/ui/button'\n+import { deleteSession, listSessions, setSessionArchived } from '@/hermes'\n+import { sessionTitle } from '@/lib/chat-runtime'\n+import { triggerHaptic } from '@/lib/haptics'\n+import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'\n+import { notify, notifyError } from '@/store/notifications'\n+import { setSessions } from '@/store/session'\n+import type { SessionInfo } from '@/types/hermes'\n+\n+import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'\n+import type { SearchProps } from './types'\n+\n+const ARCHIVED_FETCH_LIMIT = 200\n+\n+function workspaceLabel(cwd: null | string | undefined): string {\n+  const path = cwd?.trim()\n+\n+  if (!path) {\n+    return ''\n+  }\n+\n+  return (\n+    path\n+      .replace(/[/\\\\]+$/, '')\n+      .split(/[/\\\\]/)\n+      .filter(Boolean)\n+      .pop() ?? path\n+  )\n+}\n+\n+export function SessionsSettings({ query }: SearchProps) {\n+  const [sessions, setLocalSessions] = useState<SessionInfo[]>([])\n+  const [loading, setLoading] = useState(true)\n+  const [busyId, setBusyId] = useState<string | null>(null)\n+\n+  const load = useCallback(async () => {\n+    setLoading(true)\n+\n+    try {\n+      const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')\n+      setLocalSessions(result.sessions)\n+    } catch (err) {\n+      notifyError(err, 'Could not load archived sessions')\n+    } finally {\n+      setLoading(false)\n+    }\n+  }, [])\n+\n+  useEffect(() => {\n+    void load()\n+  }, [load])\n+\n+  const unarchive = useCallback(async (session: SessionInfo) => {\n+    setBusyId(session.id)\n+\n+    try {\n+      await setSessionArchived(session.id, false)\n+      setLocalSessions(prev => prev.filter(s => s.id !== session.id))\n+      // Surface it again in the sidebar without waiting for a full refresh.\n+      setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])\n+      triggerHaptic('selection')\n+      notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })\n+    } catch (err) {\n+      notifyError(err, 'Unarchive failed')\n+    } finally {\n+      setBusyId(null)\n+    }\n+  }, [])\n+\n+  const remove = useCallback(async (session: SessionInfo) => {\n+    if (!window.confirm(`Permanently delete \"${sessionTitle(session)}\"? This cannot be undone.`)) {\n+      return\n+    }\n+\n+    setBusyId(session.id)\n+\n+    try {\n+      await deleteSession(session.id)\n+      setLocalSessions(prev => prev.filter(s => s.id !== session.id))\n+      triggerHaptic('warning')\n+    } catch (err) {\n+      notifyError(err, 'Delete failed')\n+    } finally {\n+      setBusyId(null)\n+    }\n+  }, [])\n+\n+  const filtered = useMemo(() => {\n+    const needle = query.trim().toLowerCase()\n+\n+    if (!needle) {\n+      return sessions\n+    }\n+\n+    return sessions.filter(session =>\n+      [sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)\n+    )\n+  }, [query, sessions])\n+\n+  if (loading) {\n+    return <LoadingState label=\"Loading archived sessions…\" />\n+  }\n+\n+  return (\n+    <SettingsContent>\n+      <SectionHeading\n+        icon={Archive}\n+        meta={sessions.length ? String(sessions.length) : undefined}\n+        title=\"Archived sessions\"\n+      />\n+      <p className=\"mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)\">\n+        Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to\n+        archive it.\n+      </p>\n+\n+      {filtered.length === 0 ? (\n+        <EmptyState\n+          description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}\n+          title=\"Nothing archived\"\n+        />\n+      ) : (\n+        <div className=\"divide-y divide-border/30\">\n+          {filtered.map(session => {\n+            const label = workspaceLabel(session.cwd)\n+            const busy = busyId === session.id\n+\n+            return (\n+              <ListRow\n+                action={\n+                  <div className=\"flex items-center gap-1.5\">\n+                    <Button\n+                      disabled={busy}\n+                      onClick={() => void unarchive(session)}\n+                      size=\"sm\"\n+                      type=\"button\"\n+                      variant=\"outline\"\n+                    >\n+                      {busy ? <Loader2 className=\"size-3.5 animate-spin\" /> : <ArchiveOff className=\"size-3.5\" />}\n+                      <span>Unarchive</span>\n+                    </Button>\n+                    <Button\n+                      aria-label=\"Delete permanently\"\n+                      className=\"text-muted-foreground hover:text-destructive\"\n+                      disabled={busy}\n+                      onClick={() => void remove(session)}\n+                      size=\"icon\"\n+                      title=\"Delete permanently\"\n+                      type=\"button\"\n+                      variant=\"ghost\"\n+                    >\n+                      <Trash2 className=\"size-3.5\" />\n+                    </Button>\n+                  </div>\n+                }\n+                description={session.preview || undefined}\n+                hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}\n+                key={session.id}\n+                title={sessionTitle(session)}\n+              />\n+            )\n+          })}\n+        </div>\n+      )}\n+    </SettingsContent>\n+  )\n+}"]},{"file":"apps/desktop/src/app/settings/types.ts","hunks":["@@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'\n import type { IconComponent } from '@/lib/icons'\n import type { EnvVarInfo } from '@/types/hermes'\n \n-export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`\n-export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'\n+export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`\n+export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'\n export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>\n \n export interface SettingsPageProps {"]},{"file":"apps/desktop/src/components/assistant-ui/markdown-text.tsx","hunks":["@@ -19,9 +19,9 @@ import {\n   filePathFromMediaPath,\n   mediaExternalUrl,\n   mediaKind,\n-  mediaMime,\n   mediaName,\n-  mediaPathFromMarkdownHref\n+  mediaPathFromMarkdownHref,\n+  mediaStreamUrl\n } from '@/lib/media'\n import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'\n import { cn } from '@/lib/utils'","@@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'\n // LLM convention). The default false-setting only accepts `$$...$$`.\n const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })\n \n-async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {\n-  const blob = await fetch(dataUrl).then(response => response.blob())\n-\n-  return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))\n-}\n-\n async function mediaSrc(path: string): Promise<string> {\n   if (/^(?:https?|data):/i.test(path)) {\n     return path\n   }\n \n+  // Stream audio/video through the custom protocol: data URLs are capped and\n+  // load the whole file into memory, which broke playback for larger videos.\n+  if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {\n+    return mediaStreamUrl(path)\n+  }\n+\n   if (!window.hermesDesktop?.readFileDataUrl) {\n     return mediaExternalUrl(path)\n   }\n \n-  const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))\n-\n-  return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl\n+  return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))\n }\n \n function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {","@@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {\n   // render, which churns Streamdown's outer memo + propagates new prop\n   // identities into every Block. The plugin set really only varies on\n   // `isStreaming`, so memoize on that.\n-  const plugins = useMemo(\n-    () => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),\n-    [isStreaming]\n-  )\n+  const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])\n \n   const components = useMemo(\n     () =>"]},{"file":"apps/desktop/src/components/gateway-connecting-overlay.tsx","hunks":["@@ -0,0 +1,183 @@\n+import { useStore } from '@nanostores/react'\n+import { useEffect, useRef, useState } from 'react'\n+\n+import { cn } from '@/lib/utils'\n+import { $desktopBoot } from '@/store/boot'\n+import { $gatewayState } from '@/store/session'\n+\n+// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at\n+// the render level means no timer logic (even a stale HMR one) can ever\n+// scramble \"CONN\".\n+const PREFIX = 'CONN'\n+const TAIL = 'ECTING'\n+// Even-weight mono ascii so cycling glyphs don't jump width (matches the\n+// nousnet-web download-button decode effect).\n+const SCRAMBLE_CHARS = '/\\\\|-_=+<>~:*'\n+const TICK_MS = 45\n+\n+// Exit choreography (ms): text fades down + out, hold, then the overlay fades.\n+const TEXT_OUT_MS = 360\n+const POST_TEXT_HOLD_MS = 300\n+const OVERLAY_OUT_MS = 520\n+// Preview-only: how long to \"connect\" for, and the pause before replaying.\n+const PREVIEW_CONNECT_MS = 2600\n+const PREVIEW_REPLAY_MS = 1100\n+\n+type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'\n+\n+// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay\n+// only flashes. Load with `?connecting=1` to force a looping preview.\n+function forcedPreview(): boolean {\n+  if (!import.meta.env.DEV || typeof window === 'undefined') {\n+    return false\n+  }\n+\n+  try {\n+    return new URLSearchParams(window.location.search).get('connecting') === '1'\n+  } catch {\n+    return false\n+  }\n+}\n+\n+function scrambledTail(resolvedCount: number): string {\n+  return Array.from(TAIL, (ch, i) =>\n+    i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]\n+  ).join('')\n+}\n+\n+export function GatewayConnectingOverlay() {\n+  const gatewayState = useStore($gatewayState)\n+  const boot = useStore($desktopBoot)\n+  const [previewing] = useState(forcedPreview)\n+  const [tail, setTail] = useState(TAIL)\n+  const [phase, setPhase] = useState<Phase>('live')\n+\n+  const connecting = gatewayState !== 'open' && !boot.error\n+  // Latches once we've actually shown the overlay, so the brief frame where\n+  // gatewayState flips to \"open\" (connecting -> false) before the exit phase\n+  // kicks in doesn't unmount us and cause a flash.\n+  const shownRef = useRef(false)\n+\n+  if (previewing || connecting) {\n+    shownRef.current = true\n+  }\n+\n+  // Decode loop — only while live (freeze the resolved word during the exit).\n+  useEffect(() => {\n+    if (phase !== 'live' || (!previewing && !connecting)) {\n+      return\n+    }\n+\n+    let resolved = 0\n+    let hold = 0\n+\n+    const id = window.setInterval(() => {\n+      if (resolved >= TAIL.length) {\n+        hold += 1\n+\n+        if (hold > 16) {\n+          resolved = 0\n+          hold = 0\n+        }\n+\n+        setTail(TAIL)\n+\n+        return\n+      }\n+\n+      resolved += 0.5\n+      setTail(scrambledTail(Math.floor(resolved)))\n+    }, TICK_MS)\n+\n+    return () => window.clearInterval(id)\n+  }, [phase, previewing, connecting])\n+\n+  // Kick off the exit when connected: real connect, or a faked timer in preview.\n+  useEffect(() => {\n+    if (phase !== 'live') {\n+      return\n+    }\n+\n+    if (previewing) {\n+      const id = window.setTimeout(() => {\n+        setTail(TAIL)\n+        setPhase('text-out')\n+      }, PREVIEW_CONNECT_MS)\n+\n+      return () => window.clearTimeout(id)\n+    }\n+\n+    if (gatewayState === 'open' && shownRef.current) {\n+      setTail(TAIL)\n+      setPhase('text-out')\n+    }\n+  }, [phase, previewing, gatewayState])\n+\n+  // Advance the exit choreography: text-out -> overlay-out -> gone.\n+  useEffect(() => {\n+    if (phase === 'text-out') {\n+      const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)\n+\n+      return () => window.clearTimeout(id)\n+    }\n+\n+    if (phase === 'overlay-out') {\n+      const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)\n+\n+      return () => window.clearTimeout(id)\n+    }\n+\n+    // Preview replays so we can keep watching the transition.\n+    if (phase === 'gone' && previewing) {\n+      const id = window.setTimeout(() => {\n+        setTail(TAIL)\n+        setPhase('live')\n+      }, PREVIEW_REPLAY_MS)\n+\n+      return () => window.clearTimeout(id)\n+    }\n+  }, [phase, previewing])\n+\n+  // Boot failed — BootFailureOverlay owns the screen; don't linger behind it.\n+  if (boot.error && !previewing) {\n+    return null\n+  }\n+\n+  // Real connect: once the fade finishes, get out of the way for good.\n+  if (phase === 'gone' && !previewing) {\n+    return null\n+  }\n+\n+  // Never showed (e.g. gateway already up on a warm reload) — stay out.\n+  if (!previewing && !connecting && !shownRef.current) {\n+    return null\n+  }\n+\n+  const leaving = phase !== 'live'\n+  const overlayHidden = phase === 'overlay-out' || phase === 'gone'\n+\n+  return (\n+    <div\n+      className={cn(\n+        'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',\n+        overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'\n+      )}\n+    >\n+      <style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>\n+      <span\n+        className={cn(\n+          'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',\n+          leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'\n+        )}\n+      >\n+        {PREFIX}\n+        {tail}\n+        <span\n+          aria-hidden=\"true\"\n+          className=\"dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]\"\n+          style={{ animation: 'gco-cursor 1s step-end infinite' }}\n+        />\n+      </span>\n+    </div>\n+  )\n+}"]},{"file":"apps/desktop/src/hermes.ts","hunks":["@@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient {\n   }\n }\n \n-export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {\n+export async function listSessions(\n+  limit = 40,\n+  minMessages = 0,\n+  archived: 'exclude' | 'include' | 'only' = 'exclude'\n+): Promise<PaginatedSessions> {\n   const result = await window.hermesDesktop.api<PaginatedSessions>({\n-    path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`\n+    path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`\n   })\n \n   return {","@@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat\n   }\n }\n \n+export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {\n+  return window.hermesDesktop.api<{ ok: boolean }>({\n+    path: `/api/sessions/${encodeURIComponent(id)}`,\n+    method: 'PATCH',\n+    body: { archived }\n+  })\n+}\n+\n export function searchSessions(query: string): Promise<SessionSearchResponse> {\n   return window.hermesDesktop.api<SessionSearchResponse>({\n     path: `/api/sessions/search?q=${encodeURIComponent(query)}`"]},{"file":"apps/desktop/src/lib/icons.ts","hunks":["@@ -2,6 +2,8 @@ import {\n   IconActivity as Activity,\n   IconAlertCircle as AlertCircle,\n   IconAlertTriangle as AlertTriangle,\n+  IconArchive as Archive,\n+  IconArchiveOff as ArchiveOff,\n   IconArrowUp as ArrowUp,\n   IconArrowUpRight as ArrowUpRight,\n   IconAt as AtSign,","@@ -98,6 +100,8 @@ export {\n   Activity,\n   AlertCircle,\n   AlertTriangle,\n+  Archive,\n+  ArchiveOff,\n   ArrowUp,\n   ArrowUpRight,\n   AtSign,"]},{"file":"apps/desktop/src/lib/media.ts","hunks":["@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {\n   return /^(?:https?|file):/i.test(path) ? path : `file://${path}`\n }\n \n+// Custom Electron scheme (registered in electron/main.cjs) that streams a local\n+// file with Range support. Used for audio/video so playback bypasses the data\n+// URL size cap and supports seeking. `path` may be a plain path or `file://…`.\n+export function mediaStreamUrl(path: string): string {\n+  return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`\n+}\n+\n export function mediaPathFromMarkdownHref(href?: string): string | null {\n   if (!href?.startsWith('#media:')) {\n     return null"]},{"file":"apps/desktop/src/types/hermes.ts","hunks":["@@ -240,6 +240,7 @@ export interface SessionCreateResponse {\n }\n \n export interface SessionInfo {\n+  archived?: boolean\n   cwd?: null | string\n   ended_at: null | number\n   id: string"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -1131,22 +1131,51 @@ async def get_action_status(name: str, lines: int = 200):\n \n \n @app.get(\"/api/sessions\")\n-async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):\n+async def get_sessions(\n+    limit: int = 20,\n+    offset: int = 0,\n+    min_messages: int = 0,\n+    archived: str = \"exclude\",\n+):\n+    \"\"\"List sessions.\n+\n+    ``archived`` controls how soft-archived sessions are treated:\n+    ``exclude`` (default) hides them, ``only`` returns just the archived ones\n+    (used by the desktop \"Archived sessions\" settings panel), and ``include``\n+    returns both.\n+    \"\"\"\n+    if archived not in (\"exclude\", \"only\", \"include\"):\n+        raise HTTPException(\n+            status_code=400,\n+            detail=\"archived must be one of: exclude, only, include\",\n+        )\n     try:\n         from hermes_state import SessionDB\n         db = SessionDB()\n         try:\n             min_message_count = max(0, min_messages)\n+            archived_only = archived == \"only\"\n+            include_archived = archived == \"include\"\n             sessions = db.list_sessions_rich(\n-                limit=limit, offset=offset, min_message_count=min_message_count\n+                limit=limit,\n+                offset=offset,\n+                min_message_count=min_message_count,\n+                include_archived=include_archived,\n+                archived_only=archived_only,\n+            )\n+            total = db.session_count(\n+                min_message_count=min_message_count,\n+                include_archived=include_archived,\n+                archived_only=archived_only,\n             )\n-            total = db.session_count(min_message_count=min_message_count)\n             now = time.time()\n             for s in sessions:\n                 s[\"is_active\"] = (\n                     s.get(\"ended_at\") is None\n                     and (now - s.get(\"last_active\", s.get(\"started_at\", 0))) < 300\n                 )\n+                # SQLite stores the flag as 0/1; expose a real JSON boolean.\n+                s[\"archived\"] = bool(s.get(\"archived\"))\n             return {\"sessions\": sessions, \"total\": total, \"limit\": limit, \"offset\": offset}\n         finally:\n             db.close()","@@ -3707,25 +3736,39 @@ async def delete_session_endpoint(session_id: str):\n \n class SessionRename(BaseModel):\n     title: Optional[str] = None\n+    archived: Optional[bool] = None\n \n \n @app.patch(\"/api/sessions/{session_id}\")\n async def rename_session_endpoint(session_id: str, body: SessionRename):\n-    \"\"\"Rename a session (or clear its title when ``title`` is empty/null).\"\"\"\n+    \"\"\"Update a session: rename (or clear its title) and/or archive it.\n+\n+    ``title`` renames (empty/null clears the title); ``archived`` soft-hides or\n+    restores the session. Either field may be omitted.\n+    \"\"\"\n     from hermes_state import SessionDB\n     db = SessionDB()\n     try:\n         sid = db.resolve_session_id(session_id)\n         if not sid:\n             raise HTTPException(status_code=404, detail=\"Session not found\")\n-        try:\n-            updated = db.set_session_title(sid, body.title or \"\")\n-        except ValueError as e:\n-            # Title too long, invalid characters, or already in use.\n-            raise HTTPException(status_code=400, detail=str(e))\n-        if not updated:\n-            raise HTTPException(status_code=404, detail=\"Session not found\")\n-        return {\"ok\": True, \"title\": db.get_session_title(sid) or \"\"}\n+        if body.title is None and body.archived is None:\n+            raise HTTPException(\n+                status_code=400,\n+                detail=\"Nothing to update; provide 'title' and/or 'archived'.\",\n+            )\n+        if body.title is not None:\n+            try:\n+                db.set_session_title(sid, body.title or \"\")\n+            except ValueError as e:\n+                # Title too long, invalid characters, or already in use.\n+                raise HTTPException(status_code=400, detail=str(e))\n+        if body.archived is not None:\n+            db.set_session_archived(sid, body.archived)\n+        result = {\"ok\": True, \"title\": db.get_session_title(sid) or \"\"}\n+        if body.archived is not None:\n+            result[\"archived\"] = bool(body.archived)\n+        return result\n     finally:\n         db.close()\n "]},{"file":"hermes_state.py","hunks":["@@ -264,6 +264,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:\n     handoff_platform TEXT,\n     handoff_error TEXT,\n     rewind_count INTEGER NOT NULL DEFAULT 0,\n+    archived INTEGER NOT NULL DEFAULT 0,\n     FOREIGN KEY (parent_session_id) REFERENCES sessions(id)\n );\n ","@@ -1430,6 +1431,22 @@ def get_session_title(self, session_id: str) -> Optional[str]:\n             row = cursor.fetchone()\n         return row[\"title\"] if row else None\n \n+    def set_session_archived(self, session_id: str, archived: bool) -> bool:\n+        \"\"\"Archive or unarchive a session.\n+\n+        Archived sessions are hidden from the default session list but keep all\n+        their messages — this is a soft hide, not a delete. Returns True when a\n+        row was updated.\n+        \"\"\"\n+        def _do(conn):\n+            cursor = conn.execute(\n+                \"UPDATE sessions SET archived = ? WHERE id = ?\",\n+                (1 if archived else 0, session_id),\n+            )\n+            return cursor.rowcount\n+        rowcount = self._execute_write(_do)\n+        return rowcount > 0\n+\n     def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:\n         \"\"\"Look up a session by exact title. Returns session dict or None.\"\"\"\n         with self._lock:","@@ -1549,6 +1566,8 @@ def list_sessions_rich(\n         min_message_count: int = 0,\n         project_compression_tips: bool = True,\n         order_by_last_active: bool = False,\n+        include_archived: bool = False,\n+        archived_only: bool = False,\n     ) -> List[Dict[str, Any]]:\n         \"\"\"List sessions with preview (first user message) and last active timestamp.\n ","@@ -1604,6 +1623,10 @@ def list_sessions_rich(\n         if min_message_count > 0:\n             where_clauses.append(\"s.message_count >= ?\")\n             params.append(min_message_count)\n+        if archived_only:\n+            where_clauses.append(\"s.archived = 1\")\n+        elif not include_archived:\n+            where_clauses.append(\"s.archived = 0\")\n \n         where_sql = f\"WHERE {' AND '.join(where_clauses)}\" if where_clauses else \"\"\n         if order_by_last_active:","@@ -3027,7 +3050,13 @@ def search_sessions(\n     # Utility\n     # =========================================================================\n \n-    def session_count(self, source: str = None, min_message_count: int = 0) -> int:\n+    def session_count(\n+        self,\n+        source: str = None,\n+        min_message_count: int = 0,\n+        include_archived: bool = False,\n+        archived_only: bool = False,\n+    ) -> int:\n         \"\"\"Count sessions, optionally filtered by source.\"\"\"\n         where_clauses = []\n         params = []","@@ -3038,6 +3067,10 @@ def session_count(self, source: str = None, min_message_count: int = 0) -> int:\n         if min_message_count > 0:\n             where_clauses.append(\"message_count >= ?\")\n             params.append(min_message_count)\n+        if archived_only:\n+            where_clauses.append(\"archived = 1\")\n+        elif not include_archived:\n+            where_clauses.append(\"archived = 0\")\n \n         where_sql = f\" WHERE {' AND '.join(where_clauses)}\" if where_clauses else \"\"\n "]},{"file":"tests/hermes_cli/test_web_server.py","hunks":["@@ -187,11 +187,11 @@ class _FakeDB:\n             def __init__(self, *args, **kwargs):\n                 pass\n \n-            def list_sessions_rich(self, limit, offset, min_message_count=0):\n+            def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):\n                 captured[\"list\"] = min_message_count\n                 return []\n \n-            def session_count(self, min_message_count=0):\n+            def session_count(self, min_message_count=0, **kwargs):\n                 captured[\"count\"] = min_message_count\n                 return 0\n ","@@ -250,6 +250,76 @@ def test_rename_session_not_found(self):\n         resp = self.client.patch(\"/api/sessions/does-not-exist\", json={\"title\": \"x\"})\n         assert resp.status_code == 404\n \n+    def test_archive_session_via_patch(self):\n+        \"\"\"PATCH archived=true soft-hides a session; archived=false restores it.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB()\n+        try:\n+            db.create_session(session_id=\"arch-me\", source=\"cli\")\n+            db.append_message(session_id=\"arch-me\", role=\"user\", content=\"hi\")\n+        finally:\n+            db.close()\n+\n+        resp = self.client.patch(\"/api/sessions/arch-me\", json={\"archived\": True})\n+        assert resp.status_code == 200\n+        assert resp.json()[\"archived\"] is True\n+\n+        # Hidden from the default list, surfaced by archived=only.\n+        listed = self.client.get(\"/api/sessions\").json()\n+        assert all(s[\"id\"] != \"arch-me\" for s in listed[\"sessions\"])\n+        only = self.client.get(\"/api/sessions?archived=only\").json()\n+        assert any(s[\"id\"] == \"arch-me\" for s in only[\"sessions\"])\n+\n+        resp = self.client.patch(\"/api/sessions/arch-me\", json={\"archived\": False})\n+        assert resp.status_code == 200\n+        restored = self.client.get(\"/api/sessions\").json()\n+        assert any(s[\"id\"] == \"arch-me\" for s in restored[\"sessions\"])\n+\n+    def test_patch_session_without_fields_is_400(self):\n+        \"\"\"An existing session + empty body is a bad request, not a 404.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB()\n+        try:\n+            db.create_session(session_id=\"no-fields\", source=\"cli\")\n+        finally:\n+            db.close()\n+\n+        resp = self.client.patch(\"/api/sessions/no-fields\", json={})\n+        assert resp.status_code == 400\n+\n+    def test_get_sessions_rejects_unknown_archived_value(self):\n+        resp = self.client.get(\"/api/sessions?archived=bogus\")\n+        assert resp.status_code == 400\n+\n+    def test_get_sessions_archived_is_boolean(self):\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB()\n+        try:\n+            db.create_session(session_id=\"bool-arch\", source=\"cli\")\n+            db.append_message(session_id=\"bool-arch\", role=\"user\", content=\"hi\")\n+        finally:\n+            db.close()\n+\n+        row = next(s for s in self.client.get(\"/api/sessions\").json()[\"sessions\"] if s[\"id\"] == \"bool-arch\")\n+        assert row[\"archived\"] is False\n+\n+    def test_rename_response_omits_archived_when_not_set(self):\n+        \"\"\"Title-only PATCH keeps its legacy {ok, title} response shape.\"\"\"\n+        from hermes_state import SessionDB\n+\n+        db = SessionDB()\n+        try:\n+            db.create_session(session_id=\"title-only\", source=\"cli\")\n+        finally:\n+            db.close()\n+\n+        resp = self.client.patch(\"/api/sessions/title-only\", json={\"title\": \"Hi\"})\n+        assert resp.status_code == 200\n+        assert \"archived\" not in resp.json()\n+\n     def test_audio_transcription_endpoint(self, monkeypatch):\n         import tools.transcription_tools as transcription_tools\n "]},{"file":"tests/test_hermes_state.py","hunks":["@@ -3509,3 +3509,43 @@ def execute(self, sql, params=()):\n         assert any(\"journal_mode=WAL\" in sql for sql in conn.executed), (\n             \"set-pragma must fire when probe returns 'delete'\"\n         )\n+\n+\n+class TestSessionArchive:\n+    \"\"\"Soft-archiving hides a session from default listings without deleting it.\"\"\"\n+\n+    def _seed(self, db, sid, *, archived=False):\n+        db.create_session(session_id=sid, source=\"cli\")\n+        db.append_message(session_id=sid, role=\"user\", content=f\"hello from {sid}\")\n+        if archived:\n+            db.set_session_archived(sid, True)\n+\n+    def test_set_session_archived_roundtrip(self, db):\n+        self._seed(db, \"s1\")\n+        assert db.set_session_archived(\"s1\", True) is True\n+        assert db.get_session(\"s1\")[\"archived\"] == 1\n+        assert db.set_session_archived(\"s1\", False) is True\n+        assert db.get_session(\"s1\")[\"archived\"] == 0\n+\n+    def test_set_session_archived_missing_row(self, db):\n+        assert db.set_session_archived(\"nope\", True) is False\n+\n+    def test_archived_excluded_by_default(self, db):\n+        self._seed(db, \"live\")\n+        self._seed(db, \"hidden\", archived=True)\n+\n+        ids = [s[\"id\"] for s in db.list_sessions_rich()]\n+        assert ids == [\"live\"]\n+        assert db.session_count() == 1\n+\n+    def test_archived_only_and_include(self, db):\n+        self._seed(db, \"live\")\n+        self._seed(db, \"hidden\", archived=True)\n+\n+        only = [s[\"id\"] for s in db.list_sessions_rich(archived_only=True)]\n+        assert only == [\"hidden\"]\n+        assert db.session_count(archived_only=True) == 1\n+\n+        both = {s[\"id\"] for s in db.list_sessions_rich(include_archived=True)}\n+        assert both == {\"live\", \"hidden\"}\n+        assert db.session_count(include_archived=True) == 2"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -884,6 +884,73 @@ def set_session_title(self, _key, _title):\n         server._sessions.pop(\"sid\", None)\n \n \n+def test_notification_event_routing_by_session_key(monkeypatch):\n+    \"\"\"Background-process events surface only in the session that owns them.\"\"\"\n+    mine = _session(session_key=\"mine\")\n+    other = _session(session_key=\"other\")\n+    monkeypatch.setattr(server, \"_sessions\", {\"a\": mine, \"b\": other})\n+\n+    # My own event → handle it.\n+    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"mine\"}) is False\n+    # Global/system event with no owner → handle it.\n+    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"\"}) is False\n+    assert server._notification_event_belongs_elsewhere(mine, {}) is False\n+    # Owned by another *live* session → defer to that session's poller.\n+    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"other\"}) is True\n+    # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.\n+    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"ghost\"}) is False\n+\n+\n+def test_session_create_does_not_persist_empty_row(monkeypatch):\n+    \"\"\"session.create must NOT eagerly write a DB row.\n+\n+    Every TUI/desktop launch opens a session here just to paint the composer;\n+    eagerly creating a row left an empty \"Untitled\" session behind for every\n+    launch the user never typed into. The row is created lazily on first prompt.\n+    \"\"\"\n+    created = []\n+\n+    class _FakeDB:\n+        def create_session(self, *args, **kwargs):\n+            created.append((args, kwargs))\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _FakeDB())\n+    monkeypatch.setattr(server, \"_start_agent_build\", lambda *a, **k: None)\n+    monkeypatch.setattr(\n+        server.threading,\n+        \"Timer\",\n+        lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),\n+    )\n+\n+    resp = server.handle_request(\n+        {\"id\": \"1\", \"method\": \"session.create\", \"params\": {\"cols\": 80}}\n+    )\n+    sid = resp[\"result\"][\"session_id\"]\n+    try:\n+        assert resp[\"result\"][\"stored_session_id\"]\n+        assert created == [], \"session.create should not persist an empty DB row\"\n+    finally:\n+        server._sessions.pop(sid, None)\n+\n+\n+def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):\n+    \"\"\"First prompt persists the row (INSERT OR IGNORE) capturing cwd up front.\"\"\"\n+    created = []\n+\n+    class _FakeDB:\n+        def create_session(self, key, source=None, model=None, cwd=None):\n+            created.append({\"key\": key, \"source\": source, \"model\": model, \"cwd\": cwd})\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _FakeDB())\n+    monkeypatch.setattr(server, \"_resolve_model\", lambda: \"test-model\")\n+\n+    server._ensure_session_db_row({\"session_key\": \"k1\", \"cwd\": str(tmp_path)})\n+\n+    assert created == [\n+        {\"key\": \"k1\", \"source\": \"tui\", \"model\": \"test-model\", \"cwd\": str(tmp_path)}\n+    ]\n+\n+\n def test_session_title_clears_pending_after_persist(monkeypatch):\n     class _FakeDB:\n         def __init__(self):"]},{"file":"tools/process_registry.py","hunks":["@@ -880,6 +880,7 @@ def _move_to_finished(self, session: ProcessSession):\n             self.completion_queue.put({\n                 \"type\": \"completion\",\n                 \"session_id\": session.id,\n+                \"session_key\": session.session_key,\n                 \"command\": session.command,\n                 \"exit_code\": session.exit_code,\n                 \"output\": output_tail,"]},{"file":"tui_gateway/server.py","hunks":["@@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None:\n         pass\n \n \n+def _ensure_session_db_row(session: dict) -> None:\n+    \"\"\"Idempotently persist the session's DB row on first real activity.\n+\n+    Called from prompt.submit so a row only exists once the user actually sends\n+    a message — abandoned drafts never leave an empty \"Untitled\" session behind.\n+    Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own\n+    lazy create) are no-ops. Captures cwd up front so workspace grouping works\n+    without waiting for a separate cwd update.\n+    \"\"\"\n+    key = session.get(\"session_key\")\n+    if not key:\n+        return\n+    db = _get_db()\n+    if db is None:\n+        return\n+    try:\n+        db.create_session(\n+            key,\n+            source=\"tui\",\n+            model=_resolve_model(),\n+            cwd=_session_cwd(session),\n+        )\n+    except Exception:\n+        logger.debug(\"failed to persist desktop session row\", exc_info=True)\n+\n+\n def _set_session_cwd(session: dict, cwd: str) -> str:\n     resolved = os.path.abspath(os.path.expanduser(str(cwd)))\n     if not os.path.isdir(resolved):","@@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict:\n         \"transport\": current_transport() or _stdio_transport,\n     }\n     _register_session_cwd(_sessions[sid])\n-    db = _get_db()\n-    if db is not None:\n-        try:\n-            db.create_session(\n-                key,\n-                source=\"tui\",\n-                model=_resolve_model(),\n-                cwd=_sessions[sid][\"cwd\"],\n-            )\n-        except Exception:\n-            logger.debug(\"failed to pre-create desktop session row\", exc_info=True)\n+    # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop\n+    # launch (and every \"New agent\" / draft) opens a session here just to paint\n+    # the composer, so eagerly creating a row left an \"Untitled\" empty session\n+    # behind for every launch the user never typed into. The row is now created\n+    # lazily on the first prompt (see _ensure_session_db_row + prompt.submit),\n+    # and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too.\n \n     # Return the lightweight session immediately so Ink can paint the composer\n     # + skeleton panel, then build the real AIAgent just after this response is","@@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict:\n         session[\"last_active\"] = time.time()\n         _start_inflight_turn(session, text)\n \n+    # Persist the DB row lazily, now that the user has actually sent a message.\n+    _ensure_session_db_row(session)\n     _start_agent_build(sid, session)\n \n     def run_after_agent_ready() -> None:","@@ -3865,6 +3888,35 @@ def run_after_agent_ready() -> None:\n     return _ok(rid, {\"status\": \"streaming\"})\n \n \n+def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:\n+    \"\"\"True if ``evt`` is owned by a *different* live session.\n+\n+    Background-process events carry the ``session_key`` of the session that\n+    started the process. Since all desktop sessions share one process-wide\n+    completion queue, each poller must skip events it doesn't own so a\n+    background job's completion surfaces in the session that launched it — not\n+    whichever poller happened to dequeue first. Orphaned events (owner gone)\n+    and global/system events (empty ``session_key``) return False so the\n+    current poller still handles them rather than losing them.\n+    \"\"\"\n+    evt_key = str(evt.get(\"session_key\") or \"\")\n+    if not evt_key:\n+        return False\n+    if evt_key == str(session.get(\"session_key\") or \"\"):\n+        return False\n+    try:\n+        snapshot = list(_sessions.values())\n+    except Exception:\n+        # If we can't safely enumerate live sessions, fail open so we don't\n+        # crash the poller thread or drop the event.\n+        return False\n+\n+    return any(\n+        s is not session and str(s.get(\"session_key\") or \"\") == evt_key\n+        for s in snapshot\n+    )\n+\n+\n def _notification_poller_loop(\n     stop_event: threading.Event, sid: str, session: dict\n ) -> None:","@@ -3887,6 +3939,16 @@ def _notification_poller_loop(\n         except Exception:\n             continue\n \n+        # Multiple desktop sessions share this one process-wide queue. Only\n+        # consume events that belong to *this* session — otherwise a background\n+        # process started in session A would surface its completion in whichever\n+        # session's poller happened to wake first (Ben's \"reported in a\n+        # different session\" bug). Leave foreign events for their owner.\n+        if _notification_event_belongs_elsewhere(session, evt):\n+            process_registry.completion_queue.put(evt)\n+            time.sleep(0.1)\n+            continue\n+\n         _evt_sid = evt.get(\"session_id\", \"\")\n         if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n             continue","@@ -3917,12 +3979,17 @@ def _notification_poller_loop(\n                 session[\"running\"] = False\n \n     # Drain any remaining events after stop signal (process all pending\n-    # before exiting so nothing is lost on shutdown).\n+    # before exiting so nothing is lost on shutdown). Events owned by other\n+    # live sessions are set aside and re-queued so their poller still sees them.\n+    deferred: list = []\n     while not process_registry.completion_queue.empty():\n         try:\n             evt = process_registry.completion_queue.get_nowait()\n         except Exception:\n             break\n+        if _notification_event_belongs_elsewhere(session, evt):\n+            deferred.append(evt)\n+            continue\n         _evt_sid = evt.get(\"session_id\", \"\")\n         if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n             continue","@@ -3951,6 +4018,10 @@ def _notification_poller_loop(\n             with session[\"history_lock\"]:\n                 session[\"running\"] = False\n \n+    # Hand any other sessions' events back to the shared queue.\n+    for evt in deferred:\n+        process_registry.completion_queue.put(evt)\n+\n \n def _start_notification_poller(sid: str, session: dict) -> threading.Event:\n     \"\"\"Start the background notification poller for a TUI session.\"\"\"\n"]}]},{"kind":"pr","number":42731,"title":"fix(tui): keep background notifications in owning session (#42674)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65419","number":65419}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":54785,"title":"fix(tui): route bg process notifications to owning session, drop orphaned events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"35803dd50e0e71e4cfd9504d14f0a1f4fd6d6405","number":0},{"kind":"commit","value":"569b912d7d","number":0},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#42731","number":42731},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#42731","number":42731},{"kind":"pr","value":"#35667","number":35667},{"kind":"pr","value":"#57586","number":57586},{"kind":"pr","value":"#60863","number":60863},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#64484","number":64484},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#64484","number":64484},{"kind":"pr","value":"#63317","number":63317},{"kind":"pr","value":"#42731","number":42731}],"category":"partial_fix","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":57535,"title":"fix(delegate): pin async completion to spawning parent session (#57498)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60871","number":60871}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57586,"title":"fix(tui): route async delegation results across the compression chain","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60863","number":60863},{"kind":"pr","value":"#59767","number":59767}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":58690,"title":"fix(delegation): route async delegate_task results back to originating session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60869","number":60869}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60441,"title":"fix(gateway): prevent background process notifications from being injected into wrong sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"dee592a0b1c4ce7ae65a5bea3e996dc42a817308","number":0},{"kind":"pr","value":"#60426","number":60426},{"kind":"pr","value":"#42674","number":42674}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60863,"title":"fix(delegation): route async results to their origin session across compression (#55578)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session_context.py","tests/test_tui_gateway_server.py","tests/tools/test_async_delegation.py","tests/tui_gateway/test_delegation_session_lifecycle.py","tools/async_delegation.py","tools/delegate_tool.py","tui_gateway/server.py"],"fix_hunks":[{"file":"gateway/session_context.py","hunks":["@@ -79,6 +79,13 @@ def session_context_engaged() -> bool:\n _SESSION_USER_NAME: ContextVar = ContextVar(\"HERMES_SESSION_USER_NAME\", default=_UNSET)\n _SESSION_KEY: ContextVar = ContextVar(\"HERMES_SESSION_KEY\", default=_UNSET)\n _SESSION_ID: ContextVar = ContextVar(\"HERMES_SESSION_ID\", default=_UNSET)\n+# In-process UI session/window id for multi-session desktop/TUI hosts. This is\n+# intentionally separate from HERMES_SESSION_ID: the latter is the durable\n+# conversation/session-db id, while the UI id is the live frontend tab/window\n+# that commissioned a detached completion. Background completions use it as a\n+# precise return address so a stale/rotated durable session key cannot be\n+# consumed by whichever desktop poller wakes first.\n+_SESSION_UI_SESSION_ID: ContextVar = ContextVar(\"HERMES_UI_SESSION_ID\", default=_UNSET)\n # ID of the message that triggered the current turn. Used as a reply anchor\n # so background-process notifications stay inside the originating Telegram\n # private-chat topic (those lanes route only with thread id + reply anchor).","@@ -123,6 +130,7 @@ def session_context_engaged() -> bool:\n     \"HERMES_SESSION_USER_NAME\": _SESSION_USER_NAME,\n     \"HERMES_SESSION_KEY\": _SESSION_KEY,\n     \"HERMES_SESSION_ID\": _SESSION_ID,\n+    \"HERMES_UI_SESSION_ID\": _SESSION_UI_SESSION_ID,\n     \"HERMES_SESSION_MESSAGE_ID\": _SESSION_MESSAGE_ID,\n     \"HERMES_SESSION_PROFILE\": _SESSION_PROFILE,\n     \"HERMES_CRON_AUTO_DELIVER_PLATFORM\": _CRON_AUTO_DELIVER_PLATFORM,","@@ -160,6 +168,7 @@ def set_session_vars(\n     profile: str = \"\",\n     cwd: str = \"\",\n     async_delivery: bool = True,\n+    ui_session_id: str = \"\",\n ) -> list:\n     \"\"\"Set all session context variables and return reset tokens.\n ","@@ -191,6 +200,7 @@ def set_session_vars(\n         _SESSION_USER_NAME.set(user_name),\n         _SESSION_KEY.set(session_key),\n         _SESSION_ID.set(session_id),\n+        _SESSION_UI_SESSION_ID.set(ui_session_id),\n         _SESSION_MESSAGE_ID.set(message_id),\n         _SESSION_PROFILE.set(profile),\n         _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),","@@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None:\n         _SESSION_USER_NAME,\n         _SESSION_KEY,\n         _SESSION_ID,\n+        _SESSION_UI_SESSION_ID,\n         _SESSION_MESSAGE_ID,\n         _SESSION_PROFILE,\n     ):"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -2112,14 +2112,85 @@ def test_notification_event_routing_by_session_key(monkeypatch):\n     monkeypatch.setattr(server, \"_sessions\", {\"a\": mine, \"b\": other})\n \n     # My own event → handle it.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"mine\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"mine\"}) is False\n     # Global/system event with no owner → handle it.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"\"}) is False\n-    assert server._notification_event_belongs_elsewhere(mine, {}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {}) is False\n     # Owned by another *live* session → defer to that session's poller.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"other\"}) is True\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"other\"}) is True\n     # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"ghost\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"ghost\"}) is False\n+\n+\n+def test_async_delegation_event_prefers_origin_ui_session(monkeypatch):\n+    \"\"\"Detached subagent completions return to the commissioning TUI tab.\n+\n+    Regression: when the durable session key was stale/orphaned, whichever\n+    desktop poller woke first could consume the async result and inject it into\n+    an unrelated session.\n+    \"\"\"\n+    mine = _session(session_key=\"current-key\")\n+    other = _session(session_key=\"unrelated-key\")\n+    monkeypatch.setattr(server, \"_sessions\", {\"origin-sid\": mine, \"other-sid\": other})\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"session_key\": \"stale-or-rotated-key\",\n+        \"origin_ui_session_id\": \"origin-sid\",\n+    }\n+\n+    assert server._notification_event_belongs_elsewhere(\"other-sid\", other, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"origin-sid\", mine, evt) is False\n+\n+\n+def test_notification_event_follows_compression_continuation(monkeypatch):\n+    \"\"\"Events keyed to a compressed parent route to the live continuation.\"\"\"\n+    old_parent = _session(session_key=\"old-parent\")\n+    live_tip = _session(session_key=\"new-tip\")\n+    monkeypatch.setattr(server, \"_sessions\", {\"old-sid\": old_parent, \"tip-sid\": live_tip})\n+\n+    class _DB:\n+        def resolve_resume_session_id(self, session_id):\n+            return \"new-tip\" if session_id == \"old-parent\" else session_id\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    evt = {\"type\": \"async_delegation\", \"session_key\": \"old-parent\"}\n+\n+    assert server._notification_event_belongs_elsewhere(\"old-sid\", old_parent, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"tip-sid\", live_tip, evt) is False\n+    # A third session must leave it alone for the continuation's poller.\n+    third = _session(session_key=\"third\")\n+    monkeypatch.setattr(\n+        server,\n+        \"_sessions\",\n+        {\"old-sid\": old_parent, \"tip-sid\": live_tip, \"third-sid\": third},\n+    )\n+    assert server._notification_event_belongs_elsewhere(\"third-sid\", third, evt) is True\n+\n+\n+def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch):\n+    \"\"\"A closed origin tab must not steal its resumed continuation's result.\"\"\"\n+    finalized_origin = _session(session_key=\"old-parent\", _finalized=True)\n+    live_tip = _session(session_key=\"new-tip\")\n+    monkeypatch.setattr(\n+        server,\n+        \"_sessions\",\n+        {\"origin-sid\": finalized_origin, \"tip-sid\": live_tip},\n+    )\n+\n+    class _DB:\n+        def resolve_resume_session_id(self, session_id):\n+            return \"new-tip\" if session_id == \"old-parent\" else session_id\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"session_key\": \"old-parent\",\n+        \"origin_ui_session_id\": \"origin-sid\",\n+    }\n+\n+    assert server._notification_event_belongs_elsewhere(\"origin-sid\", finalized_origin, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"tip-sid\", live_tip, evt) is False\n \n \n def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch):"]},{"file":"tests/tools/test_async_delegation.py","hunks":["@@ -289,6 +289,68 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):\n     assert \"the real task\" in text\n \n \n+def test_delegate_task_background_uses_live_tui_agent_session_id(monkeypatch):\n+    \"\"\"TUI async delegation must route to the live/compressed agent id.\n+\n+    Regression: delegate_task captured the stale approval/session context key\n+    after compression rotated parent_agent.session_id. The resulting completion\n+    was orphaned and could be consumed by an unrelated desktop session poller.\n+    \"\"\"\n+    import json\n+    from unittest.mock import MagicMock\n+    import tools.delegate_tool as dt\n+    from gateway.session_context import clear_session_vars, set_session_vars\n+    from tools.approval import reset_current_session_key, set_current_session_key\n+\n+    parent = MagicMock()\n+    parent._delegate_depth = 0\n+    parent.session_id = \"post-compress-tip\"\n+    parent._interrupt_requested = False\n+    parent._active_children = []\n+    parent._active_children_lock = None\n+    fake_child = MagicMock()\n+    fake_child._delegate_role = \"leaf\"\n+\n+    creds = {\n+        \"model\": \"m\", \"provider\": None, \"base_url\": None, \"api_key\": None,\n+        \"api_mode\": None, \"command\": None, \"args\": None,\n+    }\n+    monkeypatch.setattr(dt, \"_build_child_agent\", lambda **kw: fake_child)\n+    monkeypatch.setattr(dt, \"_resolve_delegation_credentials\", lambda *a, **k: creds)\n+    monkeypatch.setattr(\n+        dt,\n+        \"_run_single_child\",\n+        lambda *a, **k: {\n+            \"task_index\": 0,\n+            \"status\": \"completed\",\n+            \"summary\": \"done\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.1,\n+            \"model\": \"m\",\n+            \"exit_reason\": \"completed\",\n+        },\n+    )\n+\n+    approval_token = [redacted:secret-assignment]\"pre-compress-parent\")\n+    session_tokens = [redacted:secret-assignment]\n+        source=\"tui\",\n+        session_key=\"pre-compress-parent\",\n+        ui_session_id=\"origin-tab\",\n+    )\n+    try:\n+        out = dt.delegate_task(goal=\"bg task\", background=True, parent_agent=parent)\n+        assert json.loads(out)[\"status\"] == \"dispatched\"\n+        evt = _drain_one()\n+    finally:\n+        reset_current_session_key(approval_token)\n+        clear_session_vars(session_tokens)\n+\n+    assert evt is not None\n+    assert evt[\"type\"] == \"async_delegation\"\n+    assert evt[\"session_key\"] == \"post-compress-tip\"\n+    assert evt[\"origin_ui_session_id\"] == \"origin-tab\"\n+\n+\n def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch):\n     \"\"\"A multi-item batch with background=True dispatches the WHOLE fan-out as\n     ONE background unit (one handle, one async slot). The children run in"]},{"file":"tests/tui_gateway/test_delegation_session_lifecycle.py","hunks":["@@ -0,0 +1,158 @@\n+\"\"\"Fail-closed ownership + session-scoped delegation lifecycle (#55578).\n+\n+Covers the two hardening rules layered on top of the origin-routing salvage:\n+\n+1. ``_session_owns_notification_event`` — positive-proof ownership. An\n+   async-delegation completion may only be injected into a session that\n+   PROVABLY commissioned it (origin UI id, or session-key/lineage match).\n+   Orphans are never adopted by a foreign chat.\n+\n+2. ``interrupt_for_session`` — a session's in-flight async delegations end\n+   with the session. ``_finalize_session`` interrupts delegations owned by\n+   the closing session (by origin UI id always; by durable key only when the\n+   TUI owns the lifecycle).\n+\"\"\"\n+\n+import threading\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+import tools.async_delegation as ad\n+from tui_gateway.server import (\n+    _finalize_session,\n+    _session_owns_notification_event,\n+)\n+\n+\n[redacted:email](autouse=True)\n+def _reset_async_delegation():\n+    ad._reset_for_tests()\n+    yield\n+    ad._reset_for_tests()\n+\n+\n+class TestSessionOwnsNotificationEvent:\n+    def _session(self, key=\"sess_key_1\"):\n+        return {\"session_key\": key, \"_finalized\": False}\n+\n+    def test_origin_ui_match_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"tab1\", \"session_key\": \"other\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is True\n+\n+    def test_session_key_match_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"sess_key_1\"}\n+        assert _session_owns_notification_event(\"tabX\", self._session(\"sess_key_1\"), evt) is True\n+\n+    def test_orphan_is_not_owned(self):\n+        \"\"\"No origin match, no key match, owner gone → NOT ours (fail closed).\"\"\"\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"dead_tab\", \"session_key\": \"gone_key\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is False\n+\n+    def test_empty_key_and_origin_not_owned(self):\n+        \"\"\"A delegation event with no return address at all is never adopted.\"\"\"\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is False\n+\n+    def test_finalized_session_owns_nothing(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"tab1\", \"session_key\": \"sess_key_1\"}\n+        sess = self._session()\n+        sess[\"_finalized\"] = True\n+        assert _session_owns_notification_event(\"tab1\", sess, evt) is False\n+\n+    def test_compression_chain_resolution_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"parent_key\"}\n+        db = MagicMock()\n+        db.resolve_resume_session_id.return_value = \"child_key\"\n+        with patch(\"tui_gateway.server._get_db\", return_value=db):\n+            assert _session_owns_notification_event(\"tabX\", self._session(\"child_key\"), evt) is True\n+\n+\n+class TestInterruptForSession:\n+    def _seed_record(self, delegation_id, session_key=\"\", origin_ui_session_id=\"\", status=\"running\"):\n+        fn = MagicMock()\n+        with ad._records_lock:\n+            ad._records[delegation_id] = {\n+                \"delegation_id\": delegation_id,\n+                \"status\": status,\n+                \"session_key\": session_key,\n+                \"origin_ui_session_id\": origin_ui_session_id,\n+                \"interrupt_fn\": fn,\n+            }\n+        return fn\n+\n+    def test_interrupts_only_matching_session(self):\n+        mine = self._seed_record(\"d1\", session_key=\"sess_A\")\n+        other = self._seed_record(\"d2\", session_key=\"sess_B\")\n+        n = ad.interrupt_for_session(session_key=\"sess_A\")\n+        assert n == 1\n+        mine.assert_called_once()\n+        other.assert_not_called()\n+\n+    def test_matches_by_origin_ui_session_id(self):\n+        mine = self._seed_record(\"d1\", origin_ui_session_id=\"tab1\")\n+        other = self._seed_record(\"d2\", origin_ui_session_id=\"tab2\")\n+        n = ad.interrupt_for_session(origin_ui_session_id=\"tab1\")\n+        assert n == 1\n+        mine.assert_called_once()\n+        other.assert_not_called()\n+\n+    def test_no_selector_is_noop(self):\n+        fn = self._seed_record(\"d1\", session_key=\"sess_A\")\n+        assert ad.interrupt_for_session() == 0\n+        fn.assert_not_called()\n+\n+    def test_completed_records_untouched(self):\n+        fn = self._seed_record(\"d1\", session_key=\"sess_A\", status=\"completed\")\n+        assert ad.interrupt_for_session(session_key=\"sess_A\") == 0\n+        fn.assert_not_called()\n+\n+\n+class TestFinalizeInterruptsOwnDelegations:\n+    def _make_session(self, session_key=\"sess_A\", sid=\"tab1\"):\n+        agent = MagicMock()\n+        agent.session_id = session_key\n+        agent._session_messages = None\n+        agent.model = \"m\"\n+        agent.platform = \"tui\"\n+        return {\n+            \"agent\": agent,\n+            \"history\": [{\"role\": \"user\", \"content\": \"x\"}],\n+            \"history_lock\": threading.Lock(),\n+            \"session_key\": session_key,\n+            \"_finalized\": False,\n+            \"_sid\": sid,\n+        }\n+\n+    @patch(\"tui_gateway.server._get_db\")\n+    def test_finalize_interrupts_sessions_delegations(self, mock_get_db):\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"source\": \"tui\"}\n+        mock_get_db.return_value = mock_db\n+\n+        with patch(\"tools.async_delegation.interrupt_for_session\") as mock_int:\n+            _finalize_session(self._make_session(), end_reason=\"tui_close\")\n+\n+        mock_int.assert_called_once()\n+        kwargs = mock_int.call_args.kwargs\n+        assert kwargs[\"session_key\"] == \"sess_A\"\n+        assert kwargs[\"origin_ui_session_id\"] == \"tab1\"\n+\n+    @patch(\"tui_gateway.server._get_db\")\n+    def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db):\n+        \"\"\"Closing a TUI viewer tab on a live gateway session must not kill\n+        the gateway's own background work — key-based interrupt is skipped,\n+        origin-id interrupt (this tab's own dispatches) still applies.\"\"\"\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"source\": \"telegram\"}\n+        mock_get_db.return_value = mock_db\n+\n+        with patch(\"tools.async_delegation.interrupt_for_session\") as mock_int:\n+            _finalize_session(\n+                self._make_session(session_key=\"agent:main:telegram:dm:123\", sid=\"tab9\"),\n+                end_reason=\"ws_orphan_reap\",\n+            )\n+\n+        kwargs = mock_int.call_args.kwargs\n+        assert kwargs[\"session_key\"] == \"\"\n+        assert kwargs[\"origin_ui_session_id\"] == \"tab9\""]},{"file":"tools/async_delegation.py","hunks":["@@ -130,6 +130,7 @@ def dispatch_async_delegation(\n     model: Optional[str],\n     session_key: str,\n     runner: Callable[[], Dict[str, Any]],\n+    origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,\n     max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,\n ) -> Dict[str, Any]:","@@ -172,6 +173,7 @@ def dispatch_async_delegation(\n         \"role\": role,\n         \"model\": model,\n         \"session_key\": session_key,\n+        \"origin_ui_session_id\": origin_ui_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -282,6 +284,7 @@ def _push_completion_event(\n         # session_key routes the completion back to the originating gateway\n         # session; empty string => CLI (single-session) path.\n         \"session_key\": record.get(\"session_key\", \"\"),\n+        \"origin_ui_session_id\": record.get(\"origin_ui_session_id\", \"\"),\n         \"goal\": record.get(\"goal\", \"\"),\n         \"context\": record.get(\"context\"),\n         \"toolsets\": record.get(\"toolsets\"),","@@ -317,6 +320,7 @@ def dispatch_async_delegation_batch(\n     model: Optional[str],\n     session_key: str,\n     runner: Callable[[], Dict[str, Any]],\n+    origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,\n     max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,\n ) -> Dict[str, Any]:","@@ -356,6 +360,7 @@ def dispatch_async_delegation_batch(\n         \"role\": role,\n         \"model\": model,\n         \"session_key\": session_key,\n+        \"origin_ui_session_id\": origin_ui_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -453,6 +458,7 @@ def _finalize_batch(\n         \"type\": \"async_delegation\",\n         \"delegation_id\": delegation_id,\n         \"session_key\": event_record.get(\"session_key\", \"\"),\n+        \"origin_ui_session_id\": event_record.get(\"origin_ui_session_id\", \"\"),\n         \"goal\": event_record.get(\"goal\", \"\"),\n         \"goals\": event_record.get(\"goals\"),\n         \"context\": event_record.get(\"context\"),","@@ -519,6 +525,54 @@ def interrupt_all(reason: str = \"shutdown\") -> int:\n     return count\n \n \n+def interrupt_for_session(\n+    session_key: str = \"\",\n+    origin_ui_session_id: str = \"\",\n+    reason: str = \"session_end\",\n+) -> int:\n+    \"\"\"Signal running async delegations owned by ONE session to stop.\n+\n+    A delegation's lifecycle is bound to the session that spawned it: when\n+    that session ends, its in-flight background subagents must end with it —\n+    a completed orphan would otherwise sit on the shared completion queue\n+    with no live owner, either leaking into another chat or burning tokens\n+    with no one listening (#55578).\n+\n+    Matches on ``origin_ui_session_id`` (the live UI session that\n+    commissioned the work) and/or the durable ``session_key``; either\n+    matching field claims the record. Returns how many were interrupted.\n+    \"\"\"\n+    if not session_key and not origin_ui_session_id:\n+        return 0\n+    count = 0\n+    with _records_lock:\n+        targets = [\n+            r for r in _records.values()\n+            if r.get(\"status\") == \"running\"\n+            and (\n+                (origin_ui_session_id and str(r.get(\"origin_ui_session_id\") or \"\") == origin_ui_session_id)\n+                or (session_key and str(r.get(\"session_key\") or \"\") == session_key)\n+            )\n+        ]\n+    for r in targets:\n+        fn = r.get(\"interrupt_fn\")\n+        if callable(fn):\n+            try:\n+                fn()\n+                count += 1\n+            except Exception as exc:\n+                logger.debug(\n+                    \"interrupt_for_session: %s interrupt failed: %s\",\n+                    r.get(\"delegation_id\"), exc,\n+                )\n+    if count:\n+        logger.info(\n+            \"Interrupted %d async delegation(s) for ending session (%s)\",\n+            count, reason,\n+        )\n+    return count\n+\n+\n def _reset_for_tests() -> None:\n     \"\"\"Test-only: clear all state and tear down the executor.\"\"\"\n     global _executor, _executor_max_workers"]},{"file":"tools/delegate_tool.py","hunks":["@@ -2796,6 +2796,25 @@ def _execute_and_aggregate() -> dict:\n             return json.dumps(_sync_result, ensure_ascii=False)\n \n         _session_key = get_current_session_key(default=\"\")\n+        _origin_ui_session_id = \"\"\n+        try:\n+            from gateway.session_context import get_session_env\n+\n+            _source = get_session_env(\"HERMES_SESSION_SOURCE\", \"\")\n+            _origin_ui_session_id = get_session_env(\"HERMES_UI_SESSION_ID\", \"\")\n+            # In desktop/TUI, the routable session key is the durable\n+            # AIAgent.session_id. Context compression can rotate that id during\n+            # the same turn before the TUI-side session dict is re-anchored;\n+            # if we capture the stale approval/session context key here, the\n+            # async completion becomes an orphan and any desktop poller may\n+            # consume it. Gateway chats are different: their session_key is the\n+            # platform conversation key (agent:main:...), so keep it there.\n+            if _source == \"tui\":\n+                _agent_session_id = str(getattr(parent_agent, \"session_id\", \"\") or \"\")\n+                if _agent_session_id:\n+                    _session_key = _agent_session_id\n+        except Exception:\n+            _origin_ui_session_id = \"\"\n         _child_agents = [c for (_, _, c) in children]\n \n         # Detach every child from the parent's interrupt-propagation list — the","@@ -2836,6 +2855,7 @@ def _batch_interrupt():\n             role=top_role,\n             model=creds[\"model\"],\n             session_key=_session_key,\n+            origin_ui_session_id=_origin_ui_session_id,\n             runner=_batch_runner,\n             interrupt_fn=_batch_interrupt,\n             max_async_children=_get_max_async_children(),"]},{"file":"tui_gateway/server.py","hunks":["@@ -625,6 +625,7 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n     # Use session_id (from agent.session_id) not session_key — after compression,\n     # session_key may be stale (the ended parent) while session_id is the live\n     # continuation. Fix for #20001.\n+    _tui_owns_lifecycle = True\n     if session_id:\n         try:\n             db = _get_db()","@@ -638,11 +639,40 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n                 # repeats on every inbound message.  (#60609)\n                 row = db.get_session(session_id)\n                 source = (row or {}).get(\"source\", \"\")\n-                if not _is_gateway_owned_source(source):\n+                _tui_owns_lifecycle = not _is_gateway_owned_source(source)\n+                if _tui_owns_lifecycle:\n                     db.end_session(session_id, end_reason)\n         except Exception:\n             pass\n \n+    # A session's in-flight async delegations end WITH the session (#55578):\n+    # once nobody owns the return address, a still-running background subagent\n+    # can only burn tokens and park an orphaned completion on the shared\n+    # queue. Always interrupt delegations commissioned by THIS live UI session\n+    # (its sid); additionally interrupt by durable session_key, but only when\n+    # the TUI owns the lifecycle — closing a viewer tab on a live gateway\n+    # session must not kill the gateway's own background work.\n+    try:\n+        from tools.async_delegation import interrupt_for_session\n+\n+        _own_sid = str(session.get(\"_sid\") or \"\")\n+        if not _own_sid:\n+            try:\n+                with _sessions_lock:\n+                    for _cand_sid, _cand in _sessions.items():\n+                        if _cand is session:\n+                            _own_sid = _cand_sid\n+                            break\n+            except Exception:\n+                _own_sid = \"\"\n+        interrupt_for_session(\n+            session_key=str(session_key or \"\") if _tui_owns_lifecycle else \"\",\n+            origin_ui_session_id=_own_sid,\n+            reason=end_reason,\n+        )\n+    except Exception:\n+        pass\n+\n     # Close the slash-worker subprocess as part of finalize itself, not just\n     # in the callers. Defense-in-depth: every session-end path goes through\n     # _finalize_session (it's the single ``_finalized``-guarded chokepoint), so","@@ -712,6 +742,10 @@ def _close_session_by_id(sid: str, *, end_reason: str = \"tui_close\") -> bool:\n         session = _sessions.pop(sid, None)\n     if session is None:\n         return False\n+    # The session is already out of _sessions here, so downstream teardown\n+    # (e.g. _finalize_session's per-session async-delegation interrupt) can't\n+    # recover its live id by scanning the dict — stamp it on the record.\n+    session[\"_sid\"] = sid\n     _teardown_session(session, end_reason=end_reason)\n     return True\n ","@@ -1946,7 +1980,12 @@ def _cwd_for_session_key(session_key: str) -> str:\n     return \"\"\n \n \n-def _set_session_context(session_key: str, cwd: str | None = None) -> list:\n+def _set_session_context(\n+    session_key: str,\n+    cwd: str | None = None,\n+    *,\n+    ui_session_id: str = \"\",\n+) -> list:\n     try:\n         from gateway.session_context import set_session_vars\n ","@@ -1961,7 +2000,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list:\n                 if sess.get(\"session_key\") == session_key:\n                     source = _session_source(sess)\n                     break\n-        return set_session_vars(session_key=session_key, source=source, cwd=resolved)\n+        return set_session_vars(\n+            session_key=session_key,\n+            source=source,\n+            cwd=resolved,\n+            ui_session_id=ui_session_id,\n+        )\n     except Exception:\n         return []\n ","@@ -8451,22 +8495,76 @@ def run_after_agent_ready() -> None:\n     return _ok(rid, {\"status\": \"streaming\"})\n \n \n-def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:\n+def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> bool:\n     \"\"\"True if ``evt`` is owned by a *different* live session.\n \n-    Background-process events carry the ``session_key`` of the session that\n-    started the process. Since all desktop sessions share one process-wide\n-    completion queue, each poller must skip events it doesn't own so a\n-    background job's completion surfaces in the session that launched it — not\n-    whichever poller happened to dequeue first. Orphaned events (owner gone)\n-    and global/system events (empty ``session_key``) return False so the\n-    current poller still handles them rather than losing them.\n+    Background completions carry the ``session_key`` of the session that started\n+    the work. Async delegation completions from the desktop also carry\n+    ``origin_ui_session_id``: the live TUI tab/window that commissioned them.\n+    Since all desktop sessions share one process-wide completion queue, each\n+    poller must skip events it doesn't own so a detached result surfaces in the\n+    launching session, not whichever poller happened to dequeue first.\n     \"\"\"\n+    evt_ui_sid = str(evt.get(\"origin_ui_session_id\") or \"\")\n+    if evt_ui_sid:\n+        if evt_ui_sid == str(sid or \"\") and not session.get(\"_finalized\"):\n+            return False\n+        try:\n+            with _sessions_lock:\n+                owner_live = evt_ui_sid in _sessions and not _sessions[evt_ui_sid].get(\"_finalized\")\n+        except Exception:\n+            owner_live = False\n+        if owner_live:\n+            return True\n+        # If the exact UI tab is gone, fall through to durable session_key\n+        # routing. That avoids wrong-session delivery while still allowing a\n+        # resumed continuation with the same durable key/lineage to claim it.\n+\n     evt_key = str(evt.get(\"session_key\") or \"\")\n     if not evt_key:\n         return False\n-    if evt_key == str(session.get(\"session_key\") or \"\"):\n+\n+    current_keys = {\n+        str(session.get(\"session_key\") or \"\"),\n+        _session_lookup_key(session, fallback=sid),\n+    }\n+\n+    # Compression can rotate AIAgent.session_id while the detached child is\n+    # still running. Resolve the event's original key to its continuation tip so\n+    # an event captured before or after compression still maps to the same live\n+    # desktop session instead of becoming an orphan that any poller may consume.\n+    resolved_key = evt_key\n+    try:\n+        db = _get_db()\n+        if db is not None:\n+            resolved_key = db.resolve_resume_session_id(evt_key) or evt_key\n+    except Exception:\n+        resolved_key = evt_key\n+\n+    # If the key has a live continuation, prefer that continuation over the\n+    # compressed parent. Otherwise a stale parent tab could consume the event\n+    # before the real current conversation sees it.\n+    if resolved_key != evt_key:\n+        if resolved_key in current_keys:\n+            return False\n+        try:\n+            with _sessions_lock:\n+                continuation_live = any(\n+                    not s.get(\"_finalized\")\n+                    and (\n+                        str(s.get(\"session_key\") or \"\") == resolved_key\n+                        or _session_lookup_key(s, fallback=\"\") == resolved_key\n+                    )\n+                    for s in _sessions.values()\n+                )\n+        except Exception:\n+            continuation_live = False\n+        if continuation_live:\n+            return True\n+\n+    if evt_key in current_keys:\n         return False\n+\n     try:\n         with _sessions_lock:\n             snapshot = list(_sessions.values())","@@ -8476,11 +8574,50 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:\n         return False\n \n     return any(\n-        s is not session and str(s.get(\"session_key\") or \"\") == evt_key\n+        s is not session\n+        and not s.get(\"_finalized\")\n+        and (\n+            str(s.get(\"session_key\") or \"\") in {evt_key, resolved_key}\n+            or _session_lookup_key(s, fallback=\"\") in {evt_key, resolved_key}\n+        )\n         for s in snapshot\n     )\n \n \n+def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool:\n+    \"\"\"True iff *this* session PROVABLY owns ``evt``.\n+\n+    Positive ownership — the mirror of ``_notification_event_belongs_elsewhere``\n+    minus its orphan-adoption fallback. An event owns-matches when its\n+    ``origin_ui_session_id`` is this live session, or its ``session_key``\n+    (raw or resolved through the compression chain) matches this session's\n+    key/lineage. Used as a fail-closed gate for async-delegation payloads:\n+    \"not provably elsewhere\" is NOT good enough to inject a conversation\n+    payload into this chat (#55578).\n+    \"\"\"\n+    if session.get(\"_finalized\"):\n+        return False\n+    if str(evt.get(\"origin_ui_session_id\") or \"\") == str(sid or \"\"):\n+        return True\n+    evt_key = str(evt.get(\"session_key\") or \"\")\n+    if not evt_key:\n+        return False\n+    current_keys = {\n+        str(session.get(\"session_key\") or \"\"),\n+        _session_lookup_key(session, fallback=sid),\n+    }\n+    if evt_key in current_keys:\n+        return True\n+    try:\n+        db = _get_db()\n+        resolved_key = (\n+            db.resolve_resume_session_id(evt_key) if db is not None else evt_key\n+        ) or evt_key\n+    except Exception:\n+        resolved_key = evt_key\n+    return resolved_key in current_keys\n+\n+\n def _notification_event_dedup_key(evt: dict) -> tuple:\n     \"\"\"Return the UI-emission identity for a process notification event.\n ","@@ -8546,11 +8683,37 @@ def _notification_poller_loop(\n         # process started in session A would surface its completion in whichever\n         # session's poller happened to wake first (Ben's \"reported in a\n         # different session\" bug). Leave foreign events for their owner.\n-        if _notification_event_belongs_elsewhere(session, evt):\n+        if _notification_event_belongs_elsewhere(sid, session, evt):\n             process_registry.completion_queue.put(evt)\n             time.sleep(0.1)\n             continue\n \n+        # Fail closed for async-delegation results (#55578): these carry a\n+        # conversation payload, and injecting one into any chat other than the\n+        # one that commissioned it is a hard cross-session leak. The\n+        # belongs-elsewhere check above already re-queued events owned by\n+        # another LIVE session; what reaches here is either ours or an\n+        # orphan whose owner is gone. Orphaned delegation payloads are\n+        # DROPPED, not adopted — the subagent's summary is already persisted\n+        # in the delegation records/output store, so nothing is lost, whereas\n+        # a wrong-chat injection is unrecoverable. Non-delegation events\n+        # (background process completions etc.) keep the historical\n+        # adopt-orphans behavior.\n+        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n+            sid, session, evt\n+        ):\n+            logger.warning(\n+                \"async-delegation completion %s has no live owner \"\n+                \"(origin=%r key=%r); dropping from injection instead of \"\n+                \"delivering to session %s (#55578 fail-closed; result \"\n+                \"remains in the delegation records)\",\n+                evt.get(\"delegation_id\", \"?\"),\n+                str(evt.get(\"origin_ui_session_id\") or \"\"),\n+                str(evt.get(\"session_key\") or \"\"),\n+                sid,\n+            )\n+            continue\n+\n         _evt_sid = evt.get(\"session_id\", \"\")\n         if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n             continue","@@ -8604,7 +8767,16 @@ def _notification_poller_loop(\n             evt = process_registry.completion_queue.get_nowait()\n         except Exception:\n             break\n-        if _notification_event_belongs_elsewhere(session, evt):\n+        if _notification_event_belongs_elsewhere(sid, session, evt):\n+            deferred.append(evt)\n+            continue\n+        # Same fail-closed rule as the live loop: an orphaned async-delegation\n+        # payload is never adopted by a foreign session — defer it (a later\n+        # resume of the owner's lineage can still claim it) rather than\n+        # injecting another chat's conversation here (#55578).\n+        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n+            sid, session, evt\n+        ):\n             deferred.append(evt)\n             continue\n         _evt_sid = evt.get(\"session_id\", \"\")","@@ -8729,7 +8901,10 @@ def run():\n             )\n \n             approval_token = [redacted:secret-assignment]\"session_key\"])\n-            session_tokens = [redacted:secret-assignment]\"session_key\"])\n+            session_tokens = [redacted:secret-assignment]\n+                session[\"session_key\"],\n+                ui_session_id=sid,\n+            )\n             _profile_home_str = session.get(\"profile_home\")\n             if _profile_home_str:\n                 home_token = [redacted:secret-assignment]\n"]}]},{"kind":"pr","number":60869,"title":"fix(delegation): session-filter the post-turn notification drain (#58684)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/run.py","tests/tools/test_process_registry.py","tools/process_registry.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -15082,7 +15082,9 @@ def process_loop():\n                             # and watch pattern matches) while agent is idle.\n                             try:\n                                 from tools.process_registry import process_registry\n-                                for _evt, _synth in process_registry.drain_notifications():\n+                                from tools.approval import get_current_session_key\n+                                _drain_sk = get_current_session_key(default=\"\")\n+                                for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):\n                                     self._pending_input.put(_synth)\n                             except Exception:\n                                 pass"]},{"file":"gateway/run.py","hunks":["@@ -15143,6 +15143,13 @@ def _build_process_event_source(self, evt: dict):\n         chat_type = str(evt.get(\"chat_type\") or derived_chat_type or \"\").strip().lower()\n         chat_id = str(evt.get(\"chat_id\") or derived_chat_id or \"\").strip()\n         if not platform_name or not chat_type or not chat_id:\n+            logger.warning(\n+                \"Synthetic event source unresolvable: \"\n+                \"session_key=%r platform=%r chat_type=%r chat_id=%r \"\n+                \"evt_type=%s\",\n+                session_key, platform_name, chat_type, chat_id,\n+                evt.get(\"type\", \"?\"),\n+            )\n             return None\n \n         try:"]},{"file":"tests/tools/test_process_registry.py","hunks":["@@ -1346,6 +1346,181 @@ def test_drain_notifications_empty_queue():\n     assert results == []\n \n \n+def test_drain_notifications_filters_async_delegation_by_session_key():\n+    \"\"\"Async-delegation events should only be consumed by the matching session's drain.\n+\n+    Regression test for issue #58684: background delegation results delivered\n+    to the wrong session when the user switches sessions while a subagent runs.\n+    \"\"\"\n+    from tools.process_registry import process_registry\n+\n+    # Clear the queue first\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    try:\n+        # Put events for different sessions\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_session_a\",\n+            \"session_key\": \"telegram:dm:111:user_a\",\n+            \"goal\": \"task A\",\n+            \"status\": \"completed\",\n+            \"summary\": \"done A\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.5,\n+        })\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_session_b\",\n+            \"session_key\": \"telegram:dm:222:user_b\",\n+            \"goal\": \"task B\",\n+            \"status\": \"completed\",\n+            \"summary\": \"done B\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.3,\n+        })\n+\n+        # Drain for session A — should only get deleg_session_a\n+        results_a = process_registry.drain_notifications(session_key=\"telegram:dm:111:user_a\")\n+        assert len(results_a) == 1, (\n+            f\"Expected 1 event for session A, got {len(results_a)}\"\n+        )\n+        assert results_a[0][0][\"delegation_id\"] == \"deleg_session_a\"\n+        assert \"done A\" in results_a[0][1]\n+\n+        # Session B's event should have been re-queued — drain for session B\n+        results_b = process_registry.drain_notifications(session_key=\"telegram:dm:222:user_b\")\n+        assert len(results_b) == 1, (\n+            f\"Expected 1 event for session B, got {len(results_b)}\"\n+        )\n+        assert results_b[0][0][\"delegation_id\"] == \"deleg_session_b\"\n+        assert \"done B\" in results_b[0][1]\n+\n+        # No more events should remain\n+        assert process_registry.completion_queue.empty()\n+    finally:\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_drain_notifications_no_filter_passes_all_async_delegation():\n+    \"\"\"Without a session_key filter, all async-delegation events are consumed.\n+\n+    This ensures backward compatibility — the default (session_key=\"\") permits\n+    all events, matching pre-fix behavior.\n+    \"\"\"\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    try:\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_1\",\n+            \"session_key\": \"telegram:dm:111:user_a\",\n+            \"goal\": \"task 1\",\n+            \"status\": \"completed\",\n+            \"summary\": \"done 1\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.5,\n+        })\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_2\",\n+            \"session_key\": \"telegram:dm:222:user_b\",\n+            \"goal\": \"task 2\",\n+            \"status\": \"completed\",\n+            \"summary\": \"done 2\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.3,\n+        })\n+\n+        # No filter — both should be consumed\n+        results = process_registry.drain_notifications()\n+        assert len(results) == 2, (\n+            f\"Expected 2 events without filter, got {len(results)}\"\n+        )\n+        ids = {r[0][\"delegation_id\"] for r in results}\n+        assert ids == {\"deleg_1\", \"deleg_2\"}\n+    finally:\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_drain_notifications_owns_event_callback_beats_key_equality():\n+    \"\"\"The positive-proof ownership callback consumes ONLY approved events —\n+    including across a compression rotation where bare key equality would\n+    wrongly re-queue the session's own pre-compression dispatch (#55578).\"\"\"\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    try:\n+        # Pre-compression dispatch: event carries the OLD key.\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_precompress\",\n+            \"session_key\": \"old_parent_key\",\n+            \"goal\": \"task\", \"status\": \"completed\", \"summary\": \"mine\",\n+            \"api_calls\": 1, \"duration_seconds\": 0.1,\n+        })\n+        # Foreign event that plain key equality would also reject.\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_foreign\",\n+            \"session_key\": \"someone_else\",\n+            \"goal\": \"task\", \"status\": \"completed\", \"summary\": \"not mine\",\n+            \"api_calls\": 1, \"duration_seconds\": 0.1,\n+        })\n+\n+        # Chain-aware ownership: this session's lineage includes old_parent_key.\n+        lineage = {\"old_parent_key\", \"new_child_key\"}\n+        results = process_registry.drain_notifications(\n+            session_key=\"new_child_key\",\n+            owns_event=lambda e: e.get(\"session_key\") in lineage,\n+        )\n+        assert [r[0][\"delegation_id\"] for r in results] == [\"deleg_precompress\"]\n+\n+        # The foreign event was re-queued, not consumed.\n+        leftover = process_registry.completion_queue.get_nowait()\n+        assert leftover[\"delegation_id\"] == \"deleg_foreign\"\n+    finally:\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_drain_notifications_owns_event_callback_fails_closed():\n+    \"\"\"A broken ownership callback must re-queue (never leak) the event.\"\"\"\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    try:\n+        process_registry.completion_queue.put({\n+            \"type\": \"async_delegation\",\n+            \"delegation_id\": \"deleg_x\",\n+            \"session_key\": \"k\",\n+            \"goal\": \"task\", \"status\": \"completed\", \"summary\": \"s\",\n+            \"api_calls\": 1, \"duration_seconds\": 0.1,\n+        })\n+\n+        def broken(_evt):\n+            raise RuntimeError(\"ownership check exploded\")\n+\n+        results = process_registry.drain_notifications(\n+            session_key=\"k\", owns_event=broken\n+        )\n+        assert results == []\n+        assert process_registry.completion_queue.get_nowait()[\"delegation_id\"] == \"deleg_x\"\n+    finally:\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n # ---------------------------------------------------------------------------\n # _terminate_host_pid — cross-platform process-tree termination\n # ---------------------------------------------------------------------------"]},{"file":"tools/process_registry.py","hunks":["@@ -1148,14 +1148,33 @@ def _drain_should_skip(self, session_id: str) -> bool:\n         \"\"\"\n         return session_id in self._completion_consumed or session_id in self._poll_observed\n \n-    def drain_notifications(self) -> \"list[tuple[dict, str]]\":\n+    def drain_notifications(\n+        self, session_key: str = \"\", owns_event=None,\n+    ) -> \"list[tuple[dict, str]]\":\n         \"\"\"Pop all pending notification events and return formatted pairs.\n \n         Returns a list of (raw_event, formatted_text) tuples.\n         Skips completion events the agent already consumed via wait/log or\n         observed inline via poll() (see ``_drain_should_skip``).\n+\n+        Async-delegation events carry a conversation payload, so draining one\n+        into the wrong session is a cross-chat leak (#58684, #55578). Two\n+        filter modes, strongest wins:\n+\n+        - ``owns_event(evt) -> bool``: positive-proof ownership callback.\n+          When provided, an async-delegation event is consumed ONLY if the\n+          callback returns True; everything else is re-queued for its owner.\n+          The TUI passes its compression-chain-aware ownership check here so\n+          a post-compression session still claims its own pre-compression\n+          dispatches.\n+        - ``session_key``: plain key equality (CLI and other single-session\n+          callers). Non-matching async-delegation events are re-queued.\n+\n+        With neither set, all events are consumed (legacy single-session\n+        behavior, backward compatible).\n         \"\"\"\n-        results = []\n+        results: \"list[tuple[dict, str]]\" = []\n+        requeue: \"list[dict]\" = []\n         while not self.completion_queue.empty():\n             try:\n                 evt = self.completion_queue.get_nowait()","@@ -1164,9 +1183,28 @@ def drain_notifications(self) -> \"list[tuple[dict, str]]\":\n             _evt_sid = evt.get(\"session_id\", \"\")\n             if evt.get(\"type\") == \"completion\" and self._drain_should_skip(_evt_sid):\n                 continue\n+            # Filter async-delegation events so they are not delivered to the\n+            # wrong session/thread (#58684). Positive-proof callback beats\n+            # bare key equality when the caller can provide one.\n+            if evt.get(\"type\") == \"async_delegation\":\n+                if owns_event is not None:\n+                    try:\n+                        owned = bool(owns_event(evt))\n+                    except Exception:\n+                        owned = False  # fail closed — never leak on a broken check\n+                    if not owned:\n+                        requeue.append(evt)\n+                        continue\n+                elif session_key:\n+                    evt_session_key = evt.get(\"session_key\", \"\") or \"\"\n+                    if evt_session_key != session_key:\n+                        requeue.append(evt)\n+                        continue\n             text = format_process_notification(evt)\n             if text:\n                 results.append((evt, text))\n+        for evt in requeue:\n+            self.completion_queue.put(evt)\n         return results\n \n     def get(self, session_id: str) -> Optional[ProcessSession]:"]},{"file":"tui_gateway/server.py","hunks":["@@ -9337,7 +9337,15 @@ def _stream(delta):\n         try:\n             from tools.process_registry import process_registry\n \n-            for _evt, synth in process_registry.drain_notifications():\n+            # Positive-proof ownership (compression-chain aware) — the same\n+            # fail-closed gate the poller uses, so the post-turn drain can't\n+            # adopt another session's (or an orphan's) delegation payload,\n+            # while a post-compression session still claims its own\n+            # pre-compression dispatches (#55578).\n+            for _evt, synth in process_registry.drain_notifications(\n+                session_key=session.get(\"session_key\", \"\"),\n+                owns_event=lambda e: _session_owns_notification_event(sid, session, e),\n+            ):\n                 with session[\"history_lock\"]:\n                     if session.get(\"running\"):\n                         process_registry.completion_queue.put(_evt)\n"]}]},{"kind":"pr","number":60871,"title":"fix(delegate): pin gateway async completions to the spawning session (#57498)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/slash_commands.py","tests/gateway/test_async_delegation_session_binding.py","tests/tools/test_async_delegation.py","tools/async_delegation.py","tools/delegate_tool.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -10633,6 +10633,46 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n \n         session_entry = self.session_store.get_or_create_session(source)\n         session_key = session_entry.session_key\n+        pinned_session_id = str(\n+            (getattr(event, \"metadata\", None) or {}).get(\"gateway_session_id\") or \"\"\n+        ).strip()\n+        if pinned_session_id and pinned_session_id != session_entry.session_id:\n+            # Fail closed (#55578): the spawning session may have ENDED since\n+            # dispatch (user /new-reset, compression rotation whose parent was\n+            # closed). switch_session() re-opens ended sessions, so pinning\n+            # blindly would RESURRECT a conversation the user explicitly\n+            # ended and inject into it — the same illicit-revival class as\n+            # the ws_orphan_reap loop (#60609). A completion whose spawning\n+            # session is dead is dropped from injection; the subagent's\n+            # output remains in the delegation records.\n+            pinned_row = None\n+            try:\n+                if self._session_db is not None:\n+                    # AsyncSessionDB already offloads to a thread.\n+                    pinned_row = await self._session_db.get_session(pinned_session_id)\n+            except Exception:\n+                pinned_row = None\n+            if pinned_row is None or pinned_row.get(\"ended_at\"):\n+                logger.warning(\n+                    \"Async-delegation completion pinned to session %s, which is \"\n+                    \"%s — dropping injection instead of resurrecting it \"\n+                    \"(#55578 fail-closed; result remains in the delegation \"\n+                    \"records).\",\n+                    pinned_session_id,\n+                    \"unknown\" if pinned_row is None else \"ended\",\n+                )\n+                return\n+            prior_session_id = session_entry.session_id\n+            switched = self.session_store.switch_session(session_key, pinned_session_id)\n+            if switched is not None:\n+                session_entry = switched\n+                logger.info(\n+                    \"Pinned async-delegation completion to spawning session %s \"\n+                    \"(was %s) for routing key %s (#57498)\",\n+                    pinned_session_id,\n+                    prior_session_id,\n+                    session_key,\n+                )\n         self._cache_session_source(session_key, source)\n         if await asyncio.to_thread(self._is_telegram_topic_lane, source):\n             try:","@@ -15202,12 +15242,17 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None:\n         if not adapter:\n             return\n         try:\n+            metadata = {}\n+            parent_session_id = str(evt.get(\"parent_session_id\") or \"\").strip()\n+            if parent_session_id:\n+                metadata[\"gateway_session_id\"] = parent_session_id\n             synth_event = MessageEvent(\n                 text=synth_text,\n                 message_type=MessageType.TEXT,\n                 source=source,\n                 internal=True,\n                 message_id=str(evt.get(\"message_id\") or \"\").strip() or None,\n+                metadata=metadata,\n             )\n             logger.info(\n                 \"Watch pattern notification — injecting for %s chat=%s thread=%s\","]},{"file":"gateway/slash_commands.py","hunks":["@@ -167,6 +167,24 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer\n         if _qe is not None:\n             _qe.pop(session_key, None)\n \n+        # The old conversation's in-flight async delegations end WITH it\n+        # (#55578): after the reset rotates the session id, their completions\n+        # would have no live owner — a dangling subagent can only burn tokens\n+        # and park an orphaned payload on the shared queue. Interrupt by the\n+        # expiring durable session id (delegations dispatched from gateway\n+        # chats are pinned to it via parent_session_id) and by the routing\n+        # key as a fallback for older records.\n+        try:\n+            from tools.async_delegation import interrupt_for_session\n+\n+            interrupt_for_session(\n+                session_key=session_key,\n+                parent_session_id=str(getattr(old_entry, \"session_id\", \"\") or \"\"),\n+                reason=\"session_reset\",\n+            )\n+        except Exception:\n+            pass\n+\n         try:\n             from tools.env_passthrough import clear_env_passthrough\n             clear_env_passthrough()"]},{"file":"tests/gateway/test_async_delegation_session_binding.py","hunks":["@@ -0,0 +1,130 @@\n+\"\"\"Gateway-side session binding for async delegations (#57498, #55578).\n+\n+Three invariants on the messaging-gateway surface, mirroring the TUI rules:\n+\n+1. Completions are pinned to the spawning session (contributor commit).\n+2. A dead/ended spawning session is never resurrected: the injection is\n+   dropped, fail-closed (never rerouted to the peer's current session).\n+3. /new interrupts the old conversation's in-flight async delegations.\n+\"\"\"\n+\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+import tools.async_delegation as ad\n+\n+\n[redacted:email](autouse=True)\n+def _reset_async_delegation():\n+    ad._reset_for_tests()\n+    yield\n+    ad._reset_for_tests()\n+\n+\n+def _seed_record(delegation_id, session_key=\"\", parent_session_id=\"\", status=\"running\"):\n+    fn = MagicMock()\n+    with ad._records_lock:\n+        ad._records[delegation_id] = {\n+            \"delegation_id\": delegation_id,\n+            \"status\": status,\n+            \"session_key\": session_key,\n+            \"parent_session_id\": parent_session_id,\n+            \"interrupt_fn\": fn,\n+        }\n+    return fn\n+\n+\n+class TestInterruptForSessionByParentId:\n+    def test_parent_session_id_selector(self):\n+        mine = _seed_record(\"d1\", session_key=\"agent:main:telegram:dm:1\", parent_session_id=\"sess_old\")\n+        other = _seed_record(\"d2\", session_key=\"agent:main:telegram:dm:2\", parent_session_id=\"sess_other\")\n+        n = ad.interrupt_for_session(parent_session_id=\"sess_old\")\n+        assert n == 1\n+        mine.assert_called_once()\n+        other.assert_not_called()\n+\n+    def test_reset_interrupts_by_key_and_parent(self):\n+        \"\"\"A /new reset passes both selectors — either match claims the record.\"\"\"\n+        by_key = _seed_record(\"d1\", session_key=\"agent:main:telegram:dm:1\", parent_session_id=\"\")\n+        by_parent = _seed_record(\"d2\", session_key=\"\", parent_session_id=\"sess_old\")\n+        unrelated = _seed_record(\"d3\", session_key=\"other\", parent_session_id=\"other\")\n+        n = ad.interrupt_for_session(\n+            session_key=\"agent:main:telegram:dm:1\",\n+            parent_session_id=\"sess_old\",\n+            reason=\"session_reset\",\n+        )\n+        assert n == 2\n+        by_key.assert_called_once()\n+        by_parent.assert_called_once()\n+        unrelated.assert_not_called()\n+\n+\n+class TestGatewayPinningFailsClosed:\n+    \"\"\"The gateway injection path must never resurrect an ended session.\"\"\"\n+\n+    def _make_runner(self, pinned_row):\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        db = MagicMock()\n+        db.get_session = AsyncMock(return_value=pinned_row)\n+        runner._session_db = db\n+\n+        entry = MagicMock()\n+        entry.session_key = \"agent:main:telegram:dm:1\"\n+        entry.session_id = \"sess_current\"\n+        runner.session_store = MagicMock()\n+        runner.session_store.get_or_create_session.return_value = entry\n+        runner.session_store.switch_session.return_value = entry\n+        return runner, entry\n+\n+    def _run_pinning_prefix(self, runner, pinned_session_id):\n+        \"\"\"Execute the pinning guard logic exactly as _handle_message does.\"\"\"\n+\n+        async def _go():\n+            event = MagicMock()\n+            event.metadata = {\"gateway_session_id\": pinned_session_id}\n+            session_entry = runner.session_store.get_or_create_session(MagicMock())\n+            pinned = str((getattr(event, \"metadata\", None) or {}).get(\"gateway_session_id\") or \"\").strip()\n+            if pinned and pinned != session_entry.session_id:\n+                pinned_row = None\n+                try:\n+                    if runner._session_db is not None:\n+                        pinned_row = await runner._session_db.get_session(pinned)\n+                except Exception:\n+                    pinned_row = None\n+                if pinned_row is None or pinned_row.get(\"ended_at\"):\n+                    return \"dropped\"\n+                switched = runner.session_store.switch_session(session_entry.session_key, pinned)\n+                if switched is not None:\n+                    return \"pinned\"\n+            return \"default\"\n+\n+        return asyncio.run(_go())\n+\n+    def test_live_spawning_session_pins(self):\n+        runner, _ = self._make_runner({\"id\": \"sess_old\", \"ended_at\": None})\n+        assert self._run_pinning_prefix(runner, \"sess_old\") == \"pinned\"\n+\n+    def test_ended_spawning_session_drops(self):\n+        runner, _ = self._make_runner({\"id\": \"sess_old\", \"ended_at\": \"2026-07-08T00:00:00\"})\n+        assert self._run_pinning_prefix(runner, \"sess_old\") == \"dropped\"\n+        runner.session_store.switch_session.assert_not_called()\n+\n+    def test_unknown_spawning_session_drops(self):\n+        runner, _ = self._make_runner(None)\n+        assert self._run_pinning_prefix(runner, \"sess_gone\") == \"dropped\"\n+        runner.session_store.switch_session.assert_not_called()\n+\n+\n+class TestResetHandlerInterruptsDelegations:\n+    def test_reset_command_calls_interrupt_for_session(self):\n+        \"\"\"The /new handler must sever the old conversation's delegations.\"\"\"\n+        import inspect\n+        from gateway import slash_commands\n+\n+        src = inspect.getsource(slash_commands.GatewaySlashCommandsMixin._handle_reset_command)\n+        assert \"interrupt_for_session\" in src\n+        assert \"session_reset\" in src"]},{"file":"tests/tools/test_async_delegation.py","hunks":["@@ -99,6 +99,7 @@ def runner():\n     res = ad.dispatch_async_delegation(\n         goal=\"compute X\", context=\"some context\", toolsets=[\"web\", \"file\"],\n         role=\"leaf\", model=\"test-model\", session_key=\"agent:main:cli:dm:local\",\n+        parent_session_id=\"20260703_parent_sid\",\n         runner=runner, max_async_children=3,\n     )\n     assert res[\"status\"] == \"dispatched\"","@@ -108,6 +109,7 @@ def runner():\n     assert evt[\"type\"] == \"async_delegation\"\n     assert evt[\"summary\"] == \"the result\"\n     assert evt[\"session_key\"] == \"agent:main:cli:dm:local\"\n+    assert evt[\"parent_session_id\"] == \"20260703_parent_sid\"\n     assert evt[\"delegation_id\"] == res[\"delegation_id\"]\n \n "]},{"file":"tools/async_delegation.py","hunks":["@@ -129,6 +129,7 @@ def dispatch_async_delegation(\n     role: str,\n     model: Optional[str],\n     session_key: str,\n+    parent_session_id: Optional[str] = None,\n     runner: Callable[[], Dict[str, Any]],\n     origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,","@@ -146,6 +147,11 @@ def dispatch_async_delegation(\n         captured on the parent thread BEFORE dispatch, because the daemon\n         worker thread won't carry the contextvar. Used to route the\n         completion back to the originating session.\n+    parent_session_id\n+        The durable ``state.db`` session id of the parent agent that spawned\n+        the delegation. Carried on the completion event so the gateway can\n+        pin routing to the spawning session instead of recovering the latest\n+        ``ended_at IS NULL`` row for the peer tuple (#57498).\n     runner\n         Zero-arg callable that builds + runs the child and returns the same\n         result dict ``_run_single_child`` produces. Runs on the worker thread.","@@ -174,6 +180,7 @@ def dispatch_async_delegation(\n         \"model\": model,\n         \"session_key\": session_key,\n         \"origin_ui_session_id\": origin_ui_session_id,\n+        \"parent_session_id\": parent_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -285,6 +292,7 @@ def _push_completion_event(\n         # session; empty string => CLI (single-session) path.\n         \"session_key\": record.get(\"session_key\", \"\"),\n         \"origin_ui_session_id\": record.get(\"origin_ui_session_id\", \"\"),\n+        \"parent_session_id\": record.get(\"parent_session_id\"),\n         \"goal\": record.get(\"goal\", \"\"),\n         \"context\": record.get(\"context\"),\n         \"toolsets\": record.get(\"toolsets\"),","@@ -319,6 +327,7 @@ def dispatch_async_delegation_batch(\n     role: str,\n     model: Optional[str],\n     session_key: str,\n+    parent_session_id: Optional[str] = None,\n     runner: Callable[[], Dict[str, Any]],\n     origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,","@@ -361,6 +370,7 @@ def dispatch_async_delegation_batch(\n         \"model\": model,\n         \"session_key\": session_key,\n         \"origin_ui_session_id\": origin_ui_session_id,\n+        \"parent_session_id\": parent_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -459,6 +469,7 @@ def _finalize_batch(\n         \"delegation_id\": delegation_id,\n         \"session_key\": event_record.get(\"session_key\", \"\"),\n         \"origin_ui_session_id\": event_record.get(\"origin_ui_session_id\", \"\"),\n+        \"parent_session_id\": event_record.get(\"parent_session_id\"),\n         \"goal\": event_record.get(\"goal\", \"\"),\n         \"goals\": event_record.get(\"goals\"),\n         \"context\": event_record.get(\"context\"),","@@ -528,6 +539,7 @@ def interrupt_all(reason: str = \"shutdown\") -> int:\n def interrupt_for_session(\n     session_key: str = \"\",\n     origin_ui_session_id: str = \"\",\n+    parent_session_id: str = \"\",\n     reason: str = \"session_end\",\n ) -> int:\n     \"\"\"Signal running async delegations owned by ONE session to stop.","@@ -538,11 +550,17 @@ def interrupt_for_session(\n     with no live owner, either leaking into another chat or burning tokens\n     with no one listening (#55578).\n \n-    Matches on ``origin_ui_session_id`` (the live UI session that\n-    commissioned the work) and/or the durable ``session_key``; either\n-    matching field claims the record. Returns how many were interrupted.\n+    Selectors (any matching field claims the record):\n+    - ``origin_ui_session_id``: the live TUI tab/window that commissioned it.\n+    - ``session_key``: the durable routing key captured at dispatch.\n+    - ``parent_session_id``: the spawning agent's durable session-db id —\n+      the right selector for gateway chats, whose ``session_key`` (the\n+      platform conversation key) SURVIVES a ``/new`` reset while the\n+      session id rotates.\n+\n+    Returns how many were interrupted.\n     \"\"\"\n-    if not session_key and not origin_ui_session_id:\n+    if not session_key and not origin_ui_session_id and not parent_session_id:\n         return 0\n     count = 0\n     with _records_lock:","@@ -552,6 +570,7 @@ def interrupt_for_session(\n             and (\n                 (origin_ui_session_id and str(r.get(\"origin_ui_session_id\") or \"\") == origin_ui_session_id)\n                 or (session_key and str(r.get(\"session_key\") or \"\") == session_key)\n+                or (parent_session_id and str(r.get(\"parent_session_id\") or \"\") == parent_session_id)\n             )\n         ]\n     for r in targets:"]},{"file":"tools/delegate_tool.py","hunks":["@@ -2815,6 +2815,7 @@ def _execute_and_aggregate() -> dict:\n                     _session_key = _agent_session_id\n         except Exception:\n             _origin_ui_session_id = \"\"\n+        _parent_session_id = getattr(parent_agent, \"session_id\", None)\n         _child_agents = [c for (_, _, c) in children]\n \n         # Detach every child from the parent's interrupt-propagation list — the","@@ -2856,6 +2857,7 @@ def _batch_interrupt():\n             model=creds[\"model\"],\n             session_key=_session_key,\n             origin_ui_session_id=_origin_ui_session_id,\n+            parent_session_id=_parent_session_id,\n             runner=_batch_runner,\n             interrupt_fn=_batch_interrupt,\n             max_async_children=_get_max_async_children(),\n"]}]},{"kind":"pr","number":61719,"title":"fix(tui): isolate background notifications by UI session","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"54d0948d3","number":0},{"kind":"pr","value":"#54785","number":54785},{"kind":"pr","value":"#73351","number":73351},{"kind":"pr","value":"#73351","number":73351}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":63317,"title":"fix(tui): enforce notification session ownership across all drain paths","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54785","number":54785}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":64593,"title":"fix(cli): stop new sessions adopting dead sessions' delegation completions (#64484)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_async_delegation_delivery.py","tests/tools/test_restored_delegation_ownership.py","tools/async_delegation.py","tools/delegate_tool.py","tools/process_registry.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -9148,6 +9148,55 @@ def _get_goal_manager(self):\n \n \n \n+    def _owns_process_notification(self, event: dict) -> bool:\n+        \"\"\"Return whether this CLI session provably owns a delegation event.\n+\n+        Delegations dispatched before context compression retain the original\n+        session key, so resolve that key to its continuation before comparing.\n+        Missing or foreign keys fail closed and remain queued for their owner.\n+        \"\"\"\n+        event_key = str(event.get(\"session_key\") or \"\")\n+        current_key = str(getattr(self, \"session_id\", \"\") or \"\")\n+        if not event_key or not current_key:\n+            return False\n+        if event_key == current_key:\n+            return True\n+        try:\n+            session_db = getattr(self, \"_session_db\", None)\n+            resolved_key = (\n+                session_db.resolve_resume_session_id(event_key)\n+                if session_db is not None\n+                else event_key\n+            ) or event_key\n+        except Exception:\n+            resolved_key = event_key\n+        return str(resolved_key) == current_key\n+\n+    def _drain_process_notifications(self, consumer: str) -> None:\n+        \"\"\"Queue background notifications owned by this visible CLI session.\n+\n+        ``process_registry`` restores durable delegation completions into every\n+        process using the same Hermes profile.  Always pass this CLI's stable\n+        session identity when draining so another window cannot claim and mark\n+        delivered a completion that belongs to this one.\n+        \"\"\"\n+        from tools.process_registry import process_registry\n+        from tools.async_delegation import (\n+            claim_event_delivery,\n+            complete_event_delivery,\n+        )\n+\n+        session_key = getattr(self, \"session_id\", \"\") or \"\"\n+        for event, synthetic_message in process_registry.drain_notifications(\n+            session_key=session_key,\n+            owns_event=self._owns_process_notification,\n+        ):\n+            claim = claim_event_delivery(event, consumer)\n+            if claim is None:\n+                continue\n+            self._pending_input.put(synthetic_message)\n+            complete_event_delivery(event, claim)\n+\n     def _drain_interrupt_queue_to_pending_input(self) -> None:\n         \"\"\"Move stray messages from ``_interrupt_queue`` into ``_pending_input``.\n ","@@ -15321,18 +15370,7 @@ def process_loop():\n                             # Check for background process notifications (completions\n                             # and watch pattern matches) while agent is idle.\n                             try:\n-                                from tools.process_registry import process_registry\n-                                from tools.approval import get_current_session_key\n-                                _drain_sk = get_current_session_key(default=\"\")\n-                                for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):\n-                                    from tools.async_delegation import (\n-                                        claim_event_delivery, complete_event_delivery,\n-                                    )\n-                                    _claim = claim_event_delivery(_evt, \"cli-idle\")\n-                                    if _claim is None:\n-                                        continue\n-                                    self._pending_input.put(_synth)\n-                                    complete_event_delivery(_evt, _claim)\n+                                self._drain_process_notifications(\"cli-idle\")\n                             except Exception:\n                                 pass\n                         continue","@@ -15492,16 +15530,7 @@ def _restart_recording():\n                         # Drain process notifications (completions + watch matches)\n                         # that arrived while the agent was running.\n                         try:\n-                            from tools.process_registry import process_registry\n-                            for _evt, _synth in process_registry.drain_notifications():\n-                                from tools.async_delegation import (\n-                                    claim_event_delivery, complete_event_delivery,\n-                                )\n-                                _claim = claim_event_delivery(_evt, \"cli-post-turn\")\n-                                if _claim is None:\n-                                    continue\n-                                self._pending_input.put(_synth)\n-                                complete_event_delivery(_evt, _claim)\n+                            self._drain_process_notifications(\"cli-post-turn\")\n                         except Exception:\n                             pass  # Non-fatal — don't break the main loop\n "]},{"file":"tests/cli/test_cli_async_delegation_delivery.py","hunks":["@@ -0,0 +1,76 @@\n+\"\"\"Regression coverage for CLI async-delegation completion ownership.\"\"\"\n+\n+import queue\n+\n+from cli import HermesCLI\n+\n+\n+def test_cli_completion_drain_uses_visible_session_identity(monkeypatch):\n+    \"\"\"A CLI window must not claim another window's restored completion.\"\"\"\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli.session_id = \"visible-session\"\n+    cli._pending_input = queue.Queue()\n+\n+    event = {\n+        \"type\": \"async_delegation\",\n+        \"delegation_id\": \"deleg_visible\",\n+        \"session_key\": \"visible-session\",\n+    }\n+    calls = []\n+\n+    class FakeRegistry:\n+        def drain_notifications(self, *, session_key=\"\", owns_event=None):\n+            calls.append((session_key, owns_event(event)))\n+            return [(event, \"completion payload\")]\n+\n+    claimed = []\n+    completed = []\n+\n+    monkeypatch.setattr(\n+        \"tools.process_registry.process_registry\",\n+        FakeRegistry(),\n+    )\n+    monkeypatch.setattr(\n+        \"tools.async_delegation.claim_event_delivery\",\n+        lambda evt, consumer: claimed.append((evt, consumer)) or \"claim-token\",\n+    )\n+    monkeypatch.setattr(\n+        \"tools.async_delegation.complete_event_delivery\",\n+        lambda evt, token: [redacted:secret-assignment] token)),\n+    )\n+\n+    cli._drain_process_notifications(\"cli-idle\")\n+\n+    assert calls == [(\"visible-session\", True)]\n+    assert cli._pending_input.get_nowait() == \"completion payload\"\n+    assert claimed == [(event, \"cli-idle\")]\n+    assert completed == [(event, \"claim-token\")]\n+\n+\n+def test_cli_completion_ownership_rejects_foreign_session():\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli.session_id = \"visible-session\"\n+    cli._session_db = None\n+\n+    assert not cli._owns_process_notification(\n+        {\"type\": \"async_delegation\", \"session_key\": \"foreign-session\"}\n+    )\n+\n+\n+def test_cli_completion_ownership_accepts_compression_lineage():\n+    cli = HermesCLI.__new__(HermesCLI)\n+    cli.session_id = \"visible-session\"\n+\n+    class FakeSessionDB:\n+        def resolve_resume_session_id(self, session_id):\n+            assert session_id == \"pre-compression-session\"\n+            return \"visible-session\"\n+\n+    cli._session_db = FakeSessionDB()\n+\n+    assert cli._owns_process_notification(\n+        {\n+            \"type\": \"async_delegation\",\n+            \"session_key\": \"pre-compression-session\",\n+        }\n+    )"]},{"file":"tests/tools/test_restored_delegation_ownership.py","hunks":["@@ -0,0 +1,149 @@\n+\"\"\"Regression coverage for #64484 — durable-restored delegation completions\n+must never be adopted by a session that cannot positively prove ownership.\n+\n+Layers under test:\n+1. ``restore_undelivered_completions`` stamps every restored event with\n+   ``restored=True`` (in-memory only).\n+2. ``ProcessRegistry.drain_notifications`` with NO filter (legacy\n+   consume-everything CLI path) re-queues restored events instead of\n+   consuming them.\n+3. Same-process (non-restored) keyless events keep the legacy behavior.\n+4. An owner with a matching session_key still receives its restored event.\n+\"\"\"\n+\n+import json\n+import queue\n+\n+from tools.process_registry import ProcessRegistry\n+\n+\n+def _make_registry():\n+    reg = ProcessRegistry.__new__(ProcessRegistry)\n+    import threading\n+\n+    reg._running = {}\n+    reg._finished = {}\n+    reg._lock = threading.Lock()\n+    reg.completion_queue = queue.Queue()\n+    reg._completion_consumed = set()\n+    reg._poll_observed = set()\n+    return reg\n+\n+\n+def _delegation_event(session_key=\"\", restored=False, delegation_id=\"d1\"):\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"delegation_id\": delegation_id,\n+        \"session_key\": session_key,\n+        \"origin_ui_session_id\": \"\",\n+        \"goal\": \"secret goal\",\n+        \"status\": \"success\",\n+        \"summary\": \"SECRET RESULT\",\n+        \"api_calls\": 3,\n+        \"duration_seconds\": 1.5,\n+        \"dispatched_at\": 1.0,\n+        \"completed_at\": 2.0,\n+    }\n+    if restored:\n+        evt[\"restored\"] = True\n+    return evt\n+\n+\n+def test_restore_stamps_restored_flag(tmp_path, monkeypatch):\n+    \"\"\"Every durable completion re-enqueued at startup carries restored=True.\"\"\"\n+    import tools.async_delegation as ad\n+\n+    monkeypatch.setattr(ad, \"_db_path\", lambda: tmp_path / \"async_delegations.db\")\n+    record = {\n+        \"delegation_id\": \"d-old\",\n+        \"goal\": \"old goal\",\n+        \"context\": None,\n+        \"toolsets\": None,\n+        \"role\": \"leaf\",\n+        \"model\": \"m\",\n+        \"session_key\": \"OLD_SESSION_A\",\n+        \"origin_ui_session_id\": \"\",\n+        \"parent_session_id\": \"OLD_SESSION_A\",\n+        \"status\": \"running\",\n+        \"dispatched_at\": 1.0,\n+        \"completed_at\": None,\n+        \"interrupt_fn\": None,\n+    }\n+    ad._persist_dispatch(record)\n+    evt = _delegation_event(session_key=\"OLD_SESSION_A\", delegation_id=\"d-old\")\n+    ad._persist_completion(evt, {\"summary\": \"SECRET RESULT\"})\n+\n+    q = queue.Queue()\n+    restored = ad.restore_undelivered_completions(q)\n+    assert restored == 1\n+    got = q.get_nowait()\n+    assert got[\"restored\"] is True\n+    assert got[\"session_key\"] == \"OLD_SESSION_A\"\n+\n+    # The stamp is in-memory only — the durable payload is unchanged.\n+    with ad._connect() as conn:\n+        row = conn.execute(\n+            \"SELECT event_json FROM async_delegations WHERE delegation_id='d-old'\"\n+        ).fetchone()\n+    assert \"restored\" not in json.loads(row[0])\n+\n+\n+def test_unfiltered_drain_never_consumes_restored_events():\n+    \"\"\"The legacy consume-everything branch must fail closed on restored events.\"\"\"\n+    reg = _make_registry()\n+    reg.completion_queue.put(_delegation_event(session_key=\"DEAD_SESSION\", restored=True))\n+\n+    results = reg.drain_notifications()  # no filter — legacy CLI post-turn shape\n+\n+    assert results == []\n+    # Still queued for its real owner.\n+    assert reg.completion_queue.qsize() == 1\n+    assert reg.completion_queue.get_nowait()[\"session_key\"] == \"DEAD_SESSION\"\n+\n+\n+def test_unfiltered_drain_keeps_legacy_behavior_for_same_process_events():\n+    \"\"\"Non-restored keyless events (created by this process) are still consumed.\"\"\"\n+    reg = _make_registry()\n+    reg.completion_queue.put(_delegation_event(session_key=\"\"))\n+\n+    results = reg.drain_notifications()\n+\n+    assert len(results) == 1\n+    assert results[0][0][\"delegation_id\"] == \"d1\"\n+    assert reg.completion_queue.empty()\n+\n+\n+def test_owner_session_key_drain_consumes_restored_event():\n+    \"\"\"The owning session (key match) still receives its restored completion.\"\"\"\n+    reg = _make_registry()\n+    reg.completion_queue.put(_delegation_event(session_key=\"OWNER\", restored=True))\n+\n+    results = reg.drain_notifications(session_key=\"OWNER\")\n+\n+    assert len(results) == 1\n+    assert results[0][0][\"session_key\"] == \"OWNER\"\n+    assert reg.completion_queue.empty()\n+\n+\n+def test_foreign_session_key_drain_requeues_restored_event():\n+    \"\"\"A different session's keyed drain must not claim the restored event.\"\"\"\n+    reg = _make_registry()\n+    reg.completion_queue.put(_delegation_event(session_key=\"OWNER\", restored=True))\n+\n+    results = reg.drain_notifications(session_key=\"SOMEONE_ELSE\")\n+\n+    assert results == []\n+    assert reg.completion_queue.qsize() == 1\n+\n+\n+def test_owns_event_callback_beats_restored_flag():\n+    \"\"\"A positive-proof ownership callback consumes restored events it owns.\"\"\"\n+    reg = _make_registry()\n+    reg.completion_queue.put(_delegation_event(session_key=\"OWNER\", restored=True))\n+\n+    results = reg.drain_notifications(\n+        owns_event=lambda e: e.get(\"session_key\") == \"OWNER\"\n+    )\n+\n+    assert len(results) == 1\n+    assert reg.completion_queue.empty()"]},{"file":"tools/async_delegation.py","hunks":["@@ -264,7 +264,17 @@ def recover_abandoned_delegations() -> int:\n \n \n def restore_undelivered_completions(target_queue) -> int:\n-    \"\"\"Enqueue durable pending completions as fresh turns after process start.\"\"\"\n+    \"\"\"Enqueue durable pending completions as fresh turns after process start.\n+\n+    Every restored event is stamped ``restored=True`` (in-memory only — the\n+    stamp is added after the durable payload is deserialized and is never\n+    persisted). Restored events originate from a *previous* process, so no\n+    consumer in THIS process implicitly owns them: drain paths that run\n+    without an ownership filter (the legacy single-session behavior) must\n+    leave them queued for a consumer that can positively prove ownership,\n+    otherwise a brand-new session adopts a dead session's delegation\n+    results seconds after boot (#64484).\n+    \"\"\"\n     recover_abandoned_delegations()\n     with _DB_LOCK, _connect() as conn:\n         rows = conn.execute(","@@ -273,7 +283,10 @@ def restore_undelivered_completions(target_queue) -> int:\n                ORDER BY completed_at, delegation_id\"\"\"\n         ).fetchall()\n         for _delegation_id, payload in rows:\n-            target_queue.put(json.loads(payload))\n+            evt = json.loads(payload)\n+            if isinstance(evt, dict):\n+                evt[\"restored\"] = True\n+            target_queue.put(evt)\n     return len(rows)\n \n "]},{"file":"tools/delegate_tool.py","hunks":["@@ -2844,6 +2844,20 @@ def _execute_and_aggregate() -> dict:\n                     _session_key = _agent_session_id\n         except Exception:\n             _origin_ui_session_id = \"\"\n+        if not _session_key:\n+            # CLI (single-process) path: the approval contextvar is only bound\n+            # during gateway/TUI turns and HERMES_SESSION_KEY is not in the CLI\n+            # environment, so the key resolves empty here. Since #64240 the CLI\n+            # drains completions through a positive-ownership filter keyed on\n+            # the durable AIAgent.session_id — an empty session_key would fail\n+            # closed and the CLI could never claim its own completions, while\n+            # a restored foreign event with an empty key could leak into any\n+            # unfiltered consumer (#64484). Stamp the parent's durable session\n+            # id instead; compression rotations are handled on the drain side\n+            # via resolve_resume_session_id lineage resolution.\n+            _agent_session_id = str(getattr(parent_agent, \"session_id\", \"\") or \"\")\n+            if _agent_session_id:\n+                _session_key = _agent_session_id\n         _parent_session_id = getattr(parent_agent, \"session_id\", None)\n         _child_agents = [c for (_, _, c) in children]\n "]},{"file":"tools/process_registry.py","hunks":["@@ -1211,6 +1211,19 @@ def drain_notifications(\n                     if evt_session_key != session_key:\n                         requeue.append(evt)\n                         continue\n+                elif evt.get(\"restored\"):\n+                    # Legacy unfiltered drain (no ownership callback, no\n+                    # session key). That behavior was safe when the in-memory\n+                    # queue could only hold events created by this very\n+                    # process — but durable restore (#63494) re-enqueues\n+                    # completions from PREVIOUS processes at startup, so an\n+                    # unfiltered consumer here would adopt a dead, unrelated\n+                    # session's conversation payload (#64484). Fail closed:\n+                    # leave restored events queued (still 'pending' on disk)\n+                    # for a consumer that can positively prove ownership,\n+                    # e.g. the owning session's --resume.\n+                    requeue.append(evt)\n+                    continue\n             text = format_process_notification(evt)\n             if text:\n                 results.append((evt, text))\n"]}]},{"kind":"pr","number":65409,"title":"fix(session): prefer ContextVar over process env for session identity","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":65419,"title":"fix(tui): route background-process notifications to their owning session (#35652)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/test_tui_gateway_server.py","tests/tools/test_process_registry.py","tools/process_registry.py","tui_gateway/server.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"2751738943\",  # PR #54785 salvage (tui: post-turn completion ownership routing)\n     \"[redacted:email]\": \"Burgunthy\",  # PR #20096 salvage (gateway: profile-based routing for inbound messages)\n     \"[redacted:email]\": \"webtecnica\",  # PR #63360 salvage (nous: restore inference-api base_url)\n     \"[redacted:email]\": \"Epoxidex\",  # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758)"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -2284,7 +2284,611 @@ def replace_messages(self, key, messages):\n         server._sessions.pop(\"trunc-sid\", None)\n \n \n-def test_session_create_does_not_persist_empty_row(monkeypatch):\n+class _StopAfterOneNotificationPoll:\n+    def __init__(self):\n+        self._checks = 0\n+\n+    def is_set(self):\n+        self._checks += 1\n+        return self._checks > 1\n+\n+\n+def test_notification_poller_live_loop_requeues_foreign_completion_for_owner(\n+    monkeypatch,\n+):\n+    \"\"\"A foreign live-loop dequeue is handed back to its proven owner.\"\"\"\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    delivered = {\"a\": [], \"b\": []}\n+    emitted = []\n+    session_a = _session(session_key=\"session-a-live-handoff\")\n+    session_b = _session(session_key=\"session-b-live-handoff\")\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc-live-handoff\",\n+        \"session_key\": \"session-a-live-handoff\",\n+        \"command\": \"echo owner\",\n+        \"exit_code\": 0,\n+        \"output\": \"owner\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+    monkeypatch.setattr(server, \"_emit\", lambda *args, **_kwargs: emitted.append(args))\n+\n+    def _deliver(_rid, sid, session, text):\n+        delivered[\"a\" if sid == \"sid-a-live-handoff\" else \"b\"].append(text)\n+        session[\"running\"] = False\n+\n+    monkeypatch.setattr(server, \"_run_prompt_submit\", _deliver)\n+    server._sessions.update(\n+        {\n+            \"sid-a-live-handoff\": session_a,\n+            \"sid-b-live-handoff\": session_b,\n+        }\n+    )\n+    process_registry._completion_consumed.discard(event[\"session_id\"])\n+\n+    try:\n+        server._notification_poller_loop(\n+            _StopAfterOneNotificationPoll(), \"sid-b-live-handoff\", session_b\n+        )\n+\n+        assert delivered[\"b\"] == []\n+        assert emitted == []\n+        assert isolated_queue.qsize() == 1\n+        assert isolated_queue.queue[0] is event\n+\n+        server._notification_poller_loop(\n+            _StopAfterOneNotificationPoll(), \"sid-a-live-handoff\", session_a\n+        )\n+\n+        assert len(delivered[\"a\"]) == 1\n+        assert \"proc-live-handoff completed normally\" in delivered[\"a\"][0]\n+        assert delivered[\"b\"] == []\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid-a-live-handoff\", None)\n+        server._sessions.pop(\"sid-b-live-handoff\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+        while not isolated_queue.empty():\n+            isolated_queue.get_nowait()\n+\n+\n+def test_completion_ownership_lineage_lookup_failure_fails_closed(monkeypatch):\n+    \"\"\"A provenance lookup failure cannot turn an addressed event into ours.\"\"\"\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    class _BrokenDB:\n+        def resolve_resume_session_id(self, _session_key):\n+            raise RuntimeError(\"lineage database unavailable\")\n+\n+    session = _session(session_key=\"unrelated-live-session\")\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc-unknown-lineage\",\n+        \"session_key\": \"unknown-parent\",\n+        \"command\": \"echo unknown\",\n+        \"exit_code\": 0,\n+        \"output\": \"unknown\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _BrokenDB())\n+\n+    drained = process_registry.drain_notifications(\n+        session_key=\"unrelated-live-session\",\n+        owns_event=lambda candidate: server._session_owns_notification_event(\n+            \"sid-unrelated-live\", session, candidate\n+        ),\n+    )\n+\n+    assert drained == []\n+    assert isolated_queue.qsize() == 1\n+    assert isolated_queue.get_nowait() is event\n+\n+\n[redacted:email](\n+    \"routing\",\n+    [\n+        {\"session_key\": \"missing-owner-key\"},\n+        {\"origin_ui_session_id\": \"missing-owner-sid\"},\n+    ],\n+)\n+def test_notification_poller_live_loop_drops_addressed_orphan(\n+    monkeypatch, routing\n+):\n+    \"\"\"A live poll never injects an addressed event whose owner is gone.\"\"\"\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    delivered = []\n+    emitted = []\n+    session = _session(session_key=\"unrelated-live-key\")\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc-live-orphan\",\n+        \"command\": \"echo orphan\",\n+        \"exit_code\": 0,\n+        \"output\": \"orphan\",\n+        **routing,\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+    monkeypatch.setattr(server, \"_emit\", lambda *args, **_kwargs: emitted.append(args))\n+    monkeypatch.setattr(\n+        server,\n+        \"_run_prompt_submit\",\n+        lambda _rid, _sid, _session, text: delivered.append(text),\n+    )\n+    server._sessions[\"sid-live-orphan\"] = session\n+    process_registry._completion_consumed.discard(event[\"session_id\"])\n+\n+    try:\n+        server._notification_poller_loop(\n+            _StopAfterOneNotificationPoll(), \"sid-live-orphan\", session\n+        )\n+\n+        assert delivered == []\n+        assert emitted == []\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid-live-orphan\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+        while not isolated_queue.empty():\n+            isolated_queue.get_nowait()\n+\n+\n[redacted:email](\n+    \"routing\",\n+    [\n+        {\"session_key\": \"session-b\"},\n+        {\"origin_ui_session_id\": \"sid_gone\"},\n+    ],\n+)\n+def test_notification_poller_drops_orphaned_events(monkeypatch, routing):\n+    \"\"\"Addressed completions whose owner is gone are dropped, not hijacked.\"\"\"\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    emitted = []\n+    delivered = []\n+    sess = _session(session_key=\"session-a\")\n+    server._sessions[\"sid_a\"] = sess\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: emitted.append(a))\n+    monkeypatch.setattr(\n+        server,\n+        \"_run_prompt_submit\",\n+        lambda _rid, _sid, _session, text: delivered.append(text),\n+    )\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    process_registry._completion_consumed.discard(\"proc_ghost\")\n+    isolated_queue.put(\n+        {\n+            \"type\": \"completion\",\n+            \"session_id\": \"proc_ghost\",\n+            \"command\": \"echo from ghost\",\n+            \"exit_code\": 0,\n+            \"output\": \"ghost output\",\n+            **routing,\n+        }\n+    )\n+\n+    stop = threading.Event()\n+    stop.set()\n+\n+    try:\n+        server._notification_poller_loop(stop, \"sid_a\", sess)\n+\n+        assert [a for a in emitted if a[0] == \"status.update\"] == []\n+        assert delivered == []\n+    finally:\n+        server._sessions.pop(\"sid_a\", None)\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n[redacted:email](\n+    (\"routing\", \"resolved_key\"),\n+    [\n+        ({\"session_key\": \"session-a\"}, None),\n+        (\n+            {\n+                \"session_key\": \"stale-durable-key\",\n+                \"origin_ui_session_id\": \"sid_a\",\n+            },\n+            None,\n+        ),\n+        ({\"session_key\": \"old-parent-key\"}, \"session-a\"),\n+    ],\n+)\n+def test_notification_poller_delivers_owned_events(\n+    monkeypatch, routing, resolved_key\n+):\n+    \"\"\"Direct, UI-origin, and compression-lineage owners are delivered.\"\"\"\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    class _CompressionDB:\n+        def resolve_resume_session_id(self, key):\n+            return resolved_key if key == \"old-parent-key\" and resolved_key else key\n+\n+    delivered = []\n+    emitted = []\n+    sess = _session(session_key=\"session-a\")\n+    server._sessions[\"sid_a\"] = sess\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: emitted.append(a))\n+    monkeypatch.setattr(\n+        server,\n+        \"_run_prompt_submit\",\n+        lambda _rid, _sid, _session, text: delivered.append(text),\n+    )\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _CompressionDB())\n+\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    process_registry._completion_consumed.discard(\"proc_mine\")\n+    isolated_queue.put(\n+        {\n+            \"type\": \"completion\",\n+            \"session_id\": \"proc_mine\",\n+            \"command\": \"echo mine\",\n+            \"exit_code\": 0,\n+            \"output\": \"mine\",\n+            **routing,\n+        }\n+    )\n+\n+    stop = threading.Event()\n+    stop.set()\n+\n+    try:\n+        server._notification_poller_loop(stop, \"sid_a\", sess)\n+\n+        status_calls = [a for a in emitted if a[0] == \"status.update\"]\n+        assert len(status_calls) == 1\n+        assert status_calls[0][2][\"kind\"] == \"process\"\n+        assert len(delivered) == 1\n+        assert \"proc_mine\" in delivered[0]\n+    finally:\n+        server._sessions.pop(\"sid_a\", None)\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def _configure_immediate_prompt_run(\n+    monkeypatch, tmp_path, *, immediate_threads=True\n+):\n+    class _ImmediateThread:\n+        def __init__(self, target=None, daemon=None, **_kwargs):\n+            self._target = target\n+\n+        def start(self):\n+            if self._target is not None:\n+                self._target()\n+\n+        def is_alive(self):\n+            return False\n+\n+    if immediate_threads:\n+        monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+    monkeypatch.setattr(server, \"_emit\", lambda *_args, **_kwargs: None)\n+    monkeypatch.setattr(server, \"make_stream_renderer\", lambda _cols: None)\n+    monkeypatch.setattr(server, \"render_message\", lambda _raw, _cols: None)\n+    monkeypatch.setattr(server, \"_wire_callbacks\", lambda _sid: None)\n+    monkeypatch.setattr(server, \"_sync_agent_model_with_config\", lambda *_args: None)\n+    monkeypatch.setattr(server, \"_session_cwd\", lambda _session: str(tmp_path))\n+    monkeypatch.setattr(server, \"_register_session_cwd\", lambda _session: None)\n+    monkeypatch.setattr(server, \"_set_session_context\", lambda *_args, **_kwargs: [])\n+    monkeypatch.setattr(server, \"_clear_session_context\", lambda _tokens: None)\n+    monkeypatch.setattr(server, \"_session_info\", lambda *_args: {})\n+    monkeypatch.setattr(server, \"_get_usage\", lambda _agent: {})\n+    monkeypatch.setattr(\n+        server, \"_sync_session_key_after_compress\", lambda *_args, **_kwargs: None\n+    )\n+    monkeypatch.setattr(server, \"_drain_queued_prompt\", lambda *_args: False)\n+    monkeypatch.setattr(server, \"_voice_tts_enabled\", lambda: False)\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+\n+\n+class _RecordingAgent:\n+    model = \"test-model\"\n+    provider = \"test-provider\"\n+\n+    def __init__(self, turns):\n+        self._turns = turns\n+\n+    def clear_interrupt(self):\n+        return None\n+\n+    def run_conversation(\n+        self, prompt, conversation_history=None, stream_callback=None\n+    ):\n+        self._turns.append(prompt)\n+        return {\"final_response\": \"\", \"messages\": []}\n+\n+\n[redacted:email](\"exit_code\", [0, 7])\n+def test_run_prompt_submit_requeues_foreign_completion(\n+    monkeypatch, tmp_path, exit_code\n+):\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    _configure_immediate_prompt_run(monkeypatch, tmp_path)\n+    turns = []\n+    session_a = _session(session_key=\"session-a\")\n+    session_b = _session(\n+        session_key=\"session-b\",\n+        agent=_RecordingAgent(turns),\n+        running=True,\n+    )\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": f\"proc_foreign_{exit_code}\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": exit_code,\n+        \"output\": \"foreign\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    server._sessions[\"sid_a\"] = session_a\n+    server._sessions[\"sid_b\"] = session_b\n+\n+    try:\n+        server._run_prompt_submit(\"rid-b\", \"sid_b\", session_b, \"session-b-turn\")\n+\n+        assert turns == [\"session-b-turn\"]\n+        assert isolated_queue.get_nowait() == event\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid_a\", None)\n+        server._sessions.pop(\"sid_b\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+\n+\n+def test_run_prompt_submit_delivers_completion_observed_by_poll(monkeypatch, tmp_path):\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    _configure_immediate_prompt_run(monkeypatch, tmp_path)\n+    turns = []\n+    session = _session(\n+        session_key=\"session-a\",\n+        agent=_RecordingAgent(turns),\n+        running=True,\n+    )\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_polled\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"observed but not consumed\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    process_registry._completion_consumed.discard(event[\"session_id\"])\n+    process_registry._poll_observed.add(event[\"session_id\"])\n+    server._sessions[\"sid_a\"] = session\n+\n+    try:\n+        server._run_prompt_submit(\"rid-a\", \"sid_a\", session, \"session-a-turn\")\n+\n+        assert turns[0] == \"session-a-turn\"\n+        assert len(turns) == 2\n+        assert \"proc_polled\" in turns[1]\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid_a\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+        process_registry._poll_observed.discard(event[\"session_id\"])\n+\n+\n+def test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading(\n+    monkeypatch, tmp_path\n+):\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    _configure_immediate_prompt_run(\n+        monkeypatch, tmp_path, immediate_threads=False\n+    )\n+    real_thread_class = threading.Thread\n+    threads = []\n+    nested_started = threading.Event()\n+    release_nested = threading.Event()\n+    turns = []\n+\n+    def _recording_thread(*args, **kwargs):\n+        thread = real_thread_class(*args, **kwargs)\n+        threads.append(thread)\n+        return thread\n+\n+    class _BlockingNotificationAgent(_RecordingAgent):\n+        def run_conversation(\n+            self, prompt, conversation_history=None, stream_callback=None\n+        ):\n+            turns.append(prompt)\n+            if \"proc_batch_1\" in prompt:\n+                nested_started.set()\n+                if not release_nested.wait(timeout=5):\n+                    raise TimeoutError(\"notification turn was not released\")\n+            return {\"final_response\": \"\", \"messages\": []}\n+\n+    monkeypatch.setattr(server.threading, \"Thread\", _recording_thread)\n+    session = _session(\n+        session_key=\"session-a\",\n+        agent=_BlockingNotificationAgent(turns),\n+        running=True,\n+    )\n+    events = [\n+        {\n+            \"type\": \"completion\",\n+            \"session_id\": f\"proc_batch_{index}\",\n+            \"session_key\": \"session-a\",\n+            \"command\": \"safe-test-command\",\n+            \"exit_code\": 0,\n+            \"output\": f\"owned-{index}\",\n+        }\n+        for index in range(1, 4)\n+    ]\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    for event in events:\n+        isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    server._sessions[\"sid_a\"] = session\n+\n+    try:\n+        server._run_prompt_submit(\"rid-a\", \"sid_a\", session, \"session-a-turn\")\n+\n+        assert nested_started.wait(timeout=5)\n+        threads[0].join(timeout=5)\n+        assert not threads[0].is_alive()\n+        queued = []\n+        while not isolated_queue.empty():\n+            queued.append(isolated_queue.get_nowait())\n+        assert [event[\"session_id\"] for event in queued] == [\n+            \"proc_batch_2\",\n+            \"proc_batch_3\",\n+        ]\n+    finally:\n+        release_nested.set()\n+        for thread in threads:\n+            thread.join(timeout=5)\n+        server._sessions.pop(\"sid_a\", None)\n+        while not isolated_queue.empty():\n+            isolated_queue.get_nowait()\n+        for event in events:\n+            process_registry._completion_consumed.discard(event[\"session_id\"])\n+            process_registry._poll_observed.discard(event[\"session_id\"])\n+\n+\n+def test_run_prompt_submit_delivers_completion_owned_through_compression_lineage(\n+    monkeypatch, tmp_path\n+):\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    class _CompressionDB:\n+        def resolve_resume_session_id(self, key):\n+            return \"new-child-key\" if key == \"old-parent-key\" else key\n+\n+    _configure_immediate_prompt_run(monkeypatch, tmp_path)\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _CompressionDB())\n+    ownership_checks = []\n+    original_owns_event = server._session_owns_notification_event\n+\n+    def _record_ownership_check(sid, checked_session, checked_event):\n+        ownership_checks.append(checked_event[\"session_id\"])\n+        return original_owns_event(sid, checked_session, checked_event)\n+\n+    monkeypatch.setattr(\n+        server, \"_session_owns_notification_event\", _record_ownership_check\n+    )\n+    turns = []\n+    session = _session(\n+        session_key=\"new-child-key\",\n+        agent=_RecordingAgent(turns),\n+        running=True,\n+    )\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_precompression\",\n+        \"session_key\": \"old-parent-key\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"owned\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    server._sessions[\"sid_b\"] = session\n+\n+    try:\n+        server._run_prompt_submit(\"rid-b\", \"sid_b\", session, \"session-b-turn\")\n+\n+        assert turns[0] == \"session-b-turn\"\n+        assert len(turns) == 2\n+        assert \"proc_precompression\" in turns[1]\n+        assert ownership_checks == [\"proc_precompression\"]\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid_b\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+\n+\n+def test_run_prompt_submit_prefers_origin_ui_session_id(monkeypatch, tmp_path):\n+    import queue as _queue_mod\n+\n+    from tools.process_registry import process_registry\n+\n+    _configure_immediate_prompt_run(monkeypatch, tmp_path)\n+    ownership_checks = []\n+    original_owns_event = server._session_owns_notification_event\n+\n+    def _record_ownership_check(sid, checked_session, checked_event):\n+        ownership_checks.append(checked_event[\"session_id\"])\n+        return original_owns_event(sid, checked_session, checked_event)\n+\n+    monkeypatch.setattr(\n+        server, \"_session_owns_notification_event\", _record_ownership_check\n+    )\n+    turns = []\n+    session = _session(\n+        session_key=\"current-key\",\n+        agent=_RecordingAgent(turns),\n+        running=True,\n+    )\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_origin_owned\",\n+        \"session_key\": \"stale-durable-key\",\n+        \"origin_ui_session_id\": \"sid_b\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"owned\",\n+    }\n+    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()\n+    isolated_queue.put(event)\n+    monkeypatch.setattr(process_registry, \"completion_queue\", isolated_queue)\n+    server._sessions[\"sid_b\"] = session\n+\n+    try:\n+        server._run_prompt_submit(\"rid-b\", \"sid_b\", session, \"session-b-turn\")\n+\n+        assert turns[0] == \"session-b-turn\"\n+        assert len(turns) == 2\n+        assert \"proc_origin_owned\" in turns[1]\n+        assert ownership_checks == [\"proc_origin_owned\"]\n+        assert isolated_queue.empty()\n+    finally:\n+        server._sessions.pop(\"sid_b\", None)\n+        process_registry._completion_consumed.discard(event[\"session_id\"])\n+\n+\n+\n     \"\"\"session.create must NOT eagerly write a DB row.\n \n     Every TUI/desktop launch opens a session here just to paint the composer;"]},{"file":"tests/tools/test_process_registry.py","hunks":["@@ -1336,6 +1336,66 @@ def test_drain_notifications_skips_consumed():\n             process_registry.completion_queue.get_nowait()\n \n \n+def test_drain_notifications_can_deliver_poll_observed_for_gateway(registry):\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_polled\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"observed but not consumed\",\n+    }\n+    registry._poll_observed.add(event[\"session_id\"])\n+    registry.completion_queue.put(event)\n+\n+    try:\n+        results = registry.drain_notifications(\n+            session_key=\"session-a\",\n+            owns_event=lambda _event: True,\n+            skip_poll_observed=False,\n+        )\n+\n+        assert [raw for raw, _ in results] == [event]\n+    finally:\n+        registry._poll_observed.discard(event[\"session_id\"])\n+\n+\n[redacted:email](\n+    \"skip_state\", [\"_poll_observed\", \"_completion_consumed\"]\n+)\n+def test_drain_notifications_routes_foreign_before_local_skip(\n+    registry, skip_state\n+):\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": f\"proc_foreign_{skip_state}\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"foreign\",\n+    }\n+    ownership_calls = []\n+    getattr(registry, skip_state).add(event[\"session_id\"])\n+    registry.completion_queue.put(event)\n+\n+    def owns_event(checked_event):\n+        ownership_calls.append(checked_event)\n+        return False\n+\n+    try:\n+        results = registry.drain_notifications(\n+            session_key=\"session-b\",\n+            owns_event=owns_event,\n+        )\n+\n+        assert results == []\n+        assert ownership_calls == [event]\n+        assert registry.completion_queue.get_nowait() == event\n+        assert registry.completion_queue.empty()\n+    finally:\n+        getattr(registry, skip_state).discard(event[\"session_id\"])\n+\n+\n def test_drain_notifications_empty_queue():\n     from tools.process_registry import process_registry\n ","@@ -1346,6 +1406,151 @@ def test_drain_notifications_empty_queue():\n     assert results == []\n \n \n[redacted:email](\"exit_code\", [0, 7])\n+def test_drain_notifications_filters_addressed_completion_by_owns_event(\n+    registry, exit_code\n+):\n+    owned = {\n+        \"type\": \"completion\",\n+        \"session_id\": f\"proc_owned_{exit_code}\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": exit_code,\n+        \"output\": \"owned\",\n+    }\n+    foreign = {\n+        \"type\": \"completion\",\n+        \"session_id\": f\"proc_foreign_{exit_code}\",\n+        \"session_key\": \"session-b\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": exit_code,\n+        \"output\": \"foreign\",\n+    }\n+    registry.completion_queue.put(owned)\n+    registry.completion_queue.put(foreign)\n+\n+    results = registry.drain_notifications(\n+        session_key=\"session-a\",\n+        owns_event=lambda event: event.get(\"session_key\") == \"session-a\",\n+    )\n+\n+    assert [event[\"session_id\"] for event, _ in results] == [\n+        f\"proc_owned_{exit_code}\"\n+    ]\n+    assert registry.completion_queue.get_nowait() == foreign\n+    assert registry.completion_queue.empty()\n+\n+\n+def test_drain_notifications_filters_addressed_completion_by_session_key(registry):\n+    owned = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_owned\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"owned\",\n+    }\n+    foreign = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_foreign\",\n+        \"session_key\": \"session-b\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"foreign\",\n+    }\n+    registry.completion_queue.put(owned)\n+    registry.completion_queue.put(foreign)\n+\n+    results = registry.drain_notifications(session_key=\"session-a\")\n+\n+    assert [event[\"session_id\"] for event, _ in results] == [\"proc_owned\"]\n+    assert registry.completion_queue.get_nowait() == foreign\n+    assert registry.completion_queue.empty()\n+\n+\n+def test_drain_notifications_session_key_filter_requeues_origin_only_event(registry):\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_origin_only\",\n+        \"origin_ui_session_id\": \"ui-session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"done\",\n+    }\n+    registry.completion_queue.put(event)\n+\n+    results = registry.drain_notifications(session_key=\"session-a\")\n+\n+    assert results == []\n+    assert registry.completion_queue.get_nowait() == event\n+    assert registry.completion_queue.empty()\n+\n+\n+def test_drain_notifications_ownerless_completion_preserves_legacy_delivery(registry):\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_ownerless\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"ownerless\",\n+    }\n+    registry.completion_queue.put(event)\n+\n+    results = registry.drain_notifications(\n+        session_key=\"session-a\",\n+        owns_event=lambda _event: False,\n+    )\n+\n+    assert [raw for raw, _ in results] == [event]\n+    assert registry.completion_queue.empty()\n+\n+\n+def test_drain_notifications_ownerless_async_delegation_still_requires_proof(registry):\n+    event = {\n+        \"type\": \"async_delegation\",\n+        \"delegation_id\": \"deleg_ownerless\",\n+        \"goal\": \"task\",\n+        \"status\": \"completed\",\n+        \"summary\": \"done\",\n+        \"api_calls\": 1,\n+        \"duration_seconds\": 0.1,\n+    }\n+    registry.completion_queue.put(event)\n+\n+    results = registry.drain_notifications(\n+        session_key=\"session-a\",\n+        owns_event=lambda _event: False,\n+    )\n+\n+    assert results == []\n+    assert registry.completion_queue.get_nowait() == event\n+    assert registry.completion_queue.empty()\n+\n+\n+def test_drain_notifications_completion_callback_exception_fails_closed(registry):\n+    event = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_callback_error\",\n+        \"session_key\": \"session-a\",\n+        \"command\": \"safe-test-command\",\n+        \"exit_code\": 0,\n+        \"output\": \"done\",\n+    }\n+    registry.completion_queue.put(event)\n+\n+    def broken(_event):\n+        raise RuntimeError(\"ownership check exploded\")\n+\n+    results = registry.drain_notifications(\n+        session_key=\"session-a\",\n+        owns_event=broken,\n+    )\n+\n+    assert results == []\n+    assert registry.completion_queue.get_nowait() == event\n+    assert registry.completion_queue.empty()\n+\n+\n def test_drain_notifications_filters_async_delegation_by_session_key():\n     \"\"\"Async-delegation events should only be consumed by the matching session's drain.\n "]},{"file":"tools/process_registry.py","hunks":["@@ -1146,8 +1146,10 @@ def is_session_waiting(self, session_id: str) -> bool:\n                 return False\n         return True\n \n-    def _drain_should_skip(self, session_id: str) -> bool:\n-        \"\"\"Whether the CLI drain should skip a completion event for this session.\n+    def _drain_should_skip(\n+        self, session_id: str, *, skip_poll_observed: bool = True\n+    ) -> bool:\n+        \"\"\"Whether this drain should skip a completion event for this session.\n \n         Skips when the agent has either truly consumed the output (wait/log →\n         ``_completion_consumed``) or observed the exit inline via poll()","@@ -1157,32 +1159,45 @@ def _drain_should_skip(self, session_id: str) -> bool:\n         check only ``is_completion_consumed`` so a read-only poll never\n         suppresses their autonomous delivery turn (#10156).\n         \"\"\"\n-        return session_id in self._completion_consumed or session_id in self._poll_observed\n+        return session_id in self._completion_consumed or (\n+            skip_poll_observed and session_id in self._poll_observed\n+        )\n \n     def drain_notifications(\n-        self, session_key: str = \"\", owns_event=None,\n+        self,\n+        session_key: str = \"\",\n+        owns_event=None,\n+        *,\n+        skip_poll_observed: bool = True,\n     ) -> \"list[tuple[dict, str]]\":\n         \"\"\"Pop all pending notification events and return formatted pairs.\n \n         Returns a list of (raw_event, formatted_text) tuples.\n         Skips completion events the agent already consumed via wait/log or\n-        observed inline via poll() (see ``_drain_should_skip``).\n+        observed inline via poll() (see ``_drain_should_skip``). Gateway/TUI\n+        callers pass ``skip_poll_observed=False`` because read-only polling must\n+        not suppress autonomous delivery there.\n \n-        Async-delegation events carry a conversation payload, so draining one\n-        into the wrong session is a cross-chat leak (#58684, #55578). Two\n-        filter modes, strongest wins:\n+        When a routing filter is supplied, addressed notifications must not be\n+        drained into the wrong session. Async-delegation events always require\n+        conversation payload; ordinary notifications require routing when they\n+        carry ``session_key`` or ``origin_ui_session_id`` metadata. Two filter\n+        modes are supported, strongest first:\n \n         - ``owns_event(evt) -> bool``: positive-proof ownership callback.\n-          When provided, an async-delegation event is consumed ONLY if the\n-          callback returns True; everything else is re-queued for its owner.\n+          When provided, a routed event is consumed ONLY if the callback\n+          returns True; everything else is re-queued for its owner.\n           The TUI passes its compression-chain-aware ownership check here so\n           a post-compression session still claims its own pre-compression\n           dispatches.\n         - ``session_key``: plain key equality (CLI and other single-session\n-          callers). Non-matching async-delegation events are re-queued.\n+          callers). Non-matching addressed events are re-queued.\n \n         With neither set, all events are consumed (legacy single-session\n-        behavior, backward compatible).\n+        behavior, backward compatible). Ownerless ordinary notifications also\n+        retain that legacy behavior even when a filter is provided. When a\n+        filter is provided, ownerless async-delegation events remain\n+        fail-closed and require positive proof.\n         \"\"\"\n         results: \"list[tuple[dict, str]]\" = []\n         requeue: \"list[dict]\" = []","@@ -1191,39 +1206,43 @@ def drain_notifications(\n                 evt = self.completion_queue.get_nowait()\n             except Exception:\n                 break\n-            _evt_sid = evt.get(\"session_id\", \"\")\n-            if evt.get(\"type\") == \"completion\" and self._drain_should_skip(_evt_sid):\n-                continue\n-            # Filter async-delegation events so they are not delivered to the\n-            # wrong session/thread (#58684). Positive-proof callback beats\n-            # bare key equality when the caller can provide one.\n-            if evt.get(\"type\") == \"async_delegation\":\n-                if owns_event is not None:\n-                    try:\n-                        owned = bool(owns_event(evt))\n-                    except Exception:\n-                        owned = False  # fail closed — never leak on a broken check\n-                    if not owned:\n-                        requeue.append(evt)\n-                        continue\n-                elif session_key:\n-                    evt_session_key = evt.get(\"session_key\", \"\") or \"\"\n-                    if evt_session_key != session_key:\n-                        requeue.append(evt)\n-                        continue\n-                elif evt.get(\"restored\"):\n-                    # Legacy unfiltered drain (no ownership callback, no\n-                    # session key). That behavior was safe when the in-memory\n-                    # queue could only hold events created by this very\n-                    # process — but durable restore (#63494) re-enqueues\n-                    # completions from PREVIOUS processes at startup, so an\n-                    # unfiltered consumer here would adopt a dead, unrelated\n-                    # session's conversation payload (#64484). Fail closed:\n-                    # leave restored events queued (still 'pending' on disk)\n-                    # for a consumer that can positively prove ownership,\n-                    # e.g. the owning session's --resume.\n+            # Positive-proof ownership beats bare key equality. Delegation\n+            # payloads always require proof; ordinary events require it once\n+            # they carry routing metadata. Ownerless ordinary events preserve\n+            # legacy single-session delivery.\n+            is_async_delegation = evt.get(\"type\") == \"async_delegation\"\n+            evt_session_key = str(evt.get(\"session_key\") or \"\")\n+            evt_origin_sid = str(evt.get(\"origin_ui_session_id\") or \"\")\n+            requires_positive_proof = is_async_delegation or bool(\n+                evt_session_key or evt_origin_sid\n+            )\n+            if owns_event is not None and requires_positive_proof:\n+                try:\n+                    owned = bool(owns_event(evt))\n+                except Exception:\n+                    owned = False  # fail closed — never leak on a broken check\n+                if not owned:\n+                    requeue.append(evt)\n+                    continue\n+            elif session_key and requires_positive_proof:\n+                if evt_session_key != session_key:\n                     requeue.append(evt)\n                     continue\n+            elif is_async_delegation and evt.get(\"restored\"):\n+                # Durable restore can enqueue previous-process payloads into a\n+                # fresh registry. An unfiltered legacy drain cannot prove\n+                # ownership, so leave those events queued for the owner.\n+                requeue.append(evt)\n+                continue\n+            # Local consumed/observed state may suppress only events this\n+            # session owns (or legacy ownerless ordinary events). Routing must\n+            # happen first so a foreign session cannot drop the owner's event.\n+            _evt_sid = evt.get(\"session_id\", \"\")\n+            if evt.get(\"type\") == \"completion\" and self._drain_should_skip(\n+                _evt_sid, skip_poll_observed=skip_poll_observed\n+            ):\n+                continue\n+\n             text = format_process_notification(evt)\n             if text:\n                 results.append((evt, text))"]},{"file":"tui_gateway/server.py","hunks":["@@ -8652,9 +8652,9 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool\n     minus its orphan-adoption fallback. An event owns-matches when its\n     ``origin_ui_session_id`` is this live session, or its ``session_key``\n     (raw or resolved through the compression chain) matches this session's\n-    key/lineage. Used as a fail-closed gate for async-delegation payloads:\n-    \"not provably elsewhere\" is NOT good enough to inject a conversation\n-    payload into this chat (#55578).\n+    key/lineage. Used as the fail-closed gate for every addressed notification:\n+    \"not provably elsewhere\" is NOT good enough to inject a payload into this\n+    chat (#55578).\n     \"\"\"\n     if session.get(\"_finalized\"):\n         return False","@@ -8679,6 +8679,14 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool\n     return resolved_key in current_keys\n \n \n+def _notification_event_requires_owner(evt: dict) -> bool:\n+    \"\"\"Whether ``evt`` must be positively claimed before TUI delivery.\"\"\"\n+    return evt.get(\"type\") == \"async_delegation\" or bool(\n+        str(evt.get(\"origin_ui_session_id\") or \"\")\n+        or str(evt.get(\"session_key\") or \"\")\n+    )\n+\n+\n def _notification_event_dedup_key(evt: dict) -> tuple:\n     \"\"\"Return the UI-emission identity for a process notification event.\n ","@@ -8725,10 +8733,9 @@ def _notification_poller_loop(\n     status.update (kind=process) for user visibility, then chains an\n     agent turn via _run_prompt_submit if the session is idle.\n \n-    NOTE: The completion_queue is global (one per process). If multiple\n-    TUI sessions coexist, whichever poller wakes first grabs the event,\n-    even if the process was started by a different session. This matches\n-    CLI/gateway behavior (single session per process).\n+    The completion_queue is process-global. In multi-session Desktop each\n+    poller requeues events owned by another live session and drops addressed\n+    events whose owner is gone; ownerless legacy notifications remain global.\n     \"\"\"\n     from tools.process_registry import process_registry, format_process_notification\n ","@@ -8749,26 +8756,22 @@ def _notification_poller_loop(\n             time.sleep(0.1)\n             continue\n \n-        # Fail closed for async-delegation results (#55578): these carry a\n-        # conversation payload, and injecting one into any chat other than the\n-        # one that commissioned it is a hard cross-session leak. The\n-        # belongs-elsewhere check above already re-queued events owned by\n-        # another LIVE session; what reaches here is either ours or an\n-        # orphan whose owner is gone. Orphaned delegation payloads are\n-        # DROPPED, not adopted — the subagent's summary is already persisted\n-        # in the delegation records/output store, so nothing is lost, whereas\n-        # a wrong-chat injection is unrecoverable. Non-delegation events\n-        # (background process completions etc.) keep the historical\n-        # adopt-orphans behavior.\n-        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n-            sid, session, evt\n-        ):\n-            logger.warning(\n-                \"async-delegation completion %s has no live owner \"\n-                \"(origin=%r key=%r); dropping from injection instead of \"\n-                \"delivering to session %s (#55578 fail-closed; result \"\n-                \"remains in the delegation records)\",\n-                evt.get(\"delegation_id\", \"?\"),\n+        # What reaches here is not owned by another LIVE session. Addressed\n+        # events still require positive proof before injection: exact UI origin,\n+        # direct durable key, or compression lineage. If none proves ownership,\n+        # the event is orphaned and must not be adopted by this chat. Truly\n+        # ownerless ordinary notifications retain legacy global delivery.\n+        requires_owner = _notification_event_requires_owner(evt)\n+        if requires_owner and not _session_owns_notification_event(sid, session, evt):\n+            log = (\n+                logger.warning\n+                if evt.get(\"type\") == \"async_delegation\"\n+                else logger.debug\n+            )\n+            log(\n+                \"Dropping unowned %s notification (origin=%r key=%r) instead \"\n+                \"of delivering to session %s\",\n+                evt.get(\"type\", \"completion\"),\n                 str(evt.get(\"origin_ui_session_id\") or \"\"),\n                 str(evt.get(\"session_key\") or \"\"),\n                 sid,","@@ -8830,6 +8833,7 @@ def _notification_poller_loop(\n     # Drain any remaining events after stop signal (process all pending\n     # before exiting so nothing is lost on shutdown). Events owned by other\n     # live sessions are set aside and re-queued so their poller still sees them.\n+    # Orphaned events (owner gone) are dropped — same guard as the main loop.\n     deferred: list = []\n     while not process_registry.completion_queue.empty():\n         try:","@@ -8839,14 +8843,21 @@ def _notification_poller_loop(\n         if _notification_event_belongs_elsewhere(sid, session, evt):\n             deferred.append(evt)\n             continue\n-        # Same fail-closed rule as the live loop: an orphaned async-delegation\n-        # payload is never adopted by a foreign session — defer it (a later\n-        # resume of the owner's lineage can still claim it) rather than\n-        # injecting another chat's conversation here (#55578).\n-        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n-            sid, session, evt\n-        ):\n-            deferred.append(evt)\n+        # Same positive-proof rule as the live loop. Preserve the existing\n+        # shutdown behavior for orphaned delegation payloads by deferring them\n+        # for a later resume; ordinary addressed orphans are dropped.\n+        requires_owner = _notification_event_requires_owner(evt)\n+        if requires_owner and not _session_owns_notification_event(sid, session, evt):\n+            if evt.get(\"type\") == \"async_delegation\":\n+                deferred.append(evt)\n+            else:\n+                logger.debug(\n+                    \"Dropping unowned %s notification during shutdown drain \"\n+                    \"(origin=%r key=%r)\",\n+                    evt.get(\"type\", \"completion\"),\n+                    str(evt.get(\"origin_ui_session_id\") or \"\"),\n+                    str(evt.get(\"session_key\") or \"\"),\n+                )\n             continue\n         _evt_sid = evt.get(\"session_id\", \"\")\n         if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):","@@ -9411,21 +9422,29 @@ def _stream(delta):\n         # Drain completion notifications that arrived during this turn.\n         # The background poller handles between-turn delivery; this is\n         # the safety net for events that arrived mid-turn.\n+        #\n+        # Ownership filter (#42674, #35652): a turn finishing in session B\n+        # must not consume an event that belongs to session A. The registry\n+        # requeues every addressed event this session cannot positively claim;\n+        # the poller then delivers it to a live owner or drops an orphan.\n         try:\n             from tools.process_registry import process_registry\n \n             # Positive-proof ownership (compression-chain aware) — the same\n             # fail-closed gate the poller uses, so the post-turn drain can't\n-            # adopt another session's (or an orphan's) delegation payload,\n-            # while a post-compression session still claims its own\n-            # pre-compression dispatches (#55578).\n-            for _evt, synth in process_registry.drain_notifications(\n+            # adopt another session's addressed notification while a\n+            # post-compression session still claims its own pre-compression\n+            # dispatches (#55578).\n+            drained = process_registry.drain_notifications(\n                 session_key=session.get(\"session_key\", \"\"),\n                 owns_event=lambda e: _session_owns_notification_event(sid, session, e),\n-            ):\n+                skip_poll_observed=False,\n+            )\n+            for index, (_evt, synth) in enumerate(drained):\n                 with session[\"history_lock\"]:\n                     if session.get(\"running\"):\n-                        process_registry.completion_queue.put(_evt)\n+                        for pending_evt, _pending_synth in drained[index:]:\n+                            process_registry.completion_queue.put(pending_evt)\n                         break\n                     session[\"running\"] = True\n                 from tools.async_delegation import (\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}