# NousResearch/hermes-agent · Background task notifications routed to wrong session

## Open now

- 1 issue, 4 PRs open on GitHub.
- #65407: \[Bug\]: residual HERMES_SESSION_* readers prefer process env over task ContextVars
- PR #12918: fix(gateway): route watch notifications to correct Discord thread (#10411)
- PR #18337: fix: resolve 7 identified issues \[automated\]
- PR #61719: fix(tui): isolate background notifications by UI session
- PR #65409: fix(session): prefer ContextVar over process env for session identity

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.

## TL;DR

- 44 member(s): 1 open issue(s), 15 closed issue(s), 4 open PR(s), 9 merged PR(s), 15 PR(s) closed without merging.
- A fix was merged to main in PR #1706. Verify it addresses your case.
- **Recommended:** read PR #1706 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-1706,pr-7454,pr-10460,pr-60869,pr-60871,pr-64593,pr-65419`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.04`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #65407: \[Bug\]: residual HERMES_SESSION_* readers prefer process env over task ContextVars

## Closed / duplicate issues

- #1143: Telegram background process notifications ignore forum topic and post to General
- #7358: Bug: Concurrent gateway messages cause cross-thread misrouting of background task notifications (os.environ state leakage)
- #9532: \[Bug\]: background process watch notifications can leak into the wrong threaded session
- #9736: Bug: Background process completion notifications can route to the wrong session
- #10411: \[Bug\]: Background watch_match notifications can be injected into the wrong Discord thread
- #18101: \[Bug\]: Background process updates can leak into the wrong Slack thread
- #35652: TUI: background process completion notifications can route to the wrong live session
- #42674: \[Bug\]: Background process notify_on_complete leaks into wrong TUI session (cross-session bleed)
- #52447: Background process notifications route to active session instead of originating session
- #57498: delegate_task background results routed to wrong session (cross-channel contamination)
- #57502: Bug: delegate_task sub-agent results routed to wrong session (parent session ID lost on write-back)
- #57576: Async delegation result delivered to wrong TUI session after context compression
- #58684: bug: delegate_task async results delivered to wrong session/thread
- #60426: Background process notifications routed to wrong session
- #64484: \[Bug\]: New CLI session adopts a dead session's async delegation completions (durable restore bypasses CLI drain ownership)

## Prior & competing PRs

- **#1706: fix(gateway): persist watcher metadata in checkpoint for crash recovery (salvaged #1573)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `tests/gateway/test_background_process_notifications.py`, `tests/tools/test_process_registry.py`, `tools/process_registry.py`, `tools/terminal_tool.py`
- **#7454: fix(gateway): replace os.environ session state with contextvars + fix skill frontmatter truncation**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/prompt_builder.py`

    ```diff
    @@ -487,7 +487,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
         (True, {}, "") to err on the side of showing the skill.
         """
         try:
    -        raw = skill_file.read_text(encoding="utf-8")[:2000]
    +        raw = skill_file.read_text(encoding="utf-8")
             frontmatter, _ = parse_frontmatter(raw)
     
             if not skill_matches_platform(frontmatter):
    ```

    ```diff
    @@ -495,7 +495,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
     
             return True, frontmatter, extract_skill_description(frontmatter)
         except Exception as e:
    -        logger.debug("Failed to parse skill file %s: %s", skill_file, e)
    +        logger.warning("Failed to parse skill file %s: %s", skill_file, e)
             return True, {}, ""
     
     
    ```

    ```diff
    @@ -558,9 +558,10 @@ def build_skills_system_prompt(
         # ── Layer 1: in-process LRU cache ─────────────────────────────────
         # Include the resolved platform so per-platform disabled-skill lists
         # produce distinct cache entries (gateway serves multiple platforms).
    +    from gateway.session_context import get_session_env
         _platform_hint = (
             os.environ.get("HERMES_PLATFORM")
    -        or os.environ.get("HERMES_SESSION_PLATFORM")
    +        or get_session_env("HERMES_SESSION_PLATFORM")
             or ""
         )
         cache_key = (
    ```
  - `agent/skill_utils.py`

    ```diff
    @@ -145,10 +145,11 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
         if not isinstance(skills_cfg, dict):
             return set()
     
    +    from gateway.session_context import get_session_env
         resolved_platform = (
             platform
             or os.getenv("HERMES_PLATFORM")
    -        or os.getenv("HERMES_SESSION_PLATFORM")
    +        or get_session_env("HERMES_SESSION_PLATFORM")
         )
         if resolved_platform:
             platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
    ```
  - `gateway/run.py`

    ```diff
    @@ -2400,8 +2400,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
             # Build session context
             context = build_session_context(source, self.config, session_entry)
             
    -        # Set environment variables for tools
    -        self._set_session_env(context)
    +        # Set session context variables for tools (task-local, concurrency-safe)
    +        _session_env_tokens = [redacted:secret-assignment]
             
             # Read privacy.redact_pii from config (re-read per message)
             _redact_pii = False
    ```

    ```diff
    @@ -3234,8 +3234,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                     "Try again or use /reset to start a fresh session."
                 )
             finally:
    -            # Clear session env
    -            self._clear_session_env()
    +            # Restore session context variables to their pre-handler state
    +            self._clear_session_env(_session_env_tokens)
         
         def _format_session_info(self) -> str:
             """Resolve current model config and return a formatted info block.
    ```

    ```diff
    @@ -6120,20 +6120,27 @@ async def _send_update_notification(self) -> bool:
     
             return True
     
    -    def _set_session_env(self, context: SessionContext) -> None:
    -        """Set environment variables for the current session."""
    -        os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
    -        os.environ["HERMES_SESSION_CHAT_ID"] = context.source.chat_id
    -        if context.source.chat_name:
    -            os.environ["HERMES_SESSION_CHAT_NAME"] = context.source.chat_name
    -        if context.source.thread_id:
    -            os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
    -    
    -    def _clear_session_env(self) -> None:
    -        """Clear session environment variables."""
    -        for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]:
    -            if var in os.environ:
    -                del os.environ[var]
    +    def _set_session_env(self, context: SessionContext) -> list:
    +        """Set session context variables for the current async task.
    +
    +        Uses ``contextvars`` instead of ``os.environ`` so that concurrent
    +        gateway messages cannot overwrite each other's session state.
    +
    +        Returns a list of reset tokens; pass them to ``_clear_session_env``
    +        in a ``finally`` block.
    +        """
    +        from gateway.session_context import set_session_vars
    +        return set_session_vars(
    +            platform=context.source.platform.value,
    +            chat_id=context.source.chat_id,
    +            chat_name=context.source.chat_name or "",
    +            thread_id=str(context.source.thread_id) if context.source.thread_id else "",
    +        )
    +
    +    def _clear_session_env(self, tokens: list) -> None:
    +        """Restore session context variables to their pre-handler values."""
    +        from gateway.session_context import clear_session_vars
    +        clear_session_vars(tokens)
         
         async def _enrich_message_with_vision(
             self,
    ```
  - `gateway/session_context.py`

    ```diff
    @@ -0,0 +1,113 @@
    +"""
    +Session-scoped context variables for the Hermes gateway.
    +
    +Replaces the previous ``os.environ``-based session state
    +(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with
    +Python's ``contextvars.ContextVar``.
    +
    +**Why this matters**
    +
    +The gateway processes messages concurrently via ``asyncio``.  When two
    +messages arrive at the same time the old code did:
    +
    +    os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
    +
    +Because ``os.environ`` is *process-global*, Message A's value was
    +silently overwritten by Message B before Message A's agent finished
    +running.  Background-task notifications and tool calls therefore routed
    +to the wrong thread.
    +
    +``contextvars.ContextVar`` values are *task-local*: each ``asyncio``
    +task (and any ``run_in_executor`` thread it spawns) gets its own copy,
    +so concurrent messages never interfere.
    +
    +**Backward compatibility**
    +
    +The public helper ``get_session_env(name, default="")`` mirrors the old
    +``os.getenv("HERMES_SESSION_*", ...)`` calls.  Existing tool code only
    +needs to replace the import + call site:
    +
    +    # before
    +    import os
    +    platform = os.getenv("HERMES_SESSION_PLATFORM", "")
    +
    +    # after
    +    from gateway.session_context import get_session_env
    +    platform = get_session_env("HERMES_SESSION_PLATFORM", "")
    +"""
    +
    +from contextvars import ContextVar
    +
    +# ---------------------------------------------------------------------------
    +# Per-task session variables
    +# ---------------------------------------------------------------------------
    +
    +_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="")
    +_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="")
    +_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="")
    +_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="")
    +
    +_VAR_MAP = {
    +    "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
    +    "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
    +    "HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
    +    "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
    +}
    +
    +
    +def set_session_vars(
    +    platform: str = "",
    +    chat_id: str = "",
    +    chat_name: str = "",
    +    thread_id: str = "",
    +) -> list:
    +    """Set all session context variables and return reset tokens.
    +
    +    Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
    +    the previous values when the handler exits.
    +
    +    Returns a list of ``Token`` objects (one per variable) that can be
    +    passed to ``clear_session_vars``.
    +    """
    +    tokens = [
    +        _SESSION_PLATFORM.set(platform),
    +        _SESSION_CHAT_ID.set(chat_id),
    +        _SESSION_CHAT_NAME.set(chat_name),
    +        _SESSION_THREAD_ID.set(thread_id),
    +    ]
    +    return tokens
    +
    +
    +def clear_session_vars(tokens: list) -> None:
    +    """Restore session context variables to their pre-handler values."""
    +    if not tokens:
    +        return
    +    vars_in_order = [
    +        _SESSION_PLATFORM,
    +        _SESSION_CHAT_ID,
    +        _SESSION_CHAT_NAME,
    +        _SESSION_THREAD_ID,
    +    ]
    +    for var, token in zip(vars_in_order, tokens):
    +        var.reset(token)
    +
    +
    +def get_session_env(name: str, default: str = "") -> str:
    +    """Read a session context variable by its legacy ``HERMES_SESSION_*`` name.
    +
    +    Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``.
    +
    +    Resolution order:
    +    1. Context variable (set by the gateway for concurrency-safe access)
    +    2. ``os.environ`` (used by CLI, cron scheduler, and tests)
    +    3. *default*
    +    """
    +    import os
    +
    +    var = _VAR_MAP.get(name)
    +    if var is not None:
    +        value = var.get()
    +        if value:
    +            return value
    +    # Fall back to os.environ for CLI, cron, and test compatibility
    +    return os.getenv(name, default)
    ```
  - `tests/gateway/test_session_env.py`

    ```diff
    @@ -3,9 +3,15 @@
     from gateway.config import Platform
     from gateway.run import GatewayRunner
     from gateway.session import SessionContext, SessionSource
    +from gateway.session_context import (
    +    get_session_env,
    +    set_session_vars,
    +    clear_session_vars,
    +)
     
     
    -def test_set_session_env_includes_thread_id(monkeypatch):
    +def test_set_session_env_sets_contextvars(monkeypatch):
    +    """_set_session_env should populate contextvars, not os.environ."""
         runner = object.__new__(GatewayRunner)
         source = SessionSource(
             platform=Platform.TELEGRAM,
    ```

    ```diff
    @@ -21,25 +27,93 @@ def test_set_session_env_includes_thread_id(monkeypatch):
         monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
         monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
     
    -    runner._set_session_env(context)
    +    tokens = [redacted:secret-assignment]
     
    -    assert os.getenv("HERMES_SESSION_PLATFORM") == "telegram"
    -    assert os.getenv("HERMES_SESSION_CHAT_ID") == "-1001"
    -    assert os.getenv("HERMES_SESSION_CHAT_NAME") == "Group"
    -    assert os.getenv("HERMES_SESSION_THREAD_ID") == "17585"
    +    # Values should be readable via get_session_env (contextvar path)
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
    +    assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
    +    assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group"
    +    assert get_session_env("HERMES_SESSION_THREAD_ID") == "17585"
     
    +    # os.environ should NOT be touched
    +    assert os.getenv("HERMES_SESSION_PLATFORM") is None
    +    assert os.getenv("HERMES_SESSION_THREAD_ID") is None
    +
    +    # Clean up
    +    runner._clear_session_env(tokens)
     
    -def test_clear_session_env_removes_thread_id(monkeypatch):
    +
    +def test_clear_session_env_restores_previous_state(monkeypatch):
    +    """_clear_session_env should restore contextvars to their pre-handler values."""
         runner = object.__new__(GatewayRunner)
     
    -    monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
    -    monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001")
    -    monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group")
    -    monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585")
    +    monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
    +    monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
    +    monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
    +    monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
    +
    +    source = SessionSource(
    +        platform=Platform.TELEGRAM,
    +        chat_id="-1001",
    +        chat_name="Group",
    +        chat_type="group",
    +        thread_id="17585",
    +    )
    +    context = SessionContext(source=source, connected_platforms=[], home_channels={})
    +
    +    tokens = [redacted:secret-assignment]
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
     
    -    runner._clear_session_env()
    +    runner._clear_session_env(tokens)
     
    -    assert os.getenv("HERMES_SESSION_PLATFORM") is None
    -    assert os.getenv("HERMES_SESSION_CHAT_ID") is None
    -    assert os.getenv("HERMES_SESSION_CHAT_NAME") is None
    -    assert os.getenv("HERMES_SESSION_THREAD_ID") is None
    +    # After clear, contextvars should return to defaults (empty)
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == ""
    +    assert get_session_env("HERMES_SESSION_CHAT_ID") == ""
    +    assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
    +    assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
    +
    +
    +def test_get_session_env_falls_back_to_os_environ(monkeypatch):
    +    """get_session_env should fall back to os.environ when contextvar is unset."""
    +    monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord")
    +
    +    # No contextvar set — should read from os.environ
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
    +
    +    # Now set a contextvar — should prefer it
    +    tokens = [redacted:secret-assignment]"telegram")
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
    +
    +    # Restore — should fall back to os.environ again
    +    clear_session_vars(tokens)
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "discord"
    +
    +
    +def test_get_session_env_default_when_nothing_set(monkeypatch):
    +    """get_session_env returns default when neither contextvar nor env is set."""
    +    monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
    +
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == ""
    +    assert get_session_env("HERMES_SESSION_PLATFORM", "fallback") == "fallback"
    +
    +
    +def test_set_session_env_handles_missing_optional_fields():
    +    """_set_session_env should handle None chat_name and thread_id gracefully."""
    +    runner = object.__new__(GatewayRunner)
    +    source = SessionSource(
    +        platform=Platform.TELEGRAM,
    +        chat_id="-1001",
    +        chat_name=None,
    +        chat_type="private",
    +        thread_id=None,
    +    )
    +    context = SessionContext(source=source, connected_platforms=[], home_channels={})
    +
    +    tokens = [redacted:secret-assignment]
    +
    +    assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram"
    +    assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001"
    +    assert get_session_env("HERMES_SESSION_CHAT_NAME") == ""
    +    assert get_session_env("HERMES_SESSION_THREAD_ID") == ""
    +
    +    runner._clear_session_env(tokens)
    ```
  - `tools/cronjob_tools.py`

    ```diff
    @@ -64,14 +64,15 @@ def _scan_cron_prompt(prompt: str) -> str:
     
     
     def _origin_from_env() -> Optional[Dict[str, str]]:
    -    origin_platform = os.getenv("HERMES_SESSION_PLATFORM")
    -    origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID")
    +    from gateway.session_context import get_session_env
    +    origin_platform = get_session_env("HERMES_SESSION_PLATFORM")
    +    origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
         if origin_platform and origin_chat_id:
             return {
                 "platform": origin_platform,
                 "chat_id": origin_chat_id,
    -            "chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"),
    -            "thread_id": os.getenv("HERMES_SESSION_THREAD_ID"),
    +            "chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
    +            "thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
             }
         return None
     
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -212,7 +212,8 @@ def _handle_send(args):
             if isinstance(result, dict) and result.get("success") and mirror_text:
                 try:
                     from gateway.mirror import mirror_to_session
    -                source_label = os.getenv("HERMES_SESSION_PLATFORM", "cli")
    +                from gateway.session_context import get_session_env
    +                source_label = get_session_env("HERMES_SESSION_PLATFORM", "cli")
                     if mirror_to_session(platform_name, chat_id, mirror_text, source_label=source_label, thread_id=thread_id):
                         result["mirrored"] = True
                 except Exception:
    ```

    ```diff
    @@ -1020,7 +1021,8 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No
     
     def _check_send_message():
         """Gate send_message on gateway running (always available on messaging platforms)."""
    -    platform = os.getenv("HERMES_SESSION_PLATFORM", "")
    +    from gateway.session_context import get_session_env
    +    platform = get_session_env("HERMES_SESSION_PLATFORM", "")
         if platform and platform != "local":
             return True
         try:
    ```
  - `tools/skills_tool.py`

    ```diff
    @@ -347,7 +347,8 @@ def _capture_required_environment_variables(
     def _is_gateway_surface() -> bool:
         if os.getenv("HERMES_GATEWAY_SESSION"):
             return True
    -    return bool(os.getenv("HERMES_SESSION_PLATFORM"))
    +    from gateway.session_context import get_session_env
    +    return bool(get_session_env("HERMES_SESSION_PLATFORM"))
     
     
     def _get_terminal_backend_name() -> str:
    ```
  - `tools/terminal_tool.py`

    ```diff
    @@ -1420,10 +1420,11 @@ def terminal_tool(
                         # In gateway mode, auto-register a fast watcher so the
                         # gateway can detect completion and trigger a new agent
                         # turn.  CLI mode uses the completion_queue directly.
    -                    _gw_platform = os.getenv("HERMES_SESSION_PLATFORM", "")
    +                    from gateway.session_context import get_session_env as _gse
    +                    _gw_platform = _gse("HERMES_SESSION_PLATFORM", "")
                         if _gw_platform and not check_interval:
    -                        _gw_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "")
    -                        _gw_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "")
    +                        _gw_chat_id = _gse("HERMES_SESSION_CHAT_ID", "")
    +                        _gw_thread_id = _gse("HERMES_SESSION_THREAD_ID", "")
                             proc_session.watcher_platform = _gw_platform
                             proc_session.watcher_chat_id = _gw_chat_id
                             proc_session.watcher_thread_id = _gw_thread_id
    ```

    ```diff
    @@ -1445,9 +1446,10 @@ def terminal_tool(
                             result_data["check_interval_note"] = (
                                 f"Requested {check_interval}s raised to minimum 30s"
                             )
    -                    watcher_platform = os.getenv("HERMES_SESSION_PLATFORM", "")
    -                    watcher_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "")
    -                    watcher_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "")
    +                    from gateway.session_context import get_session_env as _gse2
    +                    watcher_platform = _gse2("HERMES_SESSION_PLATFORM", "")
    +                    watcher_chat_id = _gse2("HERMES_SESSION_CHAT_ID", "")
    +                    watcher_thread_id = _gse2("HERMES_SESSION_THREAD_ID", "")
     
                         # Store on session for checkpoint persistence
                         proc_session.watcher_platform = watcher_platform
    ```
  - `tools/tts_tool.py`

    ```diff
    @@ -480,7 +480,8 @@ def text_to_speech_tool(
         # Telegram voice bubbles require Opus (.ogg); OpenAI and ElevenLabs can
         # produce Opus natively (no ffmpeg needed).  Edge TTS always outputs MP3
         # and needs ffmpeg for conversion.
    -    platform = os.getenv("HERMES_SESSION_PLATFORM", "").lower()
    +    from gateway.session_context import get_session_env
    +    platform = get_session_env("HERMES_SESSION_PLATFORM", "").lower()
         want_opus = (platform == "telegram")
     
         # Determine output path
    ```
- **#10460: fix(gateway): route watch-pattern notifications to originating session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/run.py`

    ```diff
    @@ -482,6 +482,27 @@ def _resolve_hermes_bin() -> Optional[list[str]]:
         return None
     
     
    +def _parse_session_key(session_key: str) -> "dict | None":
    +    """Parse a session key into its component parts.
    +
    +    Session keys follow the format
    +    ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``.
    +    Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and
    +    optionally ``thread_id`` keys, or None if the key doesn't match.
    +    """
    +    parts = session_key.split(":")
    +    if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main":
    +        result = {
    +            "platform": parts[2],
    +            "chat_type": parts[3],
    +            "chat_id": parts[4],
    +        }
    +        if len(parts) > 5:
    +            result["thread_id"] = parts[5]
    +        return result
    +    return None
    +
    +
     def _format_gateway_process_notification(evt: dict) -> "str | None":
         """Format a watch pattern event from completion_queue into a [SYSTEM:] message."""
         evt_type = evt.get("type", "completion")
    ```

    ```diff
    @@ -1489,12 +1510,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None:
             notified: set = set()
             for session_key in active:
                 # Parse platform + chat_id from the session key.
    -            # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]
    -            parts = session_key.split(":")
    -            if len(parts) < 5:
    +            _parsed = _parse_session_key(session_key)
    +            if not _parsed:
                     continue
    -            platform_str = parts[2]
    -            chat_id = parts[4]
    +            platform_str = _parsed["platform"]
    +            chat_id = _parsed["chat_id"]
     
                 # Deduplicate: one notification per chat, even if multiple
                 # sessions (different users/threads) share the same chat.
    ```

    ```diff
    @@ -1510,7 +1530,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:
     
                     # Include thread_id if present so the message lands in the
                     # correct forum topic / thread.
    -                thread_id = parts[5] if len(parts) > 5 else None
    +                thread_id = _parsed.get("thread_id")
                     metadata = {"thread_id": thread_id} if thread_id else None
     
                     await adapter.send(chat_id, msg, metadata=metadata)
    ```

    ```diff
    @@ -3958,7 +3978,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                         synth_text = _format_gateway_process_notification(evt)
                         if synth_text:
                             try:
    -                            await self._inject_watch_notification(synth_text, event)
    +                            await self._inject_watch_notification(synth_text, evt)
                             except Exception as e2:
                                 logger.error("Watch notification injection error: %s", e2)
                 except Exception as e:
    ```

    ```diff
    @@ -7452,14 +7472,75 @@ async def _enrich_message_with_transcription(
                 return prefix
             return user_text
     
    -    async def _inject_watch_notification(self, synth_text: str, original_event) -> None:
    +    def _build_process_event_source(self, evt: dict):
    +        """Resolve the canonical source for a synthetic background-process event.
    +
    +        Prefer the persisted session-store origin for the event's session key.
    +        Falling back to the currently active foreground event is what causes
    +        cross-topic bleed, so don't do that.
    +        """
    +        from gateway.session import SessionSource
    +
    +        session_key = str(evt.get("session_key") or "").strip()
    +        derived_platform = ""
    +        derived_chat_type = ""
    +        derived_chat_id = ""
    +
    +        if session_key:
    +            try:
    +                self.session_store._ensure_loaded()
    +                entry = self.session_store._entries.get(session_key)
    +                if entry and getattr(entry, "origin", None):
    +                    return entry.origin
    +            except Exception as exc:
    +                logger.debug(
    +                    "Synthetic process-event session-store lookup failed for %s: %s",
    +                    session_key,
    +                    exc,
    +                )
    +
    +            _parsed = _parse_session_key(session_key)
    +            if _parsed:
    +                derived_platform = _parsed["platform"]
    +                derived_chat_type = _parsed["chat_type"]
    +                derived_chat_id = _parsed["chat_id"]
    +
    +        platform_name = str(evt.get("platform") or derived_platform or "").strip().lower()
    +        chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower()
    +        chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip()
    +        if not platform_name or not chat_type or not chat_id:
    +            return None
    +
    +        try:
    +            platform = Platform(platform_name)
    +        except Exception:
    +            logger.warning(
    +                "Synthetic process event has invalid platform metadata: %r",
    +                platform_name,
    +            )
    +            return None
    +
    +        return SessionSource(
    +            platform=platform,
    +            chat_id=chat_id,
    +            chat_type=chat_type,
    +            thread_id=str(evt.get("thread_id") or "").strip() or None,
    +            user_id=str(evt.get("user_id") or "").strip() or None,
    +            user_name=str(evt.get("user_name") or "").strip() or None,
    +        )
    +
    +    async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None:
             """Inject a watch-pattern notification as a synthetic message event.
     
    -        Uses the source from the original user event to route the notification
    -        back to the correct chat/adapter.
    +        Routing must come from the queued watch event itself, not from whatever
    +        foreground message happened to be active when the queue was drained.
             """
    -        source = getattr(original_event, "source", None)
    +        source = self._build_process_event_source(evt)
             if not source:
    +            logger.warning(
    +                "Dropping watch notification with no routing metadata for process %s",
    +                evt.get("session_id", "unknown"),
    +            )
                 return
             platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
             adapter = None
    ```

    ```diff
    @@ -7477,7 +7558,12 @@ async def _inject_watch_notification(self, synth_text: str, original_event) -> N
                     source=source,
                     internal=True,
                 )
    -            logger.info("Watch pattern notification — injecting for %s", platform_name)
    +            logger.info(
    +                "Watch pattern notification — injecting for %s chat=%s thread=%s",
    +                platform_name,
    +                source.chat_id,
    +                source.thread_id,
    +            )
                 await adapter.handle_message(synth_event)
             except Exception as e:
                 logger.error("Watch notification injection error: %s", e)
    ```

    ```diff
    @@ -7547,33 +7633,42 @@ async def _run_process_watcher(self, watcher: dict) -> None:
                             f"Command: {session.command}\n"
                             f"Output:\n{_out}]"
                         )
    +                    source = self._build_process_event_source({
    +                        "session_id": session_id,
    +                        "session_key": session_key,
    +                        "platform": platform_name,
    +                        "chat_id": chat_id,
    +                        "thread_id": thread_id,
    +                        "user_id": user_id,
    +                        "user_name": user_name,
    +                    })
    +                    if not source:
    +                        logger.warning(
    +                            "Dropping completion notification with no routing metadata for process %s",
    +                            session_id,
    +                        )
    +                        break
    +
                         adapter = None
                         for p, a in self.adapters.items():
    -                        if p.value == platform_name:
    +                        if p == source.platform:
                                 adapter = a
                                 break
    -                    if adapter and chat_id:
    +                    if adapter and source.chat_id:
                             try:
                                 from gateway.platforms.base import MessageEvent, MessageType
    -                            from gateway.session import SessionSource
    -                            from gateway.config import Platform
    -                            _platform_enum = Platform(platform_name)
    -                            _source = SessionSource(
    -                                platform=_platform_enum,
    -                                chat_id=chat_id,
    -                                thread_id=thread_id or None,
    -                                user_id=user_id or None,
    -                                user_name=user_name or None,
    -                            )
                                 synth_event = MessageEvent(
                                     text=synth_text,
                                     message_type=MessageType.TEXT,
    -                                source=_source,
    +                                source=source,
                                     internal=True,
                                 )
                                 logger.info(
    -                                "Process %s finished — injecting agent notification for session %s",
    -                                session_id, session_key,
    +                                "Process %s finished — injecting agent notification for session %s chat=%s thread=%s",
    +                                session_id,
    +                                session_key,
    +                                source.chat_id,
    +                                source.thread_id,
                                 )
                                 await adapter.handle_message(synth_event)
                             except Exception as e:
    ```
  - `tests/gateway/test_background_process_notifications.py`

    ```diff
    @@ -14,7 +14,7 @@
     import pytest
     
     from gateway.config import GatewayConfig, Platform
    -from gateway.run import GatewayRunner
    +from gateway.run import GatewayRunner, _parse_session_key
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:
         monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
     
         runner = GatewayRunner(GatewayConfig())
    -    adapter = SimpleNamespace(send=AsyncMock())
    +    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
         runner.adapters[Platform.TELEGRAM] = adapter
         return runner
     
    ```

    ```diff
    @@ -243,3 +243,162 @@ async def _instant_sleep(*_a, **_kw):
         assert adapter.send.await_count == 1
         _, kwargs = adapter.send.call_args
         assert kwargs["metadata"] is None
    +
    +
    [redacted:email]
    +async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path):
    +    from gateway.session import SessionSource
    +
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +    adapter = runner.adapters[Platform.TELEGRAM]
    +    runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
    +        origin=SessionSource(
    +            platform=Platform.TELEGRAM,
    +            chat_id="-100",
    +            chat_type="group",
    +            thread_id="42",
    +            user_id="123",
    +            user_name="Emiliyan",
    +        )
    +    )
    +
    +    evt = {
    +        "session_id": "proc_watch",
    +        "session_key": "agent:main:telegram:group:-100:42",
    +    }
    +
    +    await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt)
    +
    +    adapter.handle_message.assert_awaited_once()
    +    synth_event = adapter.handle_message.await_args.args[0]
    +    assert synth_event.internal is True
    +    assert synth_event.source.platform == Platform.TELEGRAM
    +    assert synth_event.source.chat_id == "-100"
    +    assert synth_event.source.chat_type == "group"
    +    assert synth_event.source.thread_id == "42"
    +    assert synth_event.source.user_id == "123"
    +    assert synth_event.source.user_name == "Emiliyan"
    +
    +
    +def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path):
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +
    +    evt = {
    +        "session_id": "proc_watch",
    +        "session_key": "agent:main:telegram:group:-100:42",
    +        "platform": "telegram",
    +        "chat_id": "-100",
    +        "thread_id": "42",
    +        "user_id": "123",
    +        "user_name": "Emiliyan",
    +    }
    +
    +    source = runner._build_process_event_source(evt)
    +
    +    assert source is not None
    +    assert source.platform == Platform.TELEGRAM
    +    assert source.chat_id == "-100"
    +    assert source.chat_type == "group"
    +    assert source.thread_id == "42"
    +    assert source.user_id == "123"
    +    assert source.user_name == "Emiliyan"
    +
    +
    [redacted:email]
    +async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path):
    +    """Negative test: watch notification must NOT route to the foreground thread."""
    +    from gateway.session import SessionSource
    +
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +    adapter = runner.adapters[Platform.TELEGRAM]
    +
    +    # Session store has the process's original thread (thread 42)
    +    runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
    +        origin=SessionSource(
    +            platform=Platform.TELEGRAM,
    +            chat_id="-100",
    +            chat_type="group",
    +            thread_id="42",
    +            user_id="proc_owner",
    +            user_name="alice",
    +        )
    +    )
    +
    +    # The evt dict carries the correct session_key — NOT a foreground event
    +    evt = {
    +        "session_id": "proc_cross_thread",
    +        "session_key": "agent:main:telegram:group:-100:42",
    +    }
    +
    +    await runner._inject_watch_notification("[SYSTEM: watch match]", evt)
    +
    +    adapter.handle_message.assert_awaited_once()
    +    synth_event = adapter.handle_message.await_args.args[0]
    +    # Must route to thread 42 (process origin), NOT some other thread
    +    assert synth_event.source.thread_id == "42"
    +    assert synth_event.source.user_id == "proc_owner"
    +
    +
    +def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path):
    +    """Missing session_key and no platform metadata → None (drop notification)."""
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +
    +    source = runner._build_process_event_source({"session_id": "proc_orphan"})
    +    assert source is None
    +
    +
    +def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path):
    +    """Invalid platform string → None."""
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +
    +    evt = {
    +        "session_id": "proc_bad",
    +        "platform": "not_a_real_platform",
    +        "chat_type": "dm",
    +        "chat_id": "123",
    +    }
    +    source = runner._build_process_event_source(evt)
    +    assert source is None
    +
    +
    +def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path):
    +    """Session key with <5 parts doesn't parse, falls through to empty metadata → None."""
    +    runner = _build_runner(monkeypatch, tmp_path, "all")
    +
    +    evt = {
    +        "session_id": "proc_short",
    +        "session_key": "agent:main:telegram",  # Too few parts
    +    }
    +    source = runner._build_process_event_source(evt)
    +    assert source is None
    +
    +
    +# ---------------------------------------------------------------------------
    +# _parse_session_key helper
    +# ---------------------------------------------------------------------------
    +
    +def test_parse_session_key_valid():
    +    result = _parse_session_key("agent:main:telegram:group:-100")
    +    assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "-100"}
    +
    +
    +def test_parse_session_key_with_extra_parts():
    +    """Thread ID (6th part) is extracted; further parts are ignored."""
    +    result = _parse_session_key("agent:main:discord:group:chan123:thread456")
    +    assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"}
    +
    +
    +def test_parse_session_key_with_user_id_part():
    +    """7th part (user_id) is ignored — only up to thread_id is extracted."""
    +    result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99")
    +    assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"}
    +
    +
    +def test_parse_session_key_too_short():
    +    assert _parse_session_key("agent:main:telegram") is None
    +    assert _parse_session_key("") is None
    +
    +
    +def test_parse_session_key_wrong_prefix():
    +    assert _parse_session_key("cron:main:telegram:dm:123") is None
    +    assert _parse_session_key("agent:cron:telegram:dm:123") is None
    ```
  - `tests/gateway/test_internal_event_bypass_pairing.py`

    ```diff
    @@ -230,6 +230,59 @@ async def _instant_sleep(*_a, **_kw):
         assert event.source.user_name == "alice"
     
     
    [redacted:email]
    +async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path):
    +    import tools.process_registry as pr_module
    +    from gateway.session import SessionSource
    +
    +    sessions = [
    +        SimpleNamespace(
    +            output_buffer="done\n", exited=True, exit_code=0, command="echo test"
    +        ),
    +    ]
    +    monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
    +
    +    async def _instant_sleep(*_a, **_kw):
    +        pass
    +    monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
    +
    +    runner = GatewayRunner(GatewayConfig())
    +    adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
    +    runner.adapters[Platform.TELEGRAM] = adapter
    +    runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
    +        origin=SessionSource(
    +            platform=Platform.TELEGRAM,
    +            chat_id="-100",
    +            chat_type="group",
    +            thread_id="42",
    +            user_id="user-42",
    +            user_name="alice",
    +        )
    +    )
    +
    +    watcher = {
    +        "session_id": "proc_test_internal",
    +        "check_interval": 0,
    +        "session_key": "agent:main:telegram:group:-100:42",
    +        "platform": "telegram",
    +        "chat_id": "-100",
    +        "thread_id": "42",
    +        "notify_on_complete": True,
    +    }
    +
    +    await runner._run_process_watcher(watcher)
    +
    +    assert adapter.handle_message.await_count == 1
    +    event = adapter.handle_message.await_args.args[0]
    +    assert event.internal is True
    +    assert event.source.platform == Platform.TELEGRAM
    +    assert event.source.chat_id == "-100"
    +    assert event.source.chat_type == "group"
    +    assert event.source.thread_id == "42"
    +    assert event.source.user_id == "user-42"
    +    assert event.source.user_name == "alice"
    +
    +
     @pytest.mark.asyncio
     async def test_none_user_id_skips_pairing(monkeypatch, tmp_path):
         """A non-internal event with user_id=None should be silently dropped."""
    ```
  - `tests/tools/test_watch_patterns.py`

    ```diff
    @@ -92,6 +92,25 @@ def test_basic_match(self, registry):
             assert "disk full" in evt["output"]
             assert evt["session_id"] == "proc_test_watch"
     
    +    def test_match_carries_session_key_and_watcher_routing_metadata(self, registry):
    +        session = _make_session(watch_patterns=["ERROR"])
    +        session.session_key = "agent:main:telegram:group:-100:42"
    +        session.watcher_platform = "telegram"
    +        session.watcher_chat_id = "-100"
    +        session.watcher_user_id = "u123"
    +        session.watcher_user_name = "alice"
    +        session.watcher_thread_id = "42"
    +
    +        registry._check_watch_patterns(session, "ERROR: disk full\n")
    +        evt = registry.completion_queue.get_nowait()
    +
    +        assert evt["session_key"] == "agent:main:telegram:group:-100:42"
    +        assert evt["platform"] == "telegram"
    +        assert evt["chat_id"] == "-100"
    +        assert evt["user_id"] == "u123"
    +        assert evt["user_name"] == "alice"
    +        assert evt["thread_id"] == "42"
    +
         def test_multiple_patterns(self, registry):
             """First matching pattern is reported."""
             session = _make_session(watch_patterns=["WARN", "ERROR"])
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -191,9 +191,15 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:
                         session._watch_disabled = True
                         self.completion_queue.put({
                             "session_id": session.id,
    +                        "session_key": session.session_key,
                             "command": session.command,
                             "type": "watch_disabled",
                             "suppressed": session._watch_suppressed,
    +                        "platform": session.watcher_platform,
    +                        "chat_id": session.watcher_chat_id,
    +                        "user_id": session.watcher_user_id,
    +                        "user_name": session.watcher_user_name,
    +                        "thread_id": session.watcher_thread_id,
                             "message": (
                                 f"Watch patterns disabled for process {session.id} — "
                                 f"too many matches ({session._watch_suppressed} suppressed). "
    ```

    ```diff
    @@ -219,11 +225,17 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:
     
             self.completion_queue.put({
                 "session_id": session.id,
    +            "session_key": session.session_key,
                 "command": session.command,
                 "type": "watch_match",
                 "pattern": matched_pattern,
                 "output": output,
                 "suppressed": suppressed,
    +            "platform": session.watcher_platform,
    +            "chat_id": session.watcher_chat_id,
    +            "user_id": session.watcher_user_id,
    +            "user_name": session.watcher_user_name,
    +            "thread_id": session.watcher_thread_id,
             })
     
         @staticmethod
    ```
  - `tools/terminal_tool.py`

    ```diff
    @@ -1384,14 +1384,10 @@ def terminal_tool(
                     if pty_disabled_reason:
                         result_data["pty_note"] = pty_disabled_reason
     
    -                # Mark for agent notification on completion
    -                if notify_on_complete and background:
    -                    proc_session.notify_on_complete = True
    -                    result_data["notify_on_complete"] = True
    -
    -                    # In gateway mode, auto-register a fast watcher so the
    -                    # gateway can detect completion and trigger a new agent
    -                    # turn.  CLI mode uses the completion_queue directly.
    +                # Populate routing metadata on the session so that
    +                # watch-pattern and completion notifications can be
    +                # routed back to the correct chat/thread.
    +                if background and (notify_on_complete or watch_patterns):
                         from gateway.session_context import get_session_env as _gse
                         _gw_platform = _gse("HERMES_SESSION_PLATFORM", "")
                         if _gw_platform:
    ```

    ```diff
    @@ -1404,16 +1400,26 @@ def terminal_tool(
                             proc_session.watcher_user_id = _gw_user_id
                             proc_session.watcher_user_name = _gw_user_name
                             proc_session.watcher_thread_id = _gw_thread_id
    +
    +                # Mark for agent notification on completion
    +                if notify_on_complete and background:
    +                    proc_session.notify_on_complete = True
    +                    result_data["notify_on_complete"] = True
    +
    +                    # In gateway mode, auto-register a fast watcher so the
    +                    # gateway can detect completion and trigger a new agent
    +                    # turn.  CLI mode uses the completion_queue directly.
    +                    if proc_session.watcher_platform:
                             proc_session.watcher_interval = 5
                             process_registry.pending_watchers.append({
                                 "session_id": proc_session.id,
                                 "check_interval": 5,
                                 "session_key": session_key,
    -                            "platform": _gw_platform,
    -                            "chat_id": _gw_chat_id,
    -                            "user_id": _gw_user_id,
    -                            "user_name": _gw_user_name,
    -                            "thread_id": _gw_thread_id,
    +                            "platform": proc_session.watcher_platform,
    +                            "chat_id": proc_session.watcher_chat_id,
    +                            "user_id": proc_session.watcher_user_id,
    +                            "user_name": proc_session.watcher_user_name,
    +                            "thread_id": proc_session.watcher_thread_id,
                                 "notify_on_complete": True,
                             })
     
    ```
- **#37099: feat(desktop): session hygiene, archive, media streaming + connecting overlay**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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` (+6 more)
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -8,6 +8,8 @@ const {
       ipcMain,
       nativeImage,
       nativeTheme,
    +  net: electronNet,
    +  protocol,
       safeStorage,
       session,
       shell,
    ```

    ```diff
    @@ -364,6 +366,66 @@ app.setAboutPanelOptions({
       copyright: 'Copyright © 2026 Nous Research'
     })
     
    +// Custom scheme for streaming local media (video/audio) into the renderer.
    +// Reading large media through `readFileDataUrl` failed: it base64-loads the
    +// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
    +// so any non-trivial video silently refused to load. Streaming via a protocol
    +// handler removes the size cap and gives the <video> element seekable,
    +// range-aware playback. Must be registered before the app is ready.
    +const MEDIA_PROTOCOL = 'hermes-media'
    +// Only audio/video may be streamed. Without this the handler would read any
    +// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
    +const STREAMABLE_MEDIA_EXTS = new Set([
    +  '.avi',
    +  '.flac',
    +  '.m4a',
    +  '.mkv',
    +  '.mov',
    +  '.mp3',
    +  '.mp4',
    +  '.ogg',
    +  '.opus',
    +  '.wav',
    +  '.webm'
    +])
    +
    +protocol.registerSchemesAsPrivileged([
    +  {
    +    scheme: MEDIA_PROTOCOL,
    +    privileges: {
    +      secure: true,
    +      standard: true,
    +      stream: true,
    +      supportFetchAPI: true
    +    }
    +  }
    +])
    +
    +function registerMediaProtocol() {
    +  protocol.handle(MEDIA_PROTOCOL, async request => {
    +    let resolvedPath
    +    try {
    +      const url = new URL(request.url)
    +      const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
    +      ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
    +    } catch {
    +      return new Response('Media not found', { status: 404 })
    +    }
    +
    +    if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
    +      return new Response('Unsupported media type', { status: 415 })
    +    }
    +
    +    // Delegate to Electron's net stack on a file:// URL — it resolves the
    +    // content-type and honors Range requests so seeking works. Forward the
    +    // renderer's headers (notably Range) and skip custom-protocol re-entry.
    +    return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
    +      bypassCustomProtocolHandlers: true,
    +      headers: request.headers
    +    })
    +  })
    +}
    +
     let mainWindow = null
     let hermesProcess = null
     let connectionPromise = null
    ```

    ```diff
    @@ -3654,6 +3716,7 @@ app.whenReady().then(() => {
         Menu.setApplicationMenu(null)
       }
       installMediaPermissions()
    +  registerMediaProtocol()
       ensureWslWindowsFonts()
       createWindow()
     
    ```
  - `apps/desktop/src/app/chat/index.tsx`

    ```diff
    @@ -97,7 +97,7 @@ function ChatHeader({
       const sessions = useStore($sessions)
       const pinnedSessionIds = useStore($pinnedSessionIds)
       const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
    -  const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
    +  const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
       const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
     
       return (
    ```
  - `apps/desktop/src/app/chat/sidebar/index.tsx`

    ```diff
    @@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
     const VIRTUALIZE_THRESHOLD = 25
     
     const SIDEBAR_NAV: SidebarNavItem[] = [
    -  { id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
    +  {
    +    id: 'new-session',
    +    label: 'New session',
    +    icon: props => <Codicon name="robot" {...props} />,
    +    action: 'new-session'
    +  },
       { id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
       { id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
       { id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
    ```

    ```diff
    @@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
       onLoadMoreSessions: () => void
       onResumeSession: (sessionId: string) => void
       onDeleteSession: (sessionId: string) => void
    +  onArchiveSession: (sessionId: string) => void
    +  onNewSessionInWorkspace: (path: null | string) => void
     }
     
     export function ChatSidebar({
    ```

    ```diff
    @@ -156,7 +163,9 @@ export function ChatSidebar({
       onNavigate,
       onLoadMoreSessions,
       onResumeSession,
    -  onDeleteSession
    +  onDeleteSession,
    +  onArchiveSession,
    +  onNewSessionInWorkspace
     }: ChatSidebarProps) {
       const sidebarOpen = useStore($sidebarOpen)
       const agentsGrouped = useStore($sidebarAgentsGrouped)
    ```

    ```diff
    @@ -328,6 +337,7 @@ export function ChatSidebar({
                 dndSensors={dndSensors}
                 emptyState={<SidebarPinnedEmptyState />}
                 label="Pinned"
    +            onArchiveSession={onArchiveSession}
                 onDeleteSession={onDeleteSession}
                 onReorder={handlePinnedDragEnd}
                 onResumeSession={onResumeSession}
    ```

    ```diff
    @@ -361,9 +371,9 @@ export function ChatSidebar({
                 groups={agentsGrouped ? agentGroups : undefined}
                 headerAction={
                   <Button
    -                aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
    +                aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
                     className={cn(
    -                  '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',
    +                  'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
                       agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
                     )}
                     onClick={event => {
    ```

    ```diff
    @@ -372,15 +382,17 @@ export function ChatSidebar({
                       setSidebarAgentsGrouped(!agentsGrouped)
                     }}
                     size="icon-xs"
    -                title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
    +                title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
                     variant="ghost"
                   >
                     <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
                   </Button>
                 }
    -            label="Agents"
    +            label="Sessions"
                 labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
    +            onArchiveSession={onArchiveSession}
                 onDeleteSession={onDeleteSession}
    +            onNewSessionInWorkspace={onNewSessionInWorkspace}
                 onReorder={handleAgentDragEnd}
                 onResumeSession={onResumeSession}
                 onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
    ```

    ```diff
    @@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps {
       workingSessionIdSet: Set<string>
       onResumeSession: (sessionId: string) => void
       onDeleteSession: (sessionId: string) => void
    +  onArchiveSession: (sessionId: string) => void
       onTogglePin: (sessionId: string) => void
    +  onNewSessionInWorkspace?: (path: null | string) => void
       pinned: boolean
       rootClassName?: string
       contentClassName?: string
    ```

    ```diff
    @@ -496,7 +510,9 @@ function SidebarSessionsSection({
       workingSessionIdSet,
       onResumeSession,
       onDeleteSession,
    +  onArchiveSession,
       onTogglePin,
    +  onNewSessionInWorkspace,
       pinned,
       rootClassName,
       contentClassName,
    ```

    ```diff
    @@ -518,6 +534,7 @@ function SidebarSessionsSection({
           isPinned: pinned,
           isSelected: session.id === activeSessionId,
           isWorking: workingSessionIdSet.has(session.id),
    +      onArchive: () => onArchiveSession(session.id),
           onDelete: () => onDeleteSession(session.id),
           onPin: () => onTogglePin(session.id),
           onResume: () => onResumeSession(session.id),
    ```

    ```diff
    @@ -551,9 +568,19 @@ function SidebarSessionsSection({
       } else if (groups?.length) {
         const groupNodes = groups.map(group =>
           dndActive ? (
    -        <SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
    +        <SortableSidebarWorkspaceGroup
    +          group={group}
    +          key={group.id}
    +          onNewSession={onNewSessionInWorkspace}
    +          renderRows={renderSessionList}
    +        />
           ) : (
    -        <SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
    +        <SidebarWorkspaceGroup
    +          group={group}
    +          key={group.id}
    +          onNewSession={onNewSessionInWorkspace}
    +          renderRows={renderSessionList}
    +        />
           )
         )
     
    ```

    ```diff
    @@ -568,6 +595,7 @@ function SidebarSessionsSection({
         inner = (
           <VirtualSessionList
             activeSessionId={activeSessionId}
    +        onArchiveSession={onArchiveSession}
             onDeleteSession={onDeleteSession}
             onResumeSession={onResumeSession}
             onTogglePin={onTogglePin}
    ```

    ```diff
    @@ -610,6 +638,7 @@ function SidebarSessionsSection({
     interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
       group: SidebarSessionGroup
       renderRows: (sessions: SessionInfo[]) => React.ReactNode
    +  onNewSession?: (path: null | string) => void
       reorderable?: boolean
       dragging?: boolean
       dragHandleProps?: React.HTMLAttributes<HTMLElement>
    ```

    ```diff
    @@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
     function SidebarWorkspaceGroup({
       group,
       renderRows,
    +  onNewSession,
       reorderable = false,
       dragging = false,
       dragHandleProps,
    ```

    ```diff
    @@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({
     
       return (
         <div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
    -      <button
    -        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)"
    -        onClick={() => setOpen(value => !value)}
    -        title={group.path ?? undefined}
    -        type="button"
    -      >
    -        <span className="truncate">{group.label}</span>
    -        <SidebarCount>{group.sessions.length}</SidebarCount>
    -        <DisclosureCaret
    -          className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
    -          open={open}
    -        />
    +      <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)">
    +        <button
    +          className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
    +          onClick={() => setOpen(value => !value)}
    +          title={group.path ?? undefined}
    +          type="button"
    +        >
    +          <span className="truncate">{group.label}</span>
    +          <SidebarCount>{group.sessions.length}</SidebarCount>
    +          <DisclosureCaret
    +            className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
    +            open={open}
    +          />
    +        </button>
    +        {onNewSession && (
    +          <button
    +            aria-label={`New session in ${group.label}`}
    +            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"
    +            onClick={() => onNewSession(group.path)}
    +            title={`New session in ${group.label}`}
    +            type="button"
    +          >
    +            <Codicon name="add" size="0.75rem" />
    +          </button>
    +        )}
             {reorderable && (
               <span
                 {...dragHandleProps}
    ```

    ```diff
    @@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({
                 />
               </span>
             )}
    -      </button>
    +      </div>
           {open && (
             <>
               {renderRows(visibleSessions)}
    ```

    ```diff
    @@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({
     interface SortableWorkspaceProps {
       group: SidebarSessionGroup
       renderRows: (sessions: SessionInfo[]) => React.ReactNode
    +  onNewSession?: (path: null | string) => void
     }
     
     function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
    ```

    ```diff
    @@ -702,6 +746,7 @@ interface SortableSessionRowProps {
       isPinned: boolean
       isSelected: boolean
       isWorking: boolean
    +  onArchive: () => void
       onDelete: () => void
       onPin: () => void
       onResume: () => void
    ```
  - `apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx`

    ```diff
    @@ -26,6 +26,7 @@ interface SessionActions {
       title: string
       pinned?: boolean
       onPin?: () => void
    +  onArchive?: () => void
       onDelete?: () => void
     }
     
    ```

    ```diff
    @@ -40,7 +41,7 @@ interface ItemSpec {
       variant?: 'destructive'
     }
     
    -function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
    +function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
       const [renameOpen, setRenameOpen] = useState(false)
     
       const items: ItemSpec[] = [
    ```

    ```diff
    @@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
             setRenameOpen(true)
           }
         },
    +    {
    +      disabled: !onArchive,
    +      icon: 'archive',
    +      label: 'Archive',
    +      onSelect: () => {
    +        triggerHaptic('selection')
    +        onArchive?.()
    +      }
    +    },
         {
           className: 'text-destructive focus:text-destructive',
           disabled: !onDelete,
    ```
  - `apps/desktop/src/app/chat/sidebar/session-row.tsx`

    ```diff
    @@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
       isPinned: boolean
       isSelected: boolean
       isWorking: boolean
    +  onArchive: () => void
       onDelete: () => void
       onPin: () => void
       onResume: () => void
    ```

    ```diff
    @@ -45,6 +46,7 @@ export function SidebarSessionRow({
       isPinned,
       isSelected,
       isWorking,
    +  onArchive,
       onDelete,
       onPin,
       onResume,
    ```

    ```diff
    @@ -61,7 +63,14 @@ export function SidebarSessionRow({
       const handleLabel = `Reorder ${title}`
     
       return (
    -    <SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
    +    <SessionContextMenu
    +      onArchive={onArchive}
    +      onDelete={onDelete}
    +      onPin={onPin}
    +      pinned={isPinned}
    +      sessionId={session.id}
    +      title={title}
    +    >
           <div
             className={cn(
               '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',
    ```

    ```diff
    @@ -88,6 +97,15 @@ export function SidebarSessionRow({
                   return
                 }
     
    +            if (event.metaKey || event.ctrlKey) {
    +              event.preventDefault()
    +              event.stopPropagation()
    +              triggerHaptic('selection')
    +              onArchive()
    +
    +              return
    +            }
    +
                 onResume()
               }}
               type="button"
    ```

    ```diff
    @@ -127,7 +145,14 @@ export function SidebarSessionRow({
                   {age}
                 </span>
               )}
    -          <SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
    +          <SessionActionsMenu
    +            onArchive={onArchive}
    +            onDelete={onDelete}
    +            onPin={onPin}
    +            pinned={isPinned}
    +            sessionId={session.id}
    +            title={title}
    +          >
                 <Button
                   aria-label={`Actions for ${title}`}
                   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!"
    ```
  - `apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx`

    ```diff
    @@ -12,6 +12,7 @@ interface SessionRowCommonProps {
       isPinned: boolean
       isSelected: boolean
       isWorking: boolean
    +  onArchive: () => void
       onDelete: () => void
       onPin: () => void
       onResume: () => void
    ```

    ```diff
    @@ -20,6 +21,7 @@ interface SessionRowCommonProps {
     interface VirtualSessionListProps {
       activeSessionId: null | string
       className?: string
    +  onArchiveSession: (sessionId: string) => void
       onDeleteSession: (sessionId: string) => void
       onResumeSession: (sessionId: string) => void
       onTogglePin: (sessionId: string) => void
    ```

    ```diff
    @@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12
     export const VirtualSessionList: FC<VirtualSessionListProps> = ({
       activeSessionId,
       className,
    +  onArchiveSession,
       onDeleteSession,
       onResumeSession,
       onTogglePin,
    ```

    ```diff
    @@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
           isPinned: pinned,
           isSelected: session.id === activeSessionId,
           isWorking: workingSessionIdSet.has(session.id),
    +      onArchive: () => onArchiveSession(session.id),
           onDelete: () => onDeleteSession(session.id),
           onPin: () => onTogglePin(session.id),
           onResume: () => onResumeSession(session.id)
    ```
  - `apps/desktop/src/app/command-center/index.tsx`

    ```diff
    @@ -113,7 +113,7 @@ interface SectionSearchEntry {
     }
     
     const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
    -  { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
    +  { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
       { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
       { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
       {
    ```
  - `apps/desktop/src/app/desktop-controller.tsx`

    ```diff
    @@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
     import { BootFailureOverlay } from '@/components/boot-failure-overlay'
     import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
     import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
    +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
     import { Pane, PaneMain } from '@/components/pane-shell'
     import { useSkinCommand } from '@/themes/use-skin-command'
     
    ```

    ```diff
    @@ -33,6 +34,8 @@ import {
       $selectedStoredSessionId,
       setAwaitingResponse,
       setBusy,
    +  setCurrentBranch,
    +  setCurrentCwd,
       setCurrentModel,
       setCurrentProvider,
       setMessages,
    ```

    ```diff
    @@ -122,6 +125,7 @@ export function DesktopController() {
         settingsOpen,
         toggleCommandCenter
       } = useOverlayRouting()
    +
       const terminalTakeoverActive = chatOpen && terminalTakeover
     
       const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
    ```

    ```diff
    @@ -192,7 +196,10 @@ export function DesktopController() {
     
         try {
           const limit = $sessionsLimit.get()
    -      const result = await listSessions(limit)
    +      // Require at least one message so abandoned/empty "Untitled" drafts (one
    +      // was created per TUI/desktop launch before the lazy-create fix) don't
    +      // clutter the sidebar.
    +      const result = await listSessions(limit, 1)
     
           if (refreshSessionsRequestRef.current === requestId) {
             setSessions(result.sessions)
    ```

    ```diff
    @@ -324,6 +331,7 @@ export function DesktopController() {
       })
     
       const {
    +    archiveSession,
         branchCurrentSession,
         createBackendSessionForSend,
         openSettings,
    ```

    ```diff
    @@ -392,6 +400,29 @@ export function DesktopController() {
         [branchCurrentSession, refreshSessions]
       )
     
    +  const startSessionInWorkspace = useCallback(
    +    (path: null | string) => {
    +      startFreshSessionDraft()
    +
    +      const target = path?.trim()
    +
    +      if (!target) {
    +        return
    +      }
    +
    +      // The next message creates the backend session in $currentCwd, so seed
    +      // it (and the branch) from the workspace the user clicked the + on.
    +      setCurrentCwd(target)
    +      void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
    +        .then(info => {
    +          setCurrentCwd(info.cwd || target)
    +          setCurrentBranch(info.branch || '')
    +        })
    +        .catch(() => undefined)
    +    },
    +    [requestGateway, startFreshSessionDraft]
    +  )
    +
       const handleSkinCommand = useSkinCommand()
     
       const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
    ```

    ```diff
    @@ -461,9 +492,11 @@ export function DesktopController() {
       const sidebar = (
         <ChatSidebar
           currentView={currentView}
    +      onArchiveSession={sessionId => void archiveSession(sessionId)}
           onDeleteSession={sessionId => void removeSession(sessionId)}
           onLoadMoreSessions={loadMoreSessions}
           onNavigate={selectSidebarItem}
    +      onNewSessionInWorkspace={startSessionInWorkspace}
           onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
         />
       )
    ```

    ```diff
    @@ -485,6 +518,7 @@ export function DesktopController() {
           />
           <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
           <UpdatesOverlay />
    +      <GatewayConnectingOverlay />
           <BootFailureOverlay />
     
           {settingsOpen && (
    ```

    ```diff
    @@ -575,10 +609,10 @@ export function DesktopController() {
           titlebarTools={titlebarToolGroups.flat.right}
         >
           <Pane
    +        disabled={terminalTakeoverActive}
             id="chat-sidebar"
             maxWidth={SIDEBAR_MAX_WIDTH}
             minWidth={SIDEBAR_DEFAULT_WIDTH}
    -        disabled={terminalTakeoverActive}
             resizable
             side="left"
             width={`${SIDEBAR_DEFAULT_WIDTH}px`}
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions.ts`

    ```diff
    @@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
     import { useCallback, useRef } from 'react'
     import type { NavigateFunction } from 'react-router-dom'
     
    -import { deleteSession, getSessionMessages } from '@/hermes'
    +import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
     import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
     import { normalizePersonalityValue } from '@/lib/chat-runtime'
     import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
    ```

    ```diff
    @@ -751,7 +751,39 @@ export function useSessionActions({
         ]
       )
     
    +  const archiveSession = useCallback(
    +    async (storedSessionId: string) => {
    +      clearNotifications()
    +
    +      const archived = $sessions.get().find(s => s.id === storedSessionId)
    +      const wasSelected = selectedStoredSessionId === storedSessionId
    +      const previousPinned = $pinnedSessionIds.get()
    +
    +      // Soft-hide: drop from the sidebar immediately, keep the data.
    +      setSessions(prev => prev.filter(s => s.id !== storedSessionId))
    +      $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
    +
    +      if (wasSelected) {
    +        startFreshSessionDraft(true)
    +      }
    +
    +      try {
    +        await setSessionArchived(storedSessionId, true)
    +        notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
    +      } catch (err) {
    +        if (archived) {
    +          setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
    +        }
    +
    +        $pinnedSessionIds.set(previousPinned)
    +        notifyError(err, 'Archive failed')
    +      }
    +    },
    +    [selectedStoredSessionId, startFreshSessionDraft]
    +  )
    +
       return {
    +    archiveSession,
         branchCurrentSession,
         closeSettings,
         createBackendSessionForSend,
    ```
  - `apps/desktop/src/app/settings/constants.ts`

    ```diff
    @@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
       { id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
     ]
     
    -export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
    +export const SEARCH_PLACEHOLDER: Record<
    +  'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
    +  string
    +> = {
       about: 'About Hermes Desktop',
       config: 'Search settings...',
       gateway: 'Gateway connection...',
       keys: 'Search API keys...',
       mcp: 'Search MCP servers...',
    +  sessions: 'Search archived sessions...',
       tools: 'Search skills and tools...'
     }
    ```
  - `apps/desktop/src/app/settings/index.tsx`

    ```diff
    @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
     
     import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
     import { triggerHaptic } from '@/lib/haptics'
    -import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
    +import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
     import { notifyError } from '@/store/notifications'
     
     import { useRouteEnumParam } from '../hooks/use-route-enum-param'
    ```

    ```diff
    @@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
     import { GatewaySettings } from './gateway-settings'
     import { KeysSettings } from './keys-settings'
     import { McpSettings } from './mcp-settings'
    +import { SessionsSettings } from './sessions-settings'
     import { ToolsSettings } from './tools-settings'
     import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
     
    ```

    ```diff
    @@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
       'gateway',
       'keys',
       'mcp',
    +  'sessions',
       'tools',
       'about'
     ]
    ```

    ```diff
    @@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
         gateway: '',
         keys: '',
         mcp: '',
    +    sessions: '',
         tools: ''
       })
     
    ```

    ```diff
    @@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
                 label="MCP"
                 onClick={() => setActiveView('mcp')}
               />
    +          <OverlayNavItem
    +            active={activeView === 'sessions'}
    +            icon={Archive}
    +            label="Archived Chats"
    +            onClick={() => setActiveView('sessions')}
    +          />
               <div className="my-2 h-px bg-border/30" />
               <OverlayNavItem
                 active={activeView === 'about'}
    ```

    ```diff
    @@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
                 <KeysSettings query={queries.keys} />
               ) : activeView === 'mcp' ? (
                 <McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
    +          ) : activeView === 'sessions' ? (
    +            <SessionsSettings query={queries.sessions} />
               ) : (
                 <ToolsSettings query={queries.tools} />
               )}
    ```
  - `apps/desktop/src/app/settings/sessions-settings.tsx`

    ```diff
    @@ -0,0 +1,168 @@
    +import { useCallback, useEffect, useMemo, useState } from 'react'
    +
    +import { Button } from '@/components/ui/button'
    +import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
    +import { sessionTitle } from '@/lib/chat-runtime'
    +import { triggerHaptic } from '@/lib/haptics'
    +import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
    +import { notify, notifyError } from '@/store/notifications'
    +import { setSessions } from '@/store/session'
    +import type { SessionInfo } from '@/types/hermes'
    +
    +import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
    +import type { SearchProps } from './types'
    +
    +const ARCHIVED_FETCH_LIMIT = 200
    +
    +function workspaceLabel(cwd: null | string | undefined): string {
    +  const path = cwd?.trim()
    +
    +  if (!path) {
    +    return ''
    +  }
    +
    +  return (
    +    path
    +      .replace(/[/\\]+$/, '')
    +      .split(/[/\\]/)
    +      .filter(Boolean)
    +      .pop() ?? path
    +  )
    +}
    +
    +export function SessionsSettings({ query }: SearchProps) {
    +  const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
    +  const [loading, setLoading] = useState(true)
    +  const [busyId, setBusyId] = useState<string | null>(null)
    +
    +  const load = useCallback(async () => {
    +    setLoading(true)
    +
    +    try {
    +      const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
    +      setLocalSessions(result.sessions)
    +    } catch (err) {
    +      notifyError(err, 'Could not load archived sessions')
    +    } finally {
    +      setLoading(false)
    +    }
    +  }, [])
    +
    +  useEffect(() => {
    +    void load()
    +  }, [load])
    +
    +  const unarchive = useCallback(async (session: SessionInfo) => {
    +    setBusyId(session.id)
    +
    +    try {
    +      await setSessionArchived(session.id, false)
    +      setLocalSessions(prev => prev.filter(s => s.id !== session.id))
    +      // Surface it again in the sidebar without waiting for a full refresh.
    +      setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
    +      triggerHaptic('selection')
    +      notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
    +    } catch (err) {
    +      notifyError(err, 'Unarchive failed')
    +    } finally {
    +      setBusyId(null)
    +    }
    +  }, [])
    +
    +  const remove = useCallback(async (session: SessionInfo) => {
    +    if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
    +      return
    +    }
    +
    +    setBusyId(session.id)
    +
    +    try {
    +      await deleteSession(session.id)
    +      setLocalSessions(prev => prev.filter(s => s.id !== session.id))
    +      triggerHaptic('warning')
    +    } catch (err) {
    +      notifyError(err, 'Delete failed')
    +    } finally {
    +      setBusyId(null)
    +    }
    +  }, [])
    +
    +  const filtered = useMemo(() => {
    +    const needle = query.trim().toLowerCase()
    +
    +    if (!needle) {
    +      return sessions
    +    }
    +
    +    return sessions.filter(session =>
    +      [sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
    +    )
    +  }, [query, sessions])
    +
    +  if (loading) {
    +    return <LoadingState label="Loading archived sessions…" />
    +  }
    +
    +  return (
    +    <SettingsContent>
    +      <SectionHeading
    +        icon={Archive}
    +        meta={sessions.length ? String(sessions.length) : undefined}
    +        title="Archived sessions"
    +      />
    +      <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
    +        Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
    +        archive it.
    +      </p>
    +
    +      {filtered.length === 0 ? (
    +        <EmptyState
    +          description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
    +          title="Nothing archived"
    +        />
    +      ) : (
    +        <div className="divide-y divide-border/30">
    +          {filtered.map(session => {
    +            const label = workspaceLabel(session.cwd)
    +            const busy = busyId === session.id
    +
    +            return (
    +              <ListRow
    +                action={
    +                  <div className="flex items-center gap-1.5">
    +                    <Button
    +                      disabled={busy}
    +                      onClick={() => void unarchive(session)}
    +                      size="sm"
    +                      type="button"
    +                      variant="outline"
    +                    >
    +                      {busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
    +                      <span>Unarchive</span>
    +                    </Button>
    +                    <Button
    +                      aria-label="Delete permanently"
    +                      className="text-muted-foreground hover:text-destructive"
    +                      disabled={busy}
    +                      onClick={() => void remove(session)}
    +                      size="icon"
    +                      title="Delete permanently"
    +                      type="button"
    +                      variant="ghost"
    +                    >
    +                      <Trash2 className="size-3.5" />
    +                    </Button>
    +                  </div>
    +                }
    +                description={session.preview || undefined}
    +                hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
    +                key={session.id}
    +                title={sessionTitle(session)}
    +              />
    +            )
    +          })}
    +        </div>
    +      )}
    +    </SettingsContent>
    +  )
    +}
    ```
  - `apps/desktop/src/app/settings/types.ts`

    ```diff
    @@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'
     import type { IconComponent } from '@/lib/icons'
     import type { EnvVarInfo } from '@/types/hermes'
     
    -export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
    -export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
    +export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
    +export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
     export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
     
     export interface SettingsPageProps {
    ```
  - `apps/desktop/src/components/assistant-ui/markdown-text.tsx`

    ```diff
    @@ -19,9 +19,9 @@ import {
       filePathFromMediaPath,
       mediaExternalUrl,
       mediaKind,
    -  mediaMime,
       mediaName,
    -  mediaPathFromMarkdownHref
    +  mediaPathFromMarkdownHref,
    +  mediaStreamUrl
     } from '@/lib/media'
     import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
     import { cn } from '@/lib/utils'
    ```

    ```diff
    @@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
     // LLM convention). The default false-setting only accepts `$$...$$`.
     const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
     
    -async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
    -  const blob = await fetch(dataUrl).then(response => response.blob())
    -
    -  return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
    -}
    -
     async function mediaSrc(path: string): Promise<string> {
       if (/^(?:https?|data):/i.test(path)) {
         return path
       }
     
    +  // Stream audio/video through the custom protocol: data URLs are capped and
    +  // load the whole file into memory, which broke playback for larger videos.
    +  if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
    +    return mediaStreamUrl(path)
    +  }
    +
       if (!window.hermesDesktop?.readFileDataUrl) {
         return mediaExternalUrl(path)
       }
     
    -  const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
    -
    -  return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
    +  return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
     }
     
     function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
    ```

    ```diff
    @@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
       // render, which churns Streamdown's outer memo + propagates new prop
       // identities into every Block. The plugin set really only varies on
       // `isStreaming`, so memoize on that.
    -  const plugins = useMemo(
    -    () => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
    -    [isStreaming]
    -  )
    +  const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
     
       const components = useMemo(
         () =>
    ```
  - `apps/desktop/src/components/gateway-connecting-overlay.tsx`

    ```diff
    @@ -0,0 +1,183 @@
    +import { useStore } from '@nanostores/react'
    +import { useEffect, useRef, useState } from 'react'
    +
    +import { cn } from '@/lib/utils'
    +import { $desktopBoot } from '@/store/boot'
    +import { $gatewayState } from '@/store/session'
    +
    +// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
    +// the render level means no timer logic (even a stale HMR one) can ever
    +// scramble "CONN".
    +const PREFIX = 'CONN'
    +const TAIL = 'ECTING'
    +// Even-weight mono ascii so cycling glyphs don't jump width (matches the
    +// nousnet-web download-button decode effect).
    +const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
    +const TICK_MS = 45
    +
    +// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
    +const TEXT_OUT_MS = 360
    +const POST_TEXT_HOLD_MS = 300
    +const OVERLAY_OUT_MS = 520
    +// Preview-only: how long to "connect" for, and the pause before replaying.
    +const PREVIEW_CONNECT_MS = 2600
    +const PREVIEW_REPLAY_MS = 1100
    +
    +type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
    +
    +// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
    +// only flashes. Load with `?connecting=1` to force a looping preview.
    +function forcedPreview(): boolean {
    +  if (!import.meta.env.DEV || typeof window === 'undefined') {
    +    return false
    +  }
    +
    +  try {
    +    return new URLSearchParams(window.location.search).get('connecting') === '1'
    +  } catch {
    +    return false
    +  }
    +}
    +
    +function scrambledTail(resolvedCount: number): string {
    +  return Array.from(TAIL, (ch, i) =>
    +    i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
    +  ).join('')
    +}
    +
    +export function GatewayConnectingOverlay() {
    +  const gatewayState = useStore($gatewayState)
    +  const boot = useStore($desktopBoot)
    +  const [previewing] = useState(forcedPreview)
    +  const [tail, setTail] = useState(TAIL)
    +  const [phase, setPhase] = useState<Phase>('live')
    +
    +  const connecting = gatewayState !== 'open' && !boot.error
    +  // Latches once we've actually shown the overlay, so the brief frame where
    +  // gatewayState flips to "open" (connecting -> false) before the exit phase
    +  // kicks in doesn't unmount us and cause a flash.
    +  const shownRef = useRef(false)
    +
    +  if (previewing || connecting) {
    +    shownRef.current = true
    +  }
    +
    +  // Decode loop — only while live (freeze the resolved word during the exit).
    +  useEffect(() => {
    +    if (phase !== 'live' || (!previewing && !connecting)) {
    +      return
    +    }
    +
    +    let resolved = 0
    +    let hold = 0
    +
    +    const id = window.setInterval(() => {
    +      if (resolved >= TAIL.length) {
    +        hold += 1
    +
    +        if (hold > 16) {
    +          resolved = 0
    +          hold = 0
    +        }
    +
    +        setTail(TAIL)
    +
    +        return
    +      }
    +
    +      resolved += 0.5
    +      setTail(scrambledTail(Math.floor(resolved)))
    +    }, TICK_MS)
    +
    +    return () => window.clearInterval(id)
    +  }, [phase, previewing, connecting])
    +
    +  // Kick off the exit when connected: real connect, or a faked timer in preview.
    +  useEffect(() => {
    +    if (phase !== 'live') {
    +      return
    +    }
    +
    +    if (previewing) {
    +      const id = window.setTimeout(() => {
    +        setTail(TAIL)
    +        setPhase('text-out')
    +      }, PREVIEW_CONNECT_MS)
    +
    +      return () => window.clearTimeout(id)
    +    }
    +
    +    if (gatewayState === 'open' && shownRef.current) {
    +      setTail(TAIL)
    +      setPhase('text-out')
    +    }
    +  }, [phase, previewing, gatewayState])
    +
    +  // Advance the exit choreography: text-out -> overlay-out -> gone.
    +  useEffect(() => {
    +    if (phase === 'text-out') {
    +      const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
    +
    +      return () => window.clearTimeout(id)
    +    }
    +
    +    if (phase === 'overlay-out') {
    +      const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
    +
    +      return () => window.clearTimeout(id)
    +    }
    +
    +    // Preview replays so we can keep watching the transition.
    +    if (phase === 'gone' && previewing) {
    +      const id = window.setTimeout(() => {
    +        setTail(TAIL)
    +        setPhase('live')
    +      }, PREVIEW_REPLAY_MS)
    +
    +      return () => window.clearTimeout(id)
    +    }
    +  }, [phase, previewing])
    +
    +  // Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
    +  if (boot.error && !previewing) {
    +    return null
    +  }
    +
    +  // Real connect: once the fade finishes, get out of the way for good.
    +  if (phase === 'gone' && !previewing) {
    +    return null
    +  }
    +
    +  // Never showed (e.g. gateway already up on a warm reload) — stay out.
    +  if (!previewing && !connecting && !shownRef.current) {
    +    return null
    +  }
    +
    +  const leaving = phase !== 'live'
    +  const overlayHidden = phase === 'overlay-out' || phase === 'gone'
    +
    +  return (
    +    <div
    +      className={cn(
    +        'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
    +        overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
    +      )}
    +    >
    +      <style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
    +      <span
    +        className={cn(
    +          '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',
    +          leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
    +        )}
    +      >
    +        {PREFIX}
    +        {tail}
    +        <span
    +          aria-hidden="true"
    +          className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
    +          style={{ animation: 'gco-cursor 1s step-end infinite' }}
    +        />
    +      </span>
    +    </div>
    +  )
    +}
    ```
  - `apps/desktop/src/hermes.ts`

    ```diff
    @@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient {
       }
     }
     
    -export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
    +export async function listSessions(
    +  limit = 40,
    +  minMessages = 0,
    +  archived: 'exclude' | 'include' | 'only' = 'exclude'
    +): Promise<PaginatedSessions> {
       const result = await window.hermesDesktop.api<PaginatedSessions>({
    -    path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
    +    path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
       })
     
       return {
    ```

    ```diff
    @@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
       }
     }
     
    +export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
    +  return window.hermesDesktop.api<{ ok: boolean }>({
    +    path: `/api/sessions/${encodeURIComponent(id)}`,
    +    method: 'PATCH',
    +    body: { archived }
    +  })
    +}
    +
     export function searchSessions(query: string): Promise<SessionSearchResponse> {
       return window.hermesDesktop.api<SessionSearchResponse>({
         path: `/api/sessions/search?q=${encodeURIComponent(query)}`
    ```
  - `apps/desktop/src/lib/icons.ts`

    ```diff
    @@ -2,6 +2,8 @@ import {
       IconActivity as Activity,
       IconAlertCircle as AlertCircle,
       IconAlertTriangle as AlertTriangle,
    +  IconArchive as Archive,
    +  IconArchiveOff as ArchiveOff,
       IconArrowUp as ArrowUp,
       IconArrowUpRight as ArrowUpRight,
       IconAt as AtSign,
    ```

    ```diff
    @@ -98,6 +100,8 @@ export {
       Activity,
       AlertCircle,
       AlertTriangle,
    +  Archive,
    +  ArchiveOff,
       ArrowUp,
       ArrowUpRight,
       AtSign,
    ```
  - `apps/desktop/src/lib/media.ts`

    ```diff
    @@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
       return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
     }
     
    +// Custom Electron scheme (registered in electron/main.cjs) that streams a local
    +// file with Range support. Used for audio/video so playback bypasses the data
    +// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
    +export function mediaStreamUrl(path: string): string {
    +  return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
    +}
    +
     export function mediaPathFromMarkdownHref(href?: string): string | null {
       if (!href?.startsWith('#media:')) {
         return null
    ```
  - `apps/desktop/src/types/hermes.ts`

    ```diff
    @@ -240,6 +240,7 @@ export interface SessionCreateResponse {
     }
     
     export interface SessionInfo {
    +  archived?: boolean
       cwd?: null | string
       ended_at: null | number
       id: string
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -1131,22 +1131,51 @@ async def get_action_status(name: str, lines: int = 200):
     
     
     @app.get("/api/sessions")
    -async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
    +async def get_sessions(
    +    limit: int = 20,
    +    offset: int = 0,
    +    min_messages: int = 0,
    +    archived: str = "exclude",
    +):
    +    """List sessions.
    +
    +    ``archived`` controls how soft-archived sessions are treated:
    +    ``exclude`` (default) hides them, ``only`` returns just the archived ones
    +    (used by the desktop "Archived sessions" settings panel), and ``include``
    +    returns both.
    +    """
    +    if archived not in ("exclude", "only", "include"):
    +        raise HTTPException(
    +            status_code=400,
    +            detail="archived must be one of: exclude, only, include",
    +        )
         try:
             from hermes_state import SessionDB
             db = SessionDB()
             try:
                 min_message_count = max(0, min_messages)
    +            archived_only = archived == "only"
    +            include_archived = archived == "include"
                 sessions = db.list_sessions_rich(
    -                limit=limit, offset=offset, min_message_count=min_message_count
    +                limit=limit,
    +                offset=offset,
    +                min_message_count=min_message_count,
    +                include_archived=include_archived,
    +                archived_only=archived_only,
    +            )
    +            total = db.session_count(
    +                min_message_count=min_message_count,
    +                include_archived=include_archived,
    +                archived_only=archived_only,
                 )
    -            total = db.session_count(min_message_count=min_message_count)
                 now = time.time()
                 for s in sessions:
                     s["is_active"] = (
                         s.get("ended_at") is None
                         and (now - s.get("last_active", s.get("started_at", 0))) < 300
                     )
    +                # SQLite stores the flag as 0/1; expose a real JSON boolean.
    +                s["archived"] = bool(s.get("archived"))
                 return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
             finally:
                 db.close()
    ```

    ```diff
    @@ -3707,25 +3736,39 @@ async def delete_session_endpoint(session_id: str):
     
     class SessionRename(BaseModel):
         title: Optional[str] = None
    +    archived: Optional[bool] = None
     
     
     @app.patch("/api/sessions/{session_id}")
     async def rename_session_endpoint(session_id: str, body: SessionRename):
    -    """Rename a session (or clear its title when ``title`` is empty/null)."""
    +    """Update a session: rename (or clear its title) and/or archive it.
    +
    +    ``title`` renames (empty/null clears the title); ``archived`` soft-hides or
    +    restores the session. Either field may be omitted.
    +    """
         from hermes_state import SessionDB
         db = SessionDB()
         try:
             sid = db.resolve_session_id(session_id)
             if not sid:
                 raise HTTPException(status_code=404, detail="Session not found")
    -        try:
    -            updated = db.set_session_title(sid, body.title or "")
    -        except ValueError as e:
    -            # Title too long, invalid characters, or already in use.
    -            raise HTTPException(status_code=400, detail=str(e))
    -        if not updated:
    -            raise HTTPException(status_code=404, detail="Session not found")
    -        return {"ok": True, "title": db.get_session_title(sid) or ""}
    +        if body.title is None and body.archived is None:
    +            raise HTTPException(
    +                status_code=400,
    +                detail="Nothing to update; provide 'title' and/or 'archived'.",
    +            )
    +        if body.title is not None:
    +            try:
    +                db.set_session_title(sid, body.title or "")
    +            except ValueError as e:
    +                # Title too long, invalid characters, or already in use.
    +                raise HTTPException(status_code=400, detail=str(e))
    +        if body.archived is not None:
    +            db.set_session_archived(sid, body.archived)
    +        result = {"ok": True, "title": db.get_session_title(sid) or ""}
    +        if body.archived is not None:
    +            result["archived"] = bool(body.archived)
    +        return result
         finally:
             db.close()
     
    ```
  - `hermes_state.py`

    ```diff
    @@ -264,6 +264,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
         handoff_platform TEXT,
         handoff_error TEXT,
         rewind_count INTEGER NOT NULL DEFAULT 0,
    +    archived INTEGER NOT NULL DEFAULT 0,
         FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
     );
     
    ```

    ```diff
    @@ -1430,6 +1431,22 @@ def get_session_title(self, session_id: str) -> Optional[str]:
                 row = cursor.fetchone()
             return row["title"] if row else None
     
    +    def set_session_archived(self, session_id: str, archived: bool) -> bool:
    +        """Archive or unarchive a session.
    +
    +        Archived sessions are hidden from the default session list but keep all
    +        their messages — this is a soft hide, not a delete. Returns True when a
    +        row was updated.
    +        """
    +        def _do(conn):
    +            cursor = conn.execute(
    +                "UPDATE sessions SET archived = ? WHERE id = ?",
    +                (1 if archived else 0, session_id),
    +            )
    +            return cursor.rowcount
    +        rowcount = self._execute_write(_do)
    +        return rowcount > 0
    +
         def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
             """Look up a session by exact title. Returns session dict or None."""
             with self._lock:
    ```

    ```diff
    @@ -1549,6 +1566,8 @@ def list_sessions_rich(
             min_message_count: int = 0,
             project_compression_tips: bool = True,
             order_by_last_active: bool = False,
    +        include_archived: bool = False,
    +        archived_only: bool = False,
         ) -> List[Dict[str, Any]]:
             """List sessions with preview (first user message) and last active timestamp.
     
    ```

    ```diff
    @@ -1604,6 +1623,10 @@ def list_sessions_rich(
             if min_message_count > 0:
                 where_clauses.append("s.message_count >= ?")
                 params.append(min_message_count)
    +        if archived_only:
    +            where_clauses.append("s.archived = 1")
    +        elif not include_archived:
    +            where_clauses.append("s.archived = 0")
     
             where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
             if order_by_last_active:
    ```

    ```diff
    @@ -3027,7 +3050,13 @@ def search_sessions(
         # Utility
         # =========================================================================
     
    -    def session_count(self, source: str = None, min_message_count: int = 0) -> int:
    +    def session_count(
    +        self,
    +        source: str = None,
    +        min_message_count: int = 0,
    +        include_archived: bool = False,
    +        archived_only: bool = False,
    +    ) -> int:
             """Count sessions, optionally filtered by source."""
             where_clauses = []
             params = []
    ```

    ```diff
    @@ -3038,6 +3067,10 @@ def session_count(self, source: str = None, min_message_count: int = 0) -> int:
             if min_message_count > 0:
                 where_clauses.append("message_count >= ?")
                 params.append(min_message_count)
    +        if archived_only:
    +            where_clauses.append("archived = 1")
    +        elif not include_archived:
    +            where_clauses.append("archived = 0")
     
             where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
     
    ```
  - `tests/hermes_cli/test_web_server.py`

    ```diff
    @@ -187,11 +187,11 @@ class _FakeDB:
                 def __init__(self, *args, **kwargs):
                     pass
     
    -            def list_sessions_rich(self, limit, offset, min_message_count=0):
    +            def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
                     captured["list"] = min_message_count
                     return []
     
    -            def session_count(self, min_message_count=0):
    +            def session_count(self, min_message_count=0, **kwargs):
                     captured["count"] = min_message_count
                     return 0
     
    ```

    ```diff
    @@ -250,6 +250,76 @@ def test_rename_session_not_found(self):
             resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
             assert resp.status_code == 404
     
    +    def test_archive_session_via_patch(self):
    +        """PATCH archived=true soft-hides a session; archived=false restores it."""
    +        from hermes_state import SessionDB
    +
    +        db = SessionDB()
    +        try:
    +            db.create_session(session_id="arch-me", source="cli")
    +            db.append_message(session_id="arch-me", role="user", content="hi")
    +        finally:
    +            db.close()
    +
    +        resp = self.client.patch("/api/sessions/arch-me", json={"archived": True})
    +        assert resp.status_code == 200
    +        assert resp.json()["archived"] is True
    +
    +        # Hidden from the default list, surfaced by archived=only.
    +        listed = self.client.get("/api/sessions").json()
    +        assert all(s["id"] != "arch-me" for s in listed["sessions"])
    +        only = self.client.get("/api/sessions?archived=only").json()
    +        assert any(s["id"] == "arch-me" for s in only["sessions"])
    +
    +        resp = self.client.patch("/api/sessions/arch-me", json={"archived": False})
    +        assert resp.status_code == 200
    +        restored = self.client.get("/api/sessions").json()
    +        assert any(s["id"] == "arch-me" for s in restored["sessions"])
    +
    +    def test_patch_session_without_fields_is_400(self):
    +        """An existing session + empty body is a bad request, not a 404."""
    +        from hermes_state import SessionDB
    +
    +        db = SessionDB()
    +        try:
    +            db.create_session(session_id="no-fields", source="cli")
    +        finally:
    +            db.close()
    +
    +        resp = self.client.patch("/api/sessions/no-fields", json={})
    +        assert resp.status_code == 400
    +
    +    def test_get_sessions_rejects_unknown_archived_value(self):
    +        resp = self.client.get("/api/sessions?archived=bogus")
    +        assert resp.status_code == 400
    +
    +    def test_get_sessions_archived_is_boolean(self):
    +        from hermes_state import SessionDB
    +
    +        db = SessionDB()
    +        try:
    +            db.create_session(session_id="bool-arch", source="cli")
    +            db.append_message(session_id="bool-arch", role="user", content="hi")
    +        finally:
    +            db.close()
    +
    +        row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch")
    +        assert row["archived"] is False
    +
    +    def test_rename_response_omits_archived_when_not_set(self):
    +        """Title-only PATCH keeps its legacy {ok, title} response shape."""
    +        from hermes_state import SessionDB
    +
    +        db = SessionDB()
    +        try:
    +            db.create_session(session_id="title-only", source="cli")
    +        finally:
    +            db.close()
    +
    +        resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"})
    +        assert resp.status_code == 200
    +        assert "archived" not in resp.json()
    +
         def test_audio_transcription_endpoint(self, monkeypatch):
             import tools.transcription_tools as transcription_tools
     
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -3509,3 +3509,43 @@ def execute(self, sql, params=()):
             assert any("journal_mode=WAL" in sql for sql in conn.executed), (
                 "set-pragma must fire when probe returns 'delete'"
             )
    +
    +
    +class TestSessionArchive:
    +    """Soft-archiving hides a session from default listings without deleting it."""
    +
    +    def _seed(self, db, sid, *, archived=False):
    +        db.create_session(session_id=sid, source="cli")
    +        db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
    +        if archived:
    +            db.set_session_archived(sid, True)
    +
    +    def test_set_session_archived_roundtrip(self, db):
    +        self._seed(db, "s1")
    +        assert db.set_session_archived("s1", True) is True
    +        assert db.get_session("s1")["archived"] == 1
    +        assert db.set_session_archived("s1", False) is True
    +        assert db.get_session("s1")["archived"] == 0
    +
    +    def test_set_session_archived_missing_row(self, db):
    +        assert db.set_session_archived("nope", True) is False
    +
    +    def test_archived_excluded_by_default(self, db):
    +        self._seed(db, "live")
    +        self._seed(db, "hidden", archived=True)
    +
    +        ids = [s["id"] for s in db.list_sessions_rich()]
    +        assert ids == ["live"]
    +        assert db.session_count() == 1
    +
    +    def test_archived_only_and_include(self, db):
    +        self._seed(db, "live")
    +        self._seed(db, "hidden", archived=True)
    +
    +        only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
    +        assert only == ["hidden"]
    +        assert db.session_count(archived_only=True) == 1
    +
    +        both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
    +        assert both == {"live", "hidden"}
    +        assert db.session_count(include_archived=True) == 2
    ```
  - `tests/test_tui_gateway_server.py`

    ```diff
    @@ -884,6 +884,73 @@ def set_session_title(self, _key, _title):
             server._sessions.pop("sid", None)
     
     
    +def test_notification_event_routing_by_session_key(monkeypatch):
    +    """Background-process events surface only in the session that owns them."""
    +    mine = _session(session_key="mine")
    +    other = _session(session_key="other")
    +    monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
    +
    +    # My own event → handle it.
    +    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
    +    # Global/system event with no owner → handle it.
    +    assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
    +    assert server._notification_event_belongs_elsewhere(mine, {}) is False
    +    # Owned by another *live* session → defer to that session's poller.
    +    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
    +    # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
    +    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
    +
    +
    +def test_session_create_does_not_persist_empty_row(monkeypatch):
    +    """session.create must NOT eagerly write a DB row.
    +
    +    Every TUI/desktop launch opens a session here just to paint the composer;
    +    eagerly creating a row left an empty "Untitled" session behind for every
    +    launch the user never typed into. The row is created lazily on first prompt.
    +    """
    +    created = []
    +
    +    class _FakeDB:
    +        def create_session(self, *args, **kwargs):
    +            created.append((args, kwargs))
    +
    +    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    +    monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
    +    monkeypatch.setattr(
    +        server.threading,
    +        "Timer",
    +        lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
    +    )
    +
    +    resp = server.handle_request(
    +        {"id": "1", "method": "session.create", "params": {"cols": 80}}
    +    )
    +    sid = resp["result"]["session_id"]
    +    try:
    +        assert resp["result"]["stored_session_id"]
    +        assert created == [], "session.create should not persist an empty DB row"
    +    finally:
    +        server._sessions.pop(sid, None)
    +
    +
    +def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
    +    """First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
    +    created = []
    +
    +    class _FakeDB:
    +        def create_session(self, key, source=None, model=None, cwd=None):
    +            created.append({"key": key, "source": source, "model": model, "cwd": cwd})
    +
    +    monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
    +    monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
    +
    +    server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
    +
    +    assert created == [
    +        {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
    +    ]
    +
    +
     def test_session_title_clears_pending_after_persist(monkeypatch):
         class _FakeDB:
             def __init__(self):
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -880,6 +880,7 @@ def _move_to_finished(self, session: ProcessSession):
                 self.completion_queue.put({
                     "type": "completion",
                     "session_id": session.id,
    +                "session_key": session.session_key,
                     "command": session.command,
                     "exit_code": session.exit_code,
                     "output": output_tail,
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None:
             pass
     
     
    +def _ensure_session_db_row(session: dict) -> None:
    +    """Idempotently persist the session's DB row on first real activity.
    +
    +    Called from prompt.submit so a row only exists once the user actually sends
    +    a message — abandoned drafts never leave an empty "Untitled" session behind.
    +    Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own
    +    lazy create) are no-ops. Captures cwd up front so workspace grouping works
    +    without waiting for a separate cwd update.
    +    """
    +    key = session.get("session_key")
    +    if not key:
    +        return
    +    db = _get_db()
    +    if db is None:
    +        return
    +    try:
    +        db.create_session(
    +            key,
    +            source="tui",
    +            model=_resolve_model(),
    +            cwd=_session_cwd(session),
    +        )
    +    except Exception:
    +        logger.debug("failed to persist desktop session row", exc_info=True)
    +
    +
     def _set_session_cwd(session: dict, cwd: str) -> str:
         resolved = os.path.abspath(os.path.expanduser(str(cwd)))
         if not os.path.isdir(resolved):
    ```

    ```diff
    @@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict:
             "transport": current_transport() or _stdio_transport,
         }
         _register_session_cwd(_sessions[sid])
    -    db = _get_db()
    -    if db is not None:
    -        try:
    -            db.create_session(
    -                key,
    -                source="tui",
    -                model=_resolve_model(),
    -                cwd=_sessions[sid]["cwd"],
    -            )
    -        except Exception:
    -            logger.debug("failed to pre-create desktop session row", exc_info=True)
    +    # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
    +    # launch (and every "New agent" / draft) opens a session here just to paint
    +    # the composer, so eagerly creating a row left an "Untitled" empty session
    +    # behind for every launch the user never typed into. The row is now created
    +    # lazily on the first prompt (see _ensure_session_db_row + prompt.submit),
    +    # and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too.
     
         # Return the lightweight session immediately so Ink can paint the composer
         # + skeleton panel, then build the real AIAgent just after this response is
    ```

    ```diff
    @@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict:
             session["last_active"] = time.time()
             _start_inflight_turn(session, text)
     
    +    # Persist the DB row lazily, now that the user has actually sent a message.
    +    _ensure_session_db_row(session)
         _start_agent_build(sid, session)
     
         def run_after_agent_ready() -> None:
    ```

    ```diff
    @@ -3865,6 +3888,35 @@ def run_after_agent_ready() -> None:
         return _ok(rid, {"status": "streaming"})
     
     
    +def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
    +    """True if ``evt`` is owned by a *different* live session.
    +
    +    Background-process events carry the ``session_key`` of the session that
    +    started the process. Since all desktop sessions share one process-wide
    +    completion queue, each poller must skip events it doesn't own so a
    +    background job's completion surfaces in the session that launched it — not
    +    whichever poller happened to dequeue first. Orphaned events (owner gone)
    +    and global/system events (empty ``session_key``) return False so the
    +    current poller still handles them rather than losing them.
    +    """
    +    evt_key = str(evt.get("session_key") or "")
    +    if not evt_key:
    +        return False
    +    if evt_key == str(session.get("session_key") or ""):
    +        return False
    +    try:
    +        snapshot = list(_sessions.values())
    +    except Exception:
    +        # If we can't safely enumerate live sessions, fail open so we don't
    +        # crash the poller thread or drop the event.
    +        return False
    +
    +    return any(
    +        s is not session and str(s.get("session_key") or "") == evt_key
    +        for s in snapshot
    +    )
    +
    +
     def _notification_poller_loop(
         stop_event: threading.Event, sid: str, session: dict
     ) -> None:
    ```

    ```diff
    @@ -3887,6 +3939,16 @@ def _notification_poller_loop(
             except Exception:
                 continue
     
    +        # Multiple desktop sessions share this one process-wide queue. Only
    +        # consume events that belong to *this* session — otherwise a background
    +        # process started in session A would surface its completion in whichever
    +        # session's poller happened to wake first (Ben's "reported in a
    +        # different session" bug). Leave foreign events for their owner.
    +        if _notification_event_belongs_elsewhere(session, evt):
    +            process_registry.completion_queue.put(evt)
    +            time.sleep(0.1)
    +            continue
    +
             _evt_sid = evt.get("session_id", "")
             if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
                 continue
    ```

    ```diff
    @@ -3917,12 +3979,17 @@ def _notification_poller_loop(
                     session["running"] = False
     
         # Drain any remaining events after stop signal (process all pending
    -    # before exiting so nothing is lost on shutdown).
    +    # before exiting so nothing is lost on shutdown). Events owned by other
    +    # live sessions are set aside and re-queued so their poller still sees them.
    +    deferred: list = []
         while not process_registry.completion_queue.empty():
             try:
                 evt = process_registry.completion_queue.get_nowait()
             except Exception:
                 break
    +        if _notification_event_belongs_elsewhere(session, evt):
    +            deferred.append(evt)
    +            continue
             _evt_sid = evt.get("session_id", "")
             if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
                 continue
    ```

    ```diff
    @@ -3951,6 +4018,10 @@ def _notification_poller_loop(
                 with session["history_lock"]:
                     session["running"] = False
     
    +    # Hand any other sessions' events back to the shared queue.
    +    for evt in deferred:
    +        process_registry.completion_queue.put(evt)
    +
     
     def _start_notification_poller(sid: str, session: dict) -> threading.Event:
         """Start the background notification poller for a TUI session."""
    ```
- **#54785: fix(tui): route bg process notifications to owning session, drop orphaned events**. closed without merge · fix tier `suggested`
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `35803dd50e0e71e4cfd9504d14f0a1f4fd6d6405`, `569b912d7d`, `#54785`, `#54785`, `#54785`, `#54785`, `#54785`, `#63317`, `#42731`, `#54785`, `#54785`, `#63317`, `#54785`, `#63317`, `#42731`, `#35667`, `#57586`, `#60863`, `#54785`, `#63317`, `#54785`, `#63317`, `#54785`, `#63317`, `#64484`, `#54785`, `#54785`, `#63317`, `#54785`, `#63317`, `#63317`, `#54785`, `#64484`, `#63317`, `#42731`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#60863: fix(delegation): route async results to their origin session across compression (#55578)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/session_context.py`

    ```diff
    @@ -79,6 +79,13 @@ def session_context_engaged() -> bool:
     _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
     _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
     _SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
    +# In-process UI session/window id for multi-session desktop/TUI hosts. This is
    +# intentionally separate from HERMES_SESSION_ID: the latter is the durable
    +# conversation/session-db id, while the UI id is the live frontend tab/window
    +# that commissioned a detached completion. Background completions use it as a
    +# precise return address so a stale/rotated durable session key cannot be
    +# consumed by whichever desktop poller wakes first.
    +_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET)
     # ID of the message that triggered the current turn. Used as a reply anchor
     # so background-process notifications stay inside the originating Telegram
     # private-chat topic (those lanes route only with thread id + reply anchor).
    ```

    ```diff
    @@ -123,6 +130,7 @@ def session_context_engaged() -> bool:
         "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
         "HERMES_SESSION_KEY": _SESSION_KEY,
         "HERMES_SESSION_ID": _SESSION_ID,
    +    "HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID,
         "HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID,
         "HERMES_SESSION_PROFILE": _SESSION_PROFILE,
         "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
    ```

    ```diff
    @@ -160,6 +168,7 @@ def set_session_vars(
         profile: str = "",
         cwd: str = "",
         async_delivery: bool = True,
    +    ui_session_id: str = "",
     ) -> list:
         """Set all session context variables and return reset tokens.
     
    ```

    ```diff
    @@ -191,6 +200,7 @@ def set_session_vars(
             _SESSION_USER_NAME.set(user_name),
             _SESSION_KEY.set(session_key),
             _SESSION_ID.set(session_id),
    +        _SESSION_UI_SESSION_ID.set(ui_session_id),
             _SESSION_MESSAGE_ID.set(message_id),
             _SESSION_PROFILE.set(profile),
             _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),
    ```

    ```diff
    @@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None:
             _SESSION_USER_NAME,
             _SESSION_KEY,
             _SESSION_ID,
    +        _SESSION_UI_SESSION_ID,
             _SESSION_MESSAGE_ID,
             _SESSION_PROFILE,
         ):
    ```
  - `tests/test_tui_gateway_server.py`

    ```diff
    @@ -2112,14 +2112,85 @@ def test_notification_event_routing_by_session_key(monkeypatch):
         monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
     
         # My own event → handle it.
    -    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
    +    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "mine"}) is False
         # Global/system event with no owner → handle it.
    -    assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
    -    assert server._notification_event_belongs_elsewhere(mine, {}) is False
    +    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": ""}) is False
    +    assert server._notification_event_belongs_elsewhere("a", mine, {}) is False
         # Owned by another *live* session → defer to that session's poller.
    -    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
    +    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "other"}) is True
         # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
    -    assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
    +    assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "ghost"}) is False
    +
    +
    +def test_async_delegation_event_prefers_origin_ui_session(monkeypatch):
    +    """Detached subagent completions return to the commissioning TUI tab.
    +
    +    Regression: when the durable session key was stale/orphaned, whichever
    +    desktop poller woke first could consume the async result and inject it into
    +    an unrelated session.
    +    """
    +    mine = _session(session_key="current-key")
    +    other = _session(session_key="unrelated-key")
    +    monkeypatch.setattr(server, "_sessions", {"origin-sid": mine, "other-sid": other})
    +    monkeypatch.setattr(server, "_get_db", lambda: None)
    +    evt = {
    +        "type": "async_delegation",
    +        "session_key": "stale-or-rotated-key",
    +        "origin_ui_session_id": "origin-sid",
    +    }
    +
    +    assert server._notification_event_belongs_elsewhere("other-sid", other, evt) is True
    +    assert server._notification_event_belongs_elsewhere("origin-sid", mine, evt) is False
    +
    +
    +def test_notification_event_follows_compression_continuation(monkeypatch):
    +    """Events keyed to a compressed parent route to the live continuation."""
    +    old_parent = _session(session_key="old-parent")
    +    live_tip = _session(session_key="new-tip")
    +    monkeypatch.setattr(server, "_sessions", {"old-sid": old_parent, "tip-sid": live_tip})
    +
    +    class _DB:
    +        def resolve_resume_session_id(self, session_id):
    +            return "new-tip" if session_id == "old-parent" else session_id
    +
    +    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    +    evt = {"type": "async_delegation", "session_key": "old-parent"}
    +
    +    assert server._notification_event_belongs_elsewhere("old-sid", old_parent, evt) is True
    +    assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False
    +    # A third session must leave it alone for the continuation's poller.
    +    third = _session(session_key="third")
    +    monkeypatch.setattr(
    +        server,
    +        "_sessions",
    +        {"old-sid": old_parent, "tip-sid": live_tip, "third-sid": third},
    +    )
    +    assert server._notification_event_belongs_elsewhere("third-sid", third, evt) is True
    +
    +
    +def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch):
    +    """A closed origin tab must not steal its resumed continuation's result."""
    +    finalized_origin = _session(session_key="old-parent", _finalized=True)
    +    live_tip = _session(session_key="new-tip")
    +    monkeypatch.setattr(
    +        server,
    +        "_sessions",
    +        {"origin-sid": finalized_origin, "tip-sid": live_tip},
    +    )
    +
    +    class _DB:
    +        def resolve_resume_session_id(self, session_id):
    +            return "new-tip" if session_id == "old-parent" else session_id
    +
    +    monkeypatch.setattr(server, "_get_db", lambda: _DB())
    +    evt = {
    +        "type": "async_delegation",
    +        "session_key": "old-parent",
    +        "origin_ui_session_id": "origin-sid",
    +    }
    +
    +    assert server._notification_event_belongs_elsewhere("origin-sid", finalized_origin, evt) is True
    +    assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False
     
     
     def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch):
    ```
  - `tests/tools/test_async_delegation.py`

    ```diff
    @@ -289,6 +289,68 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):
         assert "the real task" in text
     
     
    +def test_delegate_task_background_uses_live_tui_agent_session_id(monkeypatch):
    +    """TUI async delegation must route to the live/compressed agent id.
    +
    +    Regression: delegate_task captured the stale approval/session context key
    +    after compression rotated parent_agent.session_id. The resulting completion
    +    was orphaned and could be consumed by an unrelated desktop session poller.
    +    """
    +    import json
    +    from unittest.mock import MagicMock
    +    import tools.delegate_tool as dt
    +    from gateway.session_context import clear_session_vars, set_session_vars
    +    from tools.approval import reset_current_session_key, set_current_session_key
    +
    +    parent = MagicMock()
    +    parent._delegate_depth = 0
    +    parent.session_id = "post-compress-tip"
    +    parent._interrupt_requested = False
    +    parent._active_children = []
    +    parent._active_children_lock = None
    +    fake_child = MagicMock()
    +    fake_child._delegate_role = "leaf"
    +
    +    creds = {
    +        "model": "m", "provider": None, "base_url": None, "api_key": None,
    +        "api_mode": None, "command": None, "args": None,
    +    }
    +    monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child)
    +    monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds)
    +    monkeypatch.setattr(
    +        dt,
    +        "_run_single_child",
    +        lambda *a, **k: {
    +            "task_index": 0,
    +            "status": "completed",
    +            "summary": "done",
    +            "api_calls": 1,
    +            "duration_seconds": 0.1,
    +            "model": "m",
    +            "exit_reason": "completed",
    +        },
    +    )
    +
    +    approval_token = [redacted:secret-assignment]"pre-compress-parent")
    +    session_tokens = [redacted:secret-assignment]
    +        source="tui",
    +        session_key="pre-compress-parent",
    +        ui_session_id="origin-tab",
    +    )
    +    try:
    +        out = dt.delegate_task(goal="bg task", background=True, parent_agent=parent)
    +        assert json.loads(out)["status"] == "dispatched"
    +        evt = _drain_one()
    +    finally:
    +        reset_current_session_key(approval_token)
    +        clear_session_vars(session_tokens)
    +
    +    assert evt is not None
    +    assert evt["type"] == "async_delegation"
    +    assert evt["session_key"] == "post-compress-tip"
    +    assert evt["origin_ui_session_id"] == "origin-tab"
    +
    +
     def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch):
         """A multi-item batch with background=True dispatches the WHOLE fan-out as
         ONE background unit (one handle, one async slot). The children run in
    ```
  - `tests/tui_gateway/test_delegation_session_lifecycle.py`

    ```diff
    @@ -0,0 +1,158 @@
    +"""Fail-closed ownership + session-scoped delegation lifecycle (#55578).
    +
    +Covers the two hardening rules layered on top of the origin-routing salvage:
    +
    +1. ``_session_owns_notification_event`` — positive-proof ownership. An
    +   async-delegation completion may only be injected into a session that
    +   PROVABLY commissioned it (origin UI id, or session-key/lineage match).
    +   Orphans are never adopted by a foreign chat.
    +
    +2. ``interrupt_for_session`` — a session's in-flight async delegations end
    +   with the session. ``_finalize_session`` interrupts delegations owned by
    +   the closing session (by origin UI id always; by durable key only when the
    +   TUI owns the lifecycle).
    +"""
    +
    +import threading
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +import tools.async_delegation as ad
    +from tui_gateway.server import (
    +    _finalize_session,
    +    _session_owns_notification_event,
    +)
    +
    +
    [redacted:email](autouse=True)
    +def _reset_async_delegation():
    +    ad._reset_for_tests()
    +    yield
    +    ad._reset_for_tests()
    +
    +
    +class TestSessionOwnsNotificationEvent:
    +    def _session(self, key="sess_key_1"):
    +        return {"session_key": key, "_finalized": False}
    +
    +    def test_origin_ui_match_owns(self):
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "other"}
    +        assert _session_owns_notification_event("tab1", self._session(), evt) is True
    +
    +    def test_session_key_match_owns(self):
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "sess_key_1"}
    +        assert _session_owns_notification_event("tabX", self._session("sess_key_1"), evt) is True
    +
    +    def test_orphan_is_not_owned(self):
    +        """No origin match, no key match, owner gone → NOT ours (fail closed)."""
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "dead_tab", "session_key": "gone_key"}
    +        assert _session_owns_notification_event("tab1", self._session(), evt) is False
    +
    +    def test_empty_key_and_origin_not_owned(self):
    +        """A delegation event with no return address at all is never adopted."""
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": ""}
    +        assert _session_owns_notification_event("tab1", self._session(), evt) is False
    +
    +    def test_finalized_session_owns_nothing(self):
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "sess_key_1"}
    +        sess = self._session()
    +        sess["_finalized"] = True
    +        assert _session_owns_notification_event("tab1", sess, evt) is False
    +
    +    def test_compression_chain_resolution_owns(self):
    +        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "parent_key"}
    +        db = MagicMock()
    +        db.resolve_resume_session_id.return_value = "child_key"
    +        with patch("tui_gateway.server._get_db", return_value=db):
    +            assert _session_owns_notification_event("tabX", self._session("child_key"), evt) is True
    +
    +
    +class TestInterruptForSession:
    +    def _seed_record(self, delegation_id, session_key="", origin_ui_session_id="", status="running"):
    +        fn = MagicMock()
    +        with ad._records_lock:
    +            ad._records[delegation_id] = {
    +                "delegation_id": delegation_id,
    +                "status": status,
    +                "session_key": session_key,
    +                "origin_ui_session_id": origin_ui_session_id,
    +                "interrupt_fn": fn,
    +            }
    +        return fn
    +
    +    def test_interrupts_only_matching_session(self):
    +        mine = self._seed_record("d1", session_key="sess_A")
    +        other = self._seed_record("d2", session_key="sess_B")
    +        n = ad.interrupt_for_session(session_key="sess_A")
    +        assert n == 1
    +        mine.assert_called_once()
    +        other.assert_not_called()
    +
    +    def test_matches_by_origin_ui_session_id(self):
    +        mine = self._seed_record("d1", origin_ui_session_id="tab1")
    +        other = self._seed_record("d2", origin_ui_session_id="tab2")
    +        n = ad.interrupt_for_session(origin_ui_session_id="tab1")
    +        assert n == 1
    +        mine.assert_called_once()
    +        other.assert_not_called()
    +
    +    def test_no_selector_is_noop(self):
    +        fn = self._seed_record("d1", session_key="sess_A")
    +        assert ad.interrupt_for_session() == 0
    +        fn.assert_not_called()
    +
    +    def test_completed_records_untouched(self):
    +        fn = self._seed_record("d1", session_key="sess_A", status="completed")
    +        assert ad.interrupt_for_session(session_key="sess_A") == 0
    +        fn.assert_not_called()
    +
    +
    +class TestFinalizeInterruptsOwnDelegations:
    +    def _make_session(self, session_key="sess_A", sid="tab1"):
    +        agent = MagicMock()
    +        agent.session_id = session_key
    +        agent._session_messages = None
    +        agent.model = "m"
    +        agent.platform = "tui"
    +        return {
    +            "agent": agent,
    +            "history": [{"role": "user", "content": "x"}],
    +            "history_lock": threading.Lock(),
    +            "session_key": session_key,
    +            "_finalized": False,
    +            "_sid": sid,
    +        }
    +
    +    @patch("tui_gateway.server._get_db")
    +    def test_finalize_interrupts_sessions_delegations(self, mock_get_db):
    +        mock_db = MagicMock()
    +        mock_db.get_session.return_value = {"source": "tui"}
    +        mock_get_db.return_value = mock_db
    +
    +        with patch("tools.async_delegation.interrupt_for_session") as mock_int:
    +            _finalize_session(self._make_session(), end_reason="tui_close")
    +
    +        mock_int.assert_called_once()
    +        kwargs = mock_int.call_args.kwargs
    +        assert kwargs["session_key"] == "sess_A"
    +        assert kwargs["origin_ui_session_id"] == "tab1"
    +
    +    @patch("tui_gateway.server._get_db")
    +    def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db):
    +        """Closing a TUI viewer tab on a live gateway session must not kill
    +        the gateway's own background work — key-based interrupt is skipped,
    +        origin-id interrupt (this tab's own dispatches) still applies."""
    +        mock_db = MagicMock()
    +        mock_db.get_session.return_value = {"source": "telegram"}
    +        mock_get_db.return_value = mock_db
    +
    +        with patch("tools.async_delegation.interrupt_for_session") as mock_int:
    +            _finalize_session(
    +                self._make_session(session_key="agent:main:telegram:dm:123", sid="tab9"),
    +                end_reason="ws_orphan_reap",
    +            )
    +
    +        kwargs = mock_int.call_args.kwargs
    +        assert kwargs["session_key"] == ""
    +        assert kwargs["origin_ui_session_id"] == "tab9"
    ```
  - `tools/async_delegation.py`

    ```diff
    @@ -130,6 +130,7 @@ def dispatch_async_delegation(
         model: Optional[str],
         session_key: str,
         runner: Callable[[], Dict[str, Any]],
    +    origin_ui_session_id: str = "",
         interrupt_fn: Optional[Callable[[], None]] = None,
         max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
     ) -> Dict[str, Any]:
    ```

    ```diff
    @@ -172,6 +173,7 @@ def dispatch_async_delegation(
             "role": role,
             "model": model,
             "session_key": session_key,
    +        "origin_ui_session_id": origin_ui_session_id,
             "status": "running",
             "dispatched_at": dispatched_at,
             "completed_at": None,
    ```

    ```diff
    @@ -282,6 +284,7 @@ def _push_completion_event(
             # session_key routes the completion back to the originating gateway
             # session; empty string => CLI (single-session) path.
             "session_key": record.get("session_key", ""),
    +        "origin_ui_session_id": record.get("origin_ui_session_id", ""),
             "goal": record.get("goal", ""),
             "context": record.get("context"),
             "toolsets": record.get("toolsets"),
    ```

    ```diff
    @@ -317,6 +320,7 @@ def dispatch_async_delegation_batch(
         model: Optional[str],
         session_key: str,
         runner: Callable[[], Dict[str, Any]],
    +    origin_ui_session_id: str = "",
         interrupt_fn: Optional[Callable[[], None]] = None,
         max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,
     ) -> Dict[str, Any]:
    ```

    ```diff
    @@ -356,6 +360,7 @@ def dispatch_async_delegation_batch(
             "role": role,
             "model": model,
             "session_key": session_key,
    +        "origin_ui_session_id": origin_ui_session_id,
             "status": "running",
             "dispatched_at": dispatched_at,
             "completed_at": None,
    ```

    ```diff
    @@ -453,6 +458,7 @@ def _finalize_batch(
             "type": "async_delegation",
             "delegation_id": delegation_id,
             "session_key": event_record.get("session_key", ""),
    +        "origin_ui_session_id": event_record.get("origin_ui_session_id", ""),
             "goal": event_record.get("goal", ""),
             "goals": event_record.get("goals"),
             "context": event_record.get("context"),
    ```

    ```diff
    @@ -519,6 +525,54 @@ def interrupt_all(reason: str = "shutdown") -> int:
         return count
     
     
    +def interrupt_for_session(
    +    session_key: str = "",
    +    origin_ui_session_id: str = "",
    +    reason: str = "session_end",
    +) -> int:
    +    """Signal running async delegations owned by ONE session to stop.
    +
    +    A delegation's lifecycle is bound to the session that spawned it: when
    +    that session ends, its in-flight background subagents must end with it —
    +    a completed orphan would otherwise sit on the shared completion queue
    +    with no live owner, either leaking into another chat or burning tokens
    +    with no one listening (#55578).
    +
    +    Matches on ``origin_ui_session_id`` (the live UI session that
    +    commissioned the work) and/or the durable ``session_key``; either
    +    matching field claims the record. Returns how many were interrupted.
    +    """
    +    if not session_key and not origin_ui_session_id:
    +        return 0
    +    count = 0
    +    with _records_lock:
    +        targets = [
    +            r for r in _records.values()
    +            if r.get("status") == "running"
    +            and (
    +                (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id)
    +                or (session_key and str(r.get("session_key") or "") == session_key)
    +            )
    +        ]
    +    for r in targets:
    +        fn = r.get("interrupt_fn")
    +        if callable(fn):
    +            try:
    +                fn()
    +                count += 1
    +            except Exception as exc:
    +                logger.debug(
    +                    "interrupt_for_session: %s interrupt failed: %s",
    +                    r.get("delegation_id"), exc,
    +                )
    +    if count:
    +        logger.info(
    +            "Interrupted %d async delegation(s) for ending session (%s)",
    +            count, reason,
    +        )
    +    return count
    +
    +
     def _reset_for_tests() -> None:
         """Test-only: clear all state and tear down the executor."""
         global _executor, _executor_max_workers
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -2796,6 +2796,25 @@ def _execute_and_aggregate() -> dict:
                 return json.dumps(_sync_result, ensure_ascii=False)
     
             _session_key = get_current_session_key(default="")
    +        _origin_ui_session_id = ""
    +        try:
    +            from gateway.session_context import get_session_env
    +
    +            _source = get_session_env("HERMES_SESSION_SOURCE", "")
    +            _origin_ui_session_id = get_session_env("HERMES_UI_SESSION_ID", "")
    +            # In desktop/TUI, the routable session key is the durable
    +            # AIAgent.session_id. Context compression can rotate that id during
    +            # the same turn before the TUI-side session dict is re-anchored;
    +            # if we capture the stale approval/session context key here, the
    +            # async completion becomes an orphan and any desktop poller may
    +            # consume it. Gateway chats are different: their session_key is the
    +            # platform conversation key (agent:main:...), so keep it there.
    +            if _source == "tui":
    +                _agent_session_id = str(getattr(parent_agent, "session_id", "") or "")
    +                if _agent_session_id:
    +                    _session_key = _agent_session_id
    +        except Exception:
    +            _origin_ui_session_id = ""
             _child_agents = [c for (_, _, c) in children]
     
             # Detach every child from the parent's interrupt-propagation list — the
    ```

    ```diff
    @@ -2836,6 +2855,7 @@ def _batch_interrupt():
                 role=top_role,
                 model=creds["model"],
                 session_key=_session_key,
    +            origin_ui_session_id=_origin_ui_session_id,
                 runner=_batch_runner,
                 interrupt_fn=_batch_interrupt,
                 max_async_children=_get_max_async_children(),
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -625,6 +625,7 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
         # Use session_id (from agent.session_id) not session_key — after compression,
         # session_key may be stale (the ended parent) while session_id is the live
         # continuation. Fix for #20001.
    +    _tui_owns_lifecycle = True
         if session_id:
             try:
                 db = _get_db()
    ```

    ```diff
    @@ -638,11 +639,40 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
                     # repeats on every inbound message.  (#60609)
                     row = db.get_session(session_id)
                     source = (row or {}).get("source", "")
    -                if not _is_gateway_owned_source(source):
    +                _tui_owns_lifecycle = not _is_gateway_owned_source(source)
    +                if _tui_owns_lifecycle:
                         db.end_session(session_id, end_reason)
             except Exception:
                 pass
     
    +    # A session's in-flight async delegations end WITH the session (#55578):
    +    # once nobody owns the return address, a still-running background subagent
    +    # can only burn tokens and park an orphaned completion on the shared
    +    # queue. Always interrupt delegations commissioned by THIS live UI session
    +    # (its sid); additionally interrupt by durable session_key, but only when
    +    # the TUI owns the lifecycle — closing a viewer tab on a live gateway
    +    # session must not kill the gateway's own background work.
    +    try:
    +        from tools.async_delegation import interrupt_for_session
    +
    +        _own_sid = str(session.get("_sid") or "")
    +        if not _own_sid:
    +            try:
    +                with _sessions_lock:
    +                    for _cand_sid, _cand in _sessions.items():
    +                        if _cand is session:
    +                            _own_sid = _cand_sid
    +                            break
    +            except Exception:
    +                _own_sid = ""
    +        interrupt_for_session(
    +            session_key=str(session_key or "") if _tui_owns_lifecycle else "",
    +            origin_ui_session_id=_own_sid,
    +            reason=end_reason,
    +        )
    +    except Exception:
    +        pass
    +
         # Close the slash-worker subprocess as part of finalize itself, not just
         # in the callers. Defense-in-depth: every session-end path goes through
         # _finalize_session (it's the single ``_finalized``-guarded chokepoint), so
    ```

    ```diff
    @@ -712,6 +742,10 @@ def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool:
             session = _sessions.pop(sid, None)
         if session is None:
             return False
    +    # The session is already out of _sessions here, so downstream teardown
    +    # (e.g. _finalize_session's per-session async-delegation interrupt) can't
    +    # recover its live id by scanning the dict — stamp it on the record.
    +    session["_sid"] = sid
         _teardown_session(session, end_reason=end_reason)
         return True
     
    ```

    ```diff
    @@ -1946,7 +1980,12 @@ def _cwd_for_session_key(session_key: str) -> str:
         return ""
     
     
    -def _set_session_context(session_key: str, cwd: str | None = None) -> list:
    +def _set_session_context(
    +    session_key: str,
    +    cwd: str | None = None,
    +    *,
    +    ui_session_id: str = "",
    +) -> list:
         try:
             from gateway.session_context import set_session_vars
     
    ```

    ```diff
    @@ -1961,7 +2000,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list:
                     if sess.get("session_key") == session_key:
                         source = _session_source(sess)
                         break
    -        return set_session_vars(session_key=session_key, source=source, cwd=resolved)
    +        return set_session_vars(
    +            session_key=session_key,
    +            source=source,
    +            cwd=resolved,
    +            ui_session_id=ui_session_id,
    +        )
         except Exception:
             return []
     
    ```

    ```diff
    @@ -8451,22 +8495,76 @@ def run_after_agent_ready() -> None:
         return _ok(rid, {"status": "streaming"})
     
     
    -def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
    +def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> bool:
         """True if ``evt`` is owned by a *different* live session.
     
    -    Background-process events carry the ``session_key`` of the session that
    -    started the process. Since all desktop sessions share one process-wide
    -    completion queue, each poller must skip events it doesn't own so a
    -    background job's completion surfaces in the session that launched it — not
    -    whichever poller happened to dequeue first. Orphaned events (owner gone)
    -    and global/system events (empty ``session_key``) return False so the
    -    current poller still handles them rather than losing them.
    +    Background completions carry the ``session_key`` of the session that started
    +    the work. Async delegation completions from the desktop also carry
    +    ``origin_ui_session_id``: the live TUI tab/window that commissioned them.
    +    Since all desktop sessions share one process-wide completion queue, each
    +    poller must skip events it doesn't own so a detached result surfaces in the
    +    launching session, not whichever poller happened to dequeue first.
         """
    +    evt_ui_sid = str(evt.get("origin_ui_session_id") or "")
    +    if evt_ui_sid:
    +        if evt_ui_sid == str(sid or "") and not session.get("_finalized"):
    +            return False
    +        try:
    +            with _sessions_lock:
    +                owner_live = evt_ui_sid in _sessions and not _sessions[evt_ui_sid].get("_finalized")
    +        except Exception:
    +            owner_live = False
    +        if owner_live:
    +            return True
    +        # If the exact UI tab is gone, fall through to durable session_key
    +        # routing. That avoids wrong-session delivery while still allowing a
    +        # resumed continuation with the same durable key/lineage to claim it.
    +
         evt_key = str(evt.get("session_key") or "")
         if not evt_key:
             return False
    -    if evt_key == str(session.get("session_key") or ""):
    +
    +    current_keys = {
    +        str(session.get("session_key") or ""),
    +        _session_lookup_key(session, fallback=sid),
    +    }
    +
    +    # Compression can rotate AIAgent.session_id while the detached child is
    +    # still running. Resolve the event's original key to its continuation tip so
    +    # an event captured before or after compression still maps to the same live
    +    # desktop session instead of becoming an orphan that any poller may consume.
    +    resolved_key = evt_key
    +    try:
    +        db = _get_db()
    +        if db is not None:
    +            resolved_key = db.resolve_resume_session_id(evt_key) or evt_key
    +    except Exception:
    +        resolved_key = evt_key
    +
    +    # If the key has a live continuation, prefer that continuation over the
    +    # compressed parent. Otherwise a stale parent tab could consume the event
    +    # before the real current conversation sees it.
    +    if resolved_key != evt_key:
    +        if resolved_key in current_keys:
    +            return False
    +        try:
    +            with _sessions_lock:
    +                continuation_live = any(
    +                    not s.get("_finalized")
    +                    and (
    +                        str(s.get("session_key") or "") == resolved_key
    +                        or _session_lookup_key(s, fallback="") == resolved_key
    +                    )
    +                    for s in _sessions.values()
    +                )
    +        except Exception:
    +            continuation_live = False
    +        if continuation_live:
    +            return True
    +
    +    if evt_key in current_keys:
             return False
    +
         try:
             with _sessions_lock:
                 snapshot = list(_sessions.values())
    ```

    ```diff
    @@ -8476,11 +8574,50 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
             return False
     
         return any(
    -        s is not session and str(s.get("session_key") or "") == evt_key
    +        s is not session
    +        and not s.get("_finalized")
    +        and (
    +            str(s.get("session_key") or "") in {evt_key, resolved_key}
    +            or _session_lookup_key(s, fallback="") in {evt_key, resolved_key}
    +        )
             for s in snapshot
         )
     
     
    +def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool:
    +    """True iff *this* session PROVABLY owns ``evt``.
    +
    +    Positive ownership — the mirror of ``_notification_event_belongs_elsewhere``
    +    minus its orphan-adoption fallback. An event owns-matches when its
    +    ``origin_ui_session_id`` is this live session, or its ``session_key``
    +    (raw or resolved through the compression chain) matches this session's
    +    key/lineage. Used as a fail-closed gate for async-delegation payloads:
    +    "not provably elsewhere" is NOT good enough to inject a conversation
    +    payload into this chat (#55578).
    +    """
    +    if session.get("_finalized"):
    +        return False
    +    if str(evt.get("origin_ui_session_id") or "") == str(sid or ""):
    +        return True
    +    evt_key = str(evt.get("session_key") or "")
    +    if not evt_key:
    +        return False
    +    current_keys = {
    +        str(session.get("session_key") or ""),
    +        _session_lookup_key(session, fallback=sid),
    +    }
    +    if evt_key in current_keys:
    +        return True
    +    try:
    +        db = _get_db()
    +        resolved_key = (
    +            db.resolve_resume_session_id(evt_key) if db is not None else evt_key
    +        ) or evt_key
    +    except Exception:
    +        resolved_key = evt_key
    +    return resolved_key in current_keys
    +
    +
     def _notification_event_dedup_key(evt: dict) -> tuple:
         """Return the UI-emission identity for a process notification event.
     
    ```

    ```diff
    @@ -8546,11 +8683,37 @@ def _notification_poller_loop(
             # process started in session A would surface its completion in whichever
             # session's poller happened to wake first (Ben's "reported in a
             # different session" bug). Leave foreign events for their owner.
    -        if _notification_event_belongs_elsewhere(session, evt):
    +        if _notification_event_belongs_elsewhere(sid, session, evt):
                 process_registry.completion_queue.put(evt)
                 time.sleep(0.1)
                 continue
     
    +        # Fail closed for async-delegation results (#55578): these carry a
    +        # conversation payload, and injecting one into any chat other than the
    +        # one that commissioned it is a hard cross-session leak. The
    +        # belongs-elsewhere check above already re-queued events owned by
    +        # another LIVE session; what reaches here is either ours or an
    +        # orphan whose owner is gone. Orphaned delegation payloads are
    +        # DROPPED, not adopted — the subagent's summary is already persisted
    +        # in the delegation records/output store, so nothing is lost, whereas
    +        # a wrong-chat injection is unrecoverable. Non-delegation events
    +        # (background process completions etc.) keep the historical
    +        # adopt-orphans behavior.
    +        if evt.get("type") == "async_delegation" and not _session_owns_notification_event(
    +            sid, session, evt
    +        ):
    +            logger.warning(
    +                "async-delegation completion %s has no live owner "
    +                "(origin=%r key=%r); dropping from injection instead of "
    +                "delivering to session %s (#55578 fail-closed; result "
    +                "remains in the delegation records)",
    +                evt.get("delegation_id", "?"),
    +                str(evt.get("origin_ui_session_id") or ""),
    +                str(evt.get("session_key") or ""),
    +                sid,
    +            )
    +            continue
    +
             _evt_sid = evt.get("session_id", "")
             if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
                 continue
    ```

    ```diff
    @@ -8604,7 +8767,16 @@ def _notification_poller_loop(
                 evt = process_registry.completion_queue.get_nowait()
             except Exception:
                 break
    -        if _notification_event_belongs_elsewhere(session, evt):
    +        if _notification_event_belongs_elsewhere(sid, session, evt):
    +            deferred.append(evt)
    +            continue
    +        # Same fail-closed rule as the live loop: an orphaned async-delegation
    +        # payload is never adopted by a foreign session — defer it (a later
    +        # resume of the owner's lineage can still claim it) rather than
    +        # injecting another chat's conversation here (#55578).
    +        if evt.get("type") == "async_delegation" and not _session_owns_notification_event(
    +            sid, session, evt
    +        ):
                 deferred.append(evt)
                 continue
             _evt_sid = evt.get("session_id", "")
    ```

    ```diff
    @@ -8729,7 +8901,10 @@ def run():
                 )
     
                 approval_token = [redacted:secret-assignment]"session_key"])
    -            session_tokens = [redacted:secret-assignment]"session_key"])
    +            session_tokens = [redacted:secret-assignment]
    +                session["session_key"],
    +                ui_session_id=sid,
    +            )
                 _profile_home_str = session.get("profile_home")
                 if _profile_home_str:
                     home_token = [redacted:secret-assignment]
    ```
- **#60869: fix(delegation): session-filter the post-turn notification drain (#58684)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `gateway/run.py`, `tests/tools/test_process_registry.py`, `tools/process_registry.py`, `tui_gateway/server.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -15082,7 +15082,9 @@ def process_loop():
                                 # and watch pattern matches) while agent is idle.
                                 try:
                                     from tools.process_registry import process_registry
    -                                for _evt, _synth in process_registry.drain_notifications():
    +                                from tools.approval import get_current_session_key
    +                                _drain_sk = get_current_session_key(default="")
    +                                for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):
                                         self._pending_input.put(_synth)
                                 except Exception:
                                     pass
    ```
  - `gateway/run.py`

    ```diff
    @@ -15143,6 +15143,13 @@ def _build_process_event_source(self, evt: dict):
             chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower()
             chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip()
             if not platform_name or not chat_type or not chat_id:
    +            logger.warning(
    +                "Synthetic event source unresolvable: "
    +                "session_key=%r platform=%r chat_type=%r chat_id=%r "
    +                "evt_type=%s",
    +                session_key, platform_name, chat_type, chat_id,
    +                evt.get("type", "?"),
    +            )
                 return None
     
             try:
    ```
  - `tests/tools/test_process_registry.py`

    ```diff
    @@ -1346,6 +1346,181 @@ def test_drain_notifications_empty_queue():
         assert results == []
     
     
    +def test_drain_notifications_filters_async_delegation_by_session_key():
    +    """Async-delegation events should only be consumed by the matching session's drain.
    +
    +    Regression test for issue #58684: background delegation results delivered
    +    to the wrong session when the user switches sessions while a subagent runs.
    +    """
    +    from tools.process_registry import process_registry
    +
    +    # Clear the queue first
    +    while not process_registry.completion_queue.empty():
    +        process_registry.completion_queue.get_nowait()
    +
    +    try:
    +        # Put events for different sessions
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_session_a",
    +            "session_key": "telegram:dm:111:user_a",
    +            "goal": "task A",
    +            "status": "completed",
    +            "summary": "done A",
    +            "api_calls": 1,
    +            "duration_seconds": 0.5,
    +        })
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_session_b",
    +            "session_key": "telegram:dm:222:user_b",
    +            "goal": "task B",
    +            "status": "completed",
    +            "summary": "done B",
    +            "api_calls": 1,
    +            "duration_seconds": 0.3,
    +        })
    +
    +        # Drain for session A — should only get deleg_session_a
    +        results_a = process_registry.drain_notifications(session_key="telegram:dm:111:user_a")
    +        assert len(results_a) == 1, (
    +            f"Expected 1 event for session A, got {len(results_a)}"
    +        )
    +        assert results_a[0][0]["delegation_id"] == "deleg_session_a"
    +        assert "done A" in results_a[0][1]
    +
    +        # Session B's event should have been re-queued — drain for session B
    +        results_b = process_registry.drain_notifications(session_key="telegram:dm:222:user_b")
    +        assert len(results_b) == 1, (
    +            f"Expected 1 event for session B, got {len(results_b)}"
    +        )
    +        assert results_b[0][0]["delegation_id"] == "deleg_session_b"
    +        assert "done B" in results_b[0][1]
    +
    +        # No more events should remain
    +        assert process_registry.completion_queue.empty()
    +    finally:
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
    +def test_drain_notifications_no_filter_passes_all_async_delegation():
    +    """Without a session_key filter, all async-delegation events are consumed.
    +
    +    This ensures backward compatibility — the default (session_key="") permits
    +    all events, matching pre-fix behavior.
    +    """
    +    from tools.process_registry import process_registry
    +
    +    while not process_registry.completion_queue.empty():
    +        process_registry.completion_queue.get_nowait()
    +
    +    try:
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_1",
    +            "session_key": "telegram:dm:111:user_a",
    +            "goal": "task 1",
    +            "status": "completed",
    +            "summary": "done 1",
    +            "api_calls": 1,
    +            "duration_seconds": 0.5,
    +        })
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_2",
    +            "session_key": "telegram:dm:222:user_b",
    +            "goal": "task 2",
    +            "status": "completed",
    +            "summary": "done 2",
    +            "api_calls": 1,
    +            "duration_seconds": 0.3,
    +        })
    +
    +        # No filter — both should be consumed
    +        results = process_registry.drain_notifications()
    +        assert len(results) == 2, (
    +            f"Expected 2 events without filter, got {len(results)}"
    +        )
    +        ids = {r[0]["delegation_id"] for r in results}
    +        assert ids == {"deleg_1", "deleg_2"}
    +    finally:
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
    +def test_drain_notifications_owns_event_callback_beats_key_equality():
    +    """The positive-proof ownership callback consumes ONLY approved events —
    +    including across a compression rotation where bare key equality would
    +    wrongly re-queue the session's own pre-compression dispatch (#55578)."""
    +    from tools.process_registry import process_registry
    +
    +    while not process_registry.completion_queue.empty():
    +        process_registry.completion_queue.get_nowait()
    +
    +    try:
    +        # Pre-compression dispatch: event carries the OLD key.
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_precompress",
    +            "session_key": "old_parent_key",
    +            "goal": "task", "status": "completed", "summary": "mine",
    +            "api_calls": 1, "duration_seconds": 0.1,
    +        })
    +        # Foreign event that plain key equality would also reject.
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_foreign",
    +            "session_key": "someone_else",
    +            "goal": "task", "status": "completed", "summary": "not mine",
    +            "api_calls": 1, "duration_seconds": 0.1,
    +        })
    +
    +        # Chain-aware ownership: this session's lineage includes old_parent_key.
    +        lineage = {"old_parent_key", "new_child_key"}
    +        results = process_registry.drain_notifications(
    +            session_key="new_child_key",
    +            owns_event=lambda e: e.get("session_key") in lineage,
    +        )
    +        assert [r[0]["delegation_id"] for r in results] == ["deleg_precompress"]
    +
    +        # The foreign event was re-queued, not consumed.
    +        leftover = process_registry.completion_queue.get_nowait()
    +        assert leftover["delegation_id"] == "deleg_foreign"
    +    finally:
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
    +def test_drain_notifications_owns_event_callback_fails_closed():
    +    """A broken ownership callback must re-queue (never leak) the event."""
    +    from tools.process_registry import process_registry
    +
    +    while not process_registry.completion_queue.empty():
    +        process_registry.completion_queue.get_nowait()
    +
    +    try:
    +        process_registry.completion_queue.put({
    +            "type": "async_delegation",
    +            "delegation_id": "deleg_x",
    +            "session_key": "k",
    +            "goal": "task", "status": "completed", "summary": "s",
    +            "api_calls": 1, "duration_seconds": 0.1,
    +        })
    +
    +        def broken(_evt):
    +            raise RuntimeError("ownership check exploded")
    +
    +        results = process_registry.drain_notifications(
    +            session_key="k", owns_event=broken
    +        )
    +        assert results == []
    +        assert process_registry.completion_queue.get_nowait()["delegation_id"] == "deleg_x"
    +    finally:
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
     # ---------------------------------------------------------------------------
     # _terminate_host_pid — cross-platform process-tree termination
     # ---------------------------------------------------------------------------
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -1148,14 +1148,33 @@ def _drain_should_skip(self, session_id: str) -> bool:
             """
             return session_id in self._completion_consumed or session_id in self._poll_observed
     
    -    def drain_notifications(self) -> "list[tuple[dict, str]]":
    +    def drain_notifications(
    +        self, session_key: str = "", owns_event=None,
    +    ) -> "list[tuple[dict, str]]":
             """Pop all pending notification events and return formatted pairs.
     
             Returns a list of (raw_event, formatted_text) tuples.
             Skips completion events the agent already consumed via wait/log or
             observed inline via poll() (see ``_drain_should_skip``).
    +
    +        Async-delegation events carry a conversation payload, so draining one
    +        into the wrong session is a cross-chat leak (#58684, #55578). Two
    +        filter modes, strongest wins:
    +
    +        - ``owns_event(evt) -> bool``: positive-proof ownership callback.
    +          When provided, an async-delegation event is consumed ONLY if the
    +          callback returns True; everything else is re-queued for its owner.
    +          The TUI passes its compression-chain-aware ownership check here so
    +          a post-compression session still claims its own pre-compression
    +          dispatches.
    +        - ``session_key``: plain key equality (CLI and other single-session
    +          callers). Non-matching async-delegation events are re-queued.
    +
    +        With neither set, all events are consumed (legacy single-session
    +        behavior, backward compatible).
             """
    -        results = []
    +        results: "list[tuple[dict, str]]" = []
    +        requeue: "list[dict]" = []
             while not self.completion_queue.empty():
                 try:
                     evt = self.completion_queue.get_nowait()
    ```

    ```diff
    @@ -1164,9 +1183,28 @@ def drain_notifications(self) -> "list[tuple[dict, str]]":
                 _evt_sid = evt.get("session_id", "")
                 if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid):
                     continue
    +            # Filter async-delegation events so they are not delivered to the
    +            # wrong session/thread (#58684). Positive-proof callback beats
    +            # bare key equality when the caller can provide one.
    +            if evt.get("type") == "async_delegation":
    +                if owns_event is not None:
    +                    try:
    +                        owned = bool(owns_event(evt))
    +                    except Exception:
    +                        owned = False  # fail closed — never leak on a broken check
    +                    if not owned:
    +                        requeue.append(evt)
    +                        continue
    +                elif session_key:
    +                    evt_session_key = evt.get("session_key", "") or ""
    +                    if evt_session_key != session_key:
    +                        requeue.append(evt)
    +                        continue
                 text = format_process_notification(evt)
                 if text:
                     results.append((evt, text))
    +        for evt in requeue:
    +            self.completion_queue.put(evt)
             return results
     
         def get(self, session_id: str) -> Optional[ProcessSession]:
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -9337,7 +9337,15 @@ def _stream(delta):
             try:
                 from tools.process_registry import process_registry
     
    -            for _evt, synth in process_registry.drain_notifications():
    +            # Positive-proof ownership (compression-chain aware) — the same
    +            # fail-closed gate the poller uses, so the post-turn drain can't
    +            # adopt another session's (or an orphan's) delegation payload,
    +            # while a post-compression session still claims its own
    +            # pre-compression dispatches (#55578).
    +            for _evt, synth in process_registry.drain_notifications(
    +                session_key=session.get("session_key", ""),
    +                owns_event=lambda e: _session_owns_notification_event(sid, session, e),
    +            ):
                     with session["history_lock"]:
                         if session.get("running"):
                             process_registry.completion_queue.put(_evt)
    ```
- **#60871: fix(delegate): pin gateway async completions to the spawning session (#57498)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/run.py`

    ```diff
    @@ -10633,6 +10633,46 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
     
             session_entry = self.session_store.get_or_create_session(source)
             session_key = session_entry.session_key
    +        pinned_session_id = str(
    +            (getattr(event, "metadata", None) or {}).get("gateway_session_id") or ""
    +        ).strip()
    +        if pinned_session_id and pinned_session_id != session_entry.session_id:
    +            # Fail closed (#55578): the spawning session may have ENDED since
    +            # dispatch (user /new-reset, compression rotation whose parent was
    +            # closed). switch_session() re-opens ended sessions, so pinning
    +            # blindly would RESURRECT a conversation the user explicitly
    +            # ended and inject into it — the same illicit-revival class as
    +            # the ws_orphan_reap loop (#60609). A completion whose spawning
    +            # session is dead is dropped from injection; the subagent's
    +            # output remains in the delegation records.
    +            pinned_row = None
    +            try:
    +                if self._session_db is not None:
    +                    # AsyncSessionDB already offloads to a thread.
    +                    pinned_row = await self._session_db.get_session(pinned_session_id)
    +            except Exception:
    +                pinned_row = None
    +            if pinned_row is None or pinned_row.get("ended_at"):
    +                logger.warning(
    +                    "Async-delegation completion pinned to session %s, which is "
    +                    "%s — dropping injection instead of resurrecting it "
    +                    "(#55578 fail-closed; result remains in the delegation "
    +                    "records).",
    +                    pinned_session_id,
    +                    "unknown" if pinned_row is None else "ended",
    +                )
    +                return
    +            prior_session_id = session_entry.session_id
    +            switched = self.session_store.switch_session(session_key, pinned_session_id)
    +            if switched is not None:
    +                session_entry = switched
    +                logger.info(
    +                    "Pinned async-delegation completion to spawning session %s "
    +                    "(was %s) for routing key %s (#57498)",
    +                    pinned_session_id,
    +                    prior_session_id,
    +                    session_key,
    +                )
             self._cache_session_source(session_key, source)
             if await asyncio.to_thread(self._is_telegram_topic_lane, source):
                 try:
    ```

    ```diff
    @@ -15202,12 +15242,17 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None:
             if not adapter:
                 return
             try:
    +            metadata = {}
    +            parent_session_id = str(evt.get("parent_session_id") or "").strip()
    +            if parent_session_id:
    +                metadata["gateway_session_id"] = parent_session_id
                 synth_event = MessageEvent(
                     text=synth_text,
                     message_type=MessageType.TEXT,
                     source=source,
                     internal=True,
                     message_id=str(evt.get("message_id") or "").strip() or None,
    +                metadata=metadata,
                 )
                 logger.info(
                     "Watch pattern notification — injecting for %s chat=%s thread=%s",
    ```
  - `gateway/slash_commands.py`

    ```diff
    @@ -167,6 +167,24 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
             if _qe is not None:
                 _qe.pop(session_key, None)
     
    +        # The old conversation's in-flight async delegations end WITH it
    +        # (#55578): after the reset rotates the session id, their completions
    +        # would have no live owner — a dangling subagent can only burn tokens
    +        # and park an orphaned payload on the shared queue. Interrupt by the
    +        # expiring durable session id (delegations dispatched from gateway
    +        # chats are pinned to it via parent_session_id) and by the routing
    +        # key as a fallback for older records.
    +        try:
    +            from tools.async_delegation import interrupt_for_session
    +
    +            interrupt_for_session(
    +                session_key=session_key,
    +                parent_session_id=str(getattr(old_entry, "session_id", "") or ""),
    +                reason="session_reset",
    +            )
    +        except Exception:
    +            pass
    +
             try:
                 from tools.env_passthrough import clear_env_passthrough
                 clear_env_passthrough()
    ```
  - `tests/gateway/test_async_delegation_session_binding.py`

    ```diff
    @@ -0,0 +1,130 @@
    +"""Gateway-side session binding for async delegations (#57498, #55578).
    +
    +Three invariants on the messaging-gateway surface, mirroring the TUI rules:
    +
    +1. Completions are pinned to the spawning session (contributor commit).
    +2. A dead/ended spawning session is never resurrected: the injection is
    +   dropped, fail-closed (never rerouted to the peer's current session).
    +3. /new interrupts the old conversation's in-flight async delegations.
    +"""
    +
    +import asyncio
    +from unittest.mock import AsyncMock, MagicMock, patch
    +
    +import pytest
    +
    +import tools.async_delegation as ad
    +
    +
    [redacted:email](autouse=True)
    +def _reset_async_delegation():
    +    ad._reset_for_tests()
    +    yield
    +    ad._reset_for_tests()
    +
    +
    +def _seed_record(delegation_id, session_key="", parent_session_id="", status="running"):
    +    fn = MagicMock()
    +    with ad._records_lock:
    +        ad._records[delegation_id] = {
    +            "delegation_id": delegation_id,
    +            "status": status,
    +            "session_key": session_key,
    +            "parent_session_id": parent_session_id,
    +            "interrupt_fn": fn,
    +        }
    +    return fn
    +
    +
    +class TestInterruptForSessionByParentId:
    +    def test_parent_session_id_selector(self):
    +        mine = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="sess_old")
    +        other = _seed_record("d2", session_key="agent:main:telegram:dm:2", parent_session_id="sess_other")
    +        n = ad.interrupt_for_session(parent_session_id="sess_old")
    +        assert n == 1
    +        mine.assert_called_once()
    +        other.assert_not_called()
    +
    +    def test_reset_interrupts_by_key_and_parent(self):
    +        """A /new reset passes both selectors — either match claims the record."""
    +        by_key = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="")
    +        by_parent = _seed_record("d2", session_key="", parent_session_id="sess_old")
    +        unrelated = _seed_record("d3", session_key="other", parent_session_id="other")
    +        n = ad.interrupt_for_session(
    +            session_key="agent:main:telegram:dm:1",
    +            parent_session_id="sess_old",
    +            reason="session_reset",
    +        )
    +        assert n == 2
    +        by_key.assert_called_once()
    +        by_parent.assert_called_once()
    +        unrelated.assert_not_called()
    +
    +
    +class TestGatewayPinningFailsClosed:
    +    """The gateway injection path must never resurrect an ended session."""
    +
    +    def _make_runner(self, pinned_row):
    +        from gateway.run import GatewayRunner
    +
    +        runner = object.__new__(GatewayRunner)
    +        db = MagicMock()
    +        db.get_session = AsyncMock(return_value=pinned_row)
    +        runner._session_db = db
    +
    +        entry = MagicMock()
    +        entry.session_key = "agent:main:telegram:dm:1"
    +        entry.session_id = "sess_current"
    +        runner.session_store = MagicMock()
    +        runner.session_store.get_or_create_session.return_value = entry
    +        runner.session_store.switch_session.return_value = entry
    +        return runner, entry
    +
    +    def _run_pinning_prefix(self, runner, pinned_session_id):
    +        """Execute the pinning guard logic exactly as _handle_message does."""
    +
    +        async def _go():
    +            event = MagicMock()
    +            event.metadata = {"gateway_session_id": pinned_session_id}
    +            session_entry = runner.session_store.get_or_create_session(MagicMock())
    +            pinned = str((getattr(event, "metadata", None) or {}).get("gateway_session_id") or "").strip()
    +            if pinned and pinned != session_entry.session_id:
    +                pinned_row = None
    +                try:
    +                    if runner._session_db is not None:
    +                        pinned_row = await runner._session_db.get_session(pinned)
    +                except Exception:
    +                    pinned_row = None
    +                if pinned_row is None or pinned_row.get("ended_at"):
    +                    return "dropped"
    +                switched = runner.session_store.switch_session(session_entry.session_key, pinned)
    +                if switched is not None:
    +                    return "pinned"
    +            return "default"
    +
    +        return asyncio.run(_go())
    +
    +    def test_live_spawning_session_pins(self):
    +        runner, _ = self._make_runner({"id": "sess_old", "ended_at": None})
    +        assert self._run_pinning_prefix(runner, "sess_old") == "pinned"
    +
    +    def test_ended_spawning_session_drops(self):
    +        runner, _ = self._make_runner({"id": "sess_old", "ended_at": "2026-07-08T00:00:00"})
    +        assert self._run_pinning_prefix(runner, "sess_old") == "dropped"
    +        runner.session_store.switch_session.assert_not_called()
    +
    +    def test_unknown_spawning_session_drops(self):
    +        runner, _ = self._make_runner(None)
    +        assert self._run_pinning_prefix(runner, "sess_gone") == "dropped"
    +        runner.session_store.switch_session.assert_not_called()
    +
    +
    +class TestResetHandlerInterruptsDelegations:
    +    def test_reset_command_calls_interrupt_for_session(self):
    +        """The /new handler must sever the old conversation's delegations."""
    +        import inspect
    +        from gateway import slash_commands
    +
    +        src = inspect.getsource(slash_commands.GatewaySlashCommandsMixin._handle_reset_command)
    +        assert "interrupt_for_session" in src
    +        assert "session_reset" in src
    ```
  - `tests/tools/test_async_delegation.py`

    ```diff
    @@ -99,6 +99,7 @@ def runner():
         res = ad.dispatch_async_delegation(
             goal="compute X", context="some context", toolsets=["web", "file"],
             role="leaf", model="test-model", session_key="agent:main:cli:dm:local",
    +        parent_session_id="20260703_parent_sid",
             runner=runner, max_async_children=3,
         )
         assert res["status"] == "dispatched"
    ```

    ```diff
    @@ -108,6 +109,7 @@ def runner():
         assert evt["type"] == "async_delegation"
         assert evt["summary"] == "the result"
         assert evt["session_key"] == "agent:main:cli:dm:local"
    +    assert evt["parent_session_id"] == "20260703_parent_sid"
         assert evt["delegation_id"] == res["delegation_id"]
     
     
    ```
  - `tools/async_delegation.py`

    ```diff
    @@ -129,6 +129,7 @@ def dispatch_async_delegation(
         role: str,
         model: Optional[str],
         session_key: str,
    +    parent_session_id: Optional[str] = None,
         runner: Callable[[], Dict[str, Any]],
         origin_ui_session_id: str = "",
         interrupt_fn: Optional[Callable[[], None]] = None,
    ```

    ```diff
    @@ -146,6 +147,11 @@ def dispatch_async_delegation(
             captured on the parent thread BEFORE dispatch, because the daemon
             worker thread won't carry the contextvar. Used to route the
             completion back to the originating session.
    +    parent_session_id
    +        The durable ``state.db`` session id of the parent agent that spawned
    +        the delegation. Carried on the completion event so the gateway can
    +        pin routing to the spawning session instead of recovering the latest
    +        ``ended_at IS NULL`` row for the peer tuple (#57498).
         runner
             Zero-arg callable that builds + runs the child and returns the same
             result dict ``_run_single_child`` produces. Runs on the worker thread.
    ```

    ```diff
    @@ -174,6 +180,7 @@ def dispatch_async_delegation(
             "model": model,
             "session_key": session_key,
             "origin_ui_session_id": origin_ui_session_id,
    +        "parent_session_id": parent_session_id,
             "status": "running",
             "dispatched_at": dispatched_at,
             "completed_at": None,
    ```

    ```diff
    @@ -285,6 +292,7 @@ def _push_completion_event(
             # session; empty string => CLI (single-session) path.
             "session_key": record.get("session_key", ""),
             "origin_ui_session_id": record.get("origin_ui_session_id", ""),
    +        "parent_session_id": record.get("parent_session_id"),
             "goal": record.get("goal", ""),
             "context": record.get("context"),
             "toolsets": record.get("toolsets"),
    ```

    ```diff
    @@ -319,6 +327,7 @@ def dispatch_async_delegation_batch(
         role: str,
         model: Optional[str],
         session_key: str,
    +    parent_session_id: Optional[str] = None,
         runner: Callable[[], Dict[str, Any]],
         origin_ui_session_id: str = "",
         interrupt_fn: Optional[Callable[[], None]] = None,
    ```

    ```diff
    @@ -361,6 +370,7 @@ def dispatch_async_delegation_batch(
             "model": model,
             "session_key": session_key,
             "origin_ui_session_id": origin_ui_session_id,
    +        "parent_session_id": parent_session_id,
             "status": "running",
             "dispatched_at": dispatched_at,
             "completed_at": None,
    ```

    ```diff
    @@ -459,6 +469,7 @@ def _finalize_batch(
             "delegation_id": delegation_id,
             "session_key": event_record.get("session_key", ""),
             "origin_ui_session_id": event_record.get("origin_ui_session_id", ""),
    +        "parent_session_id": event_record.get("parent_session_id"),
             "goal": event_record.get("goal", ""),
             "goals": event_record.get("goals"),
             "context": event_record.get("context"),
    ```

    ```diff
    @@ -528,6 +539,7 @@ def interrupt_all(reason: str = "shutdown") -> int:
     def interrupt_for_session(
         session_key: str = "",
         origin_ui_session_id: str = "",
    +    parent_session_id: str = "",
         reason: str = "session_end",
     ) -> int:
         """Signal running async delegations owned by ONE session to stop.
    ```

    ```diff
    @@ -538,11 +550,17 @@ def interrupt_for_session(
         with no live owner, either leaking into another chat or burning tokens
         with no one listening (#55578).
     
    -    Matches on ``origin_ui_session_id`` (the live UI session that
    -    commissioned the work) and/or the durable ``session_key``; either
    -    matching field claims the record. Returns how many were interrupted.
    +    Selectors (any matching field claims the record):
    +    - ``origin_ui_session_id``: the live TUI tab/window that commissioned it.
    +    - ``session_key``: the durable routing key captured at dispatch.
    +    - ``parent_session_id``: the spawning agent's durable session-db id —
    +      the right selector for gateway chats, whose ``session_key`` (the
    +      platform conversation key) SURVIVES a ``/new`` reset while the
    +      session id rotates.
    +
    +    Returns how many were interrupted.
         """
    -    if not session_key and not origin_ui_session_id:
    +    if not session_key and not origin_ui_session_id and not parent_session_id:
             return 0
         count = 0
         with _records_lock:
    ```

    ```diff
    @@ -552,6 +570,7 @@ def interrupt_for_session(
                 and (
                     (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id)
                     or (session_key and str(r.get("session_key") or "") == session_key)
    +                or (parent_session_id and str(r.get("parent_session_id") or "") == parent_session_id)
                 )
             ]
         for r in targets:
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -2815,6 +2815,7 @@ def _execute_and_aggregate() -> dict:
                         _session_key = _agent_session_id
             except Exception:
                 _origin_ui_session_id = ""
    +        _parent_session_id = getattr(parent_agent, "session_id", None)
             _child_agents = [c for (_, _, c) in children]
     
             # Detach every child from the parent's interrupt-propagation list — the
    ```

    ```diff
    @@ -2856,6 +2857,7 @@ def _batch_interrupt():
                 model=creds["model"],
                 session_key=_session_key,
                 origin_ui_session_id=_origin_ui_session_id,
    +            parent_session_id=_parent_session_id,
                 runner=_batch_runner,
                 interrupt_fn=_batch_interrupt,
                 max_async_children=_get_max_async_children(),
    ```
- **#63317: fix(tui): enforce notification session ownership across all drain paths**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54785`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#64593: fix(cli): stop new sessions adopting dead sessions' delegation completions (#64484)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `cli.py`

    ```diff
    @@ -9148,6 +9148,55 @@ def _get_goal_manager(self):
     
     
     
    +    def _owns_process_notification(self, event: dict) -> bool:
    +        """Return whether this CLI session provably owns a delegation event.
    +
    +        Delegations dispatched before context compression retain the original
    +        session key, so resolve that key to its continuation before comparing.
    +        Missing or foreign keys fail closed and remain queued for their owner.
    +        """
    +        event_key = str(event.get("session_key") or "")
    +        current_key = str(getattr(self, "session_id", "") or "")
    +        if not event_key or not current_key:
    +            return False
    +        if event_key == current_key:
    +            return True
    +        try:
    +            session_db = getattr(self, "_session_db", None)
    +            resolved_key = (
    +                session_db.resolve_resume_session_id(event_key)
    +                if session_db is not None
    +                else event_key
    +            ) or event_key
    +        except Exception:
    +            resolved_key = event_key
    +        return str(resolved_key) == current_key
    +
    +    def _drain_process_notifications(self, consumer: str) -> None:
    +        """Queue background notifications owned by this visible CLI session.
    +
    +        ``process_registry`` restores durable delegation completions into every
    +        process using the same Hermes profile.  Always pass this CLI's stable
    +        session identity when draining so another window cannot claim and mark
    +        delivered a completion that belongs to this one.
    +        """
    +        from tools.process_registry import process_registry
    +        from tools.async_delegation import (
    +            claim_event_delivery,
    +            complete_event_delivery,
    +        )
    +
    +        session_key = getattr(self, "session_id", "") or ""
    +        for event, synthetic_message in process_registry.drain_notifications(
    +            session_key=session_key,
    +            owns_event=self._owns_process_notification,
    +        ):
    +            claim = claim_event_delivery(event, consumer)
    +            if claim is None:
    +                continue
    +            self._pending_input.put(synthetic_message)
    +            complete_event_delivery(event, claim)
    +
         def _drain_interrupt_queue_to_pending_input(self) -> None:
             """Move stray messages from ``_interrupt_queue`` into ``_pending_input``.
     
    ```

    ```diff
    @@ -15321,18 +15370,7 @@ def process_loop():
                                 # Check for background process notifications (completions
                                 # and watch pattern matches) while agent is idle.
                                 try:
    -                                from tools.process_registry import process_registry
    -                                from tools.approval import get_current_session_key
    -                                _drain_sk = get_current_session_key(default="")
    -                                for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):
    -                                    from tools.async_delegation import (
    -                                        claim_event_delivery, complete_event_delivery,
    -                                    )
    -                                    _claim = claim_event_delivery(_evt, "cli-idle")
    -                                    if _claim is None:
    -                                        continue
    -                                    self._pending_input.put(_synth)
    -                                    complete_event_delivery(_evt, _claim)
    +                                self._drain_process_notifications("cli-idle")
                                 except Exception:
                                     pass
                             continue
    ```

    ```diff
    @@ -15492,16 +15530,7 @@ def _restart_recording():
                             # Drain process notifications (completions + watch matches)
                             # that arrived while the agent was running.
                             try:
    -                            from tools.process_registry import process_registry
    -                            for _evt, _synth in process_registry.drain_notifications():
    -                                from tools.async_delegation import (
    -                                    claim_event_delivery, complete_event_delivery,
    -                                )
    -                                _claim = claim_event_delivery(_evt, "cli-post-turn")
    -                                if _claim is None:
    -                                    continue
    -                                self._pending_input.put(_synth)
    -                                complete_event_delivery(_evt, _claim)
    +                            self._drain_process_notifications("cli-post-turn")
                             except Exception:
                                 pass  # Non-fatal — don't break the main loop
     
    ```
  - `tests/cli/test_cli_async_delegation_delivery.py`

    ```diff
    @@ -0,0 +1,76 @@
    +"""Regression coverage for CLI async-delegation completion ownership."""
    +
    +import queue
    +
    +from cli import HermesCLI
    +
    +
    +def test_cli_completion_drain_uses_visible_session_identity(monkeypatch):
    +    """A CLI window must not claim another window's restored completion."""
    +    cli = HermesCLI.__new__(HermesCLI)
    +    cli.session_id = "visible-session"
    +    cli._pending_input = queue.Queue()
    +
    +    event = {
    +        "type": "async_delegation",
    +        "delegation_id": "deleg_visible",
    +        "session_key": "visible-session",
    +    }
    +    calls = []
    +
    +    class FakeRegistry:
    +        def drain_notifications(self, *, session_key="", owns_event=None):
    +            calls.append((session_key, owns_event(event)))
    +            return [(event, "completion payload")]
    +
    +    claimed = []
    +    completed = []
    +
    +    monkeypatch.setattr(
    +        "tools.process_registry.process_registry",
    +        FakeRegistry(),
    +    )
    +    monkeypatch.setattr(
    +        "tools.async_delegation.claim_event_delivery",
    +        lambda evt, consumer: claimed.append((evt, consumer)) or "claim-token",
    +    )
    +    monkeypatch.setattr(
    +        "tools.async_delegation.complete_event_delivery",
    +        lambda evt, token: [redacted:secret-assignment] token)),
    +    )
    +
    +    cli._drain_process_notifications("cli-idle")
    +
    +    assert calls == [("visible-session", True)]
    +    assert cli._pending_input.get_nowait() == "completion payload"
    +    assert claimed == [(event, "cli-idle")]
    +    assert completed == [(event, "claim-token")]
    +
    +
    +def test_cli_completion_ownership_rejects_foreign_session():
    +    cli = HermesCLI.__new__(HermesCLI)
    +    cli.session_id = "visible-session"
    +    cli._session_db = None
    +
    +    assert not cli._owns_process_notification(
    +        {"type": "async_delegation", "session_key": "foreign-session"}
    +    )
    +
    +
    +def test_cli_completion_ownership_accepts_compression_lineage():
    +    cli = HermesCLI.__new__(HermesCLI)
    +    cli.session_id = "visible-session"
    +
    +    class FakeSessionDB:
    +        def resolve_resume_session_id(self, session_id):
    +            assert session_id == "pre-compression-session"
    +            return "visible-session"
    +
    +    cli._session_db = FakeSessionDB()
    +
    +    assert cli._owns_process_notification(
    +        {
    +            "type": "async_delegation",
    +            "session_key": "pre-compression-session",
    +        }
    +    )
    ```
  - `tests/tools/test_restored_delegation_ownership.py`

    ```diff
    @@ -0,0 +1,149 @@
    +"""Regression coverage for #64484 — durable-restored delegation completions
    +must never be adopted by a session that cannot positively prove ownership.
    +
    +Layers under test:
    +1. ``restore_undelivered_completions`` stamps every restored event with
    +   ``restored=True`` (in-memory only).
    +2. ``ProcessRegistry.drain_notifications`` with NO filter (legacy
    +   consume-everything CLI path) re-queues restored events instead of
    +   consuming them.
    +3. Same-process (non-restored) keyless events keep the legacy behavior.
    +4. An owner with a matching session_key still receives its restored event.
    +"""
    +
    +import json
    +import queue
    +
    +from tools.process_registry import ProcessRegistry
    +
    +
    +def _make_registry():
    +    reg = ProcessRegistry.__new__(ProcessRegistry)
    +    import threading
    +
    +    reg._running = {}
    +    reg._finished = {}
    +    reg._lock = threading.Lock()
    +    reg.completion_queue = queue.Queue()
    +    reg._completion_consumed = set()
    +    reg._poll_observed = set()
    +    return reg
    +
    +
    +def _delegation_event(session_key="", restored=False, delegation_id="d1"):
    +    evt = {
    +        "type": "async_delegation",
    +        "delegation_id": delegation_id,
    +        "session_key": session_key,
    +        "origin_ui_session_id": "",
    +        "goal": "secret goal",
    +        "status": "success",
    +        "summary": "SECRET RESULT",
    +        "api_calls": 3,
    +        "duration_seconds": 1.5,
    +        "dispatched_at": 1.0,
    +        "completed_at": 2.0,
    +    }
    +    if restored:
    +        evt["restored"] = True
    +    return evt
    +
    +
    +def test_restore_stamps_restored_flag(tmp_path, monkeypatch):
    +    """Every durable completion re-enqueued at startup carries restored=True."""
    +    import tools.async_delegation as ad
    +
    +    monkeypatch.setattr(ad, "_db_path", lambda: tmp_path / "async_delegations.db")
    +    record = {
    +        "delegation_id": "d-old",
    +        "goal": "old goal",
    +        "context": None,
    +        "toolsets": None,
    +        "role": "leaf",
    +        "model": "m",
    +        "session_key": "OLD_SESSION_A",
    +        "origin_ui_session_id": "",
    +        "parent_session_id": "OLD_SESSION_A",
    +        "status": "running",
    +        "dispatched_at": 1.0,
    +        "completed_at": None,
    +        "interrupt_fn": None,
    +    }
    +    ad._persist_dispatch(record)
    +    evt = _delegation_event(session_key="OLD_SESSION_A", delegation_id="d-old")
    +    ad._persist_completion(evt, {"summary": "SECRET RESULT"})
    +
    +    q = queue.Queue()
    +    restored = ad.restore_undelivered_completions(q)
    +    assert restored == 1
    +    got = q.get_nowait()
    +    assert got["restored"] is True
    +    assert got["session_key"] == "OLD_SESSION_A"
    +
    +    # The stamp is in-memory only — the durable payload is unchanged.
    +    with ad._connect() as conn:
    +        row = conn.execute(
    +            "SELECT event_json FROM async_delegations WHERE delegation_id='d-old'"
    +        ).fetchone()
    +    assert "restored" not in json.loads(row[0])
    +
    +
    +def test_unfiltered_drain_never_consumes_restored_events():
    +    """The legacy consume-everything branch must fail closed on restored events."""
    +    reg = _make_registry()
    +    reg.completion_queue.put(_delegation_event(session_key="DEAD_SESSION", restored=True))
    +
    +    results = reg.drain_notifications()  # no filter — legacy CLI post-turn shape
    +
    +    assert results == []
    +    # Still queued for its real owner.
    +    assert reg.completion_queue.qsize() == 1
    +    assert reg.completion_queue.get_nowait()["session_key"] == "DEAD_SESSION"
    +
    +
    +def test_unfiltered_drain_keeps_legacy_behavior_for_same_process_events():
    +    """Non-restored keyless events (created by this process) are still consumed."""
    +    reg = _make_registry()
    +    reg.completion_queue.put(_delegation_event(session_key=""))
    +
    +    results = reg.drain_notifications()
    +
    +    assert len(results) == 1
    +    assert results[0][0]["delegation_id"] == "d1"
    +    assert reg.completion_queue.empty()
    +
    +
    +def test_owner_session_key_drain_consumes_restored_event():
    +    """The owning session (key match) still receives its restored completion."""
    +    reg = _make_registry()
    +    reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True))
    +
    +    results = reg.drain_notifications(session_key="OWNER")
    +
    +    assert len(results) == 1
    +    assert results[0][0]["session_key"] == "OWNER"
    +    assert reg.completion_queue.empty()
    +
    +
    +def test_foreign_session_key_drain_requeues_restored_event():
    +    """A different session's keyed drain must not claim the restored event."""
    +    reg = _make_registry()
    +    reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True))
    +
    +    results = reg.drain_notifications(session_key="SOMEONE_ELSE")
    +
    +    assert results == []
    +    assert reg.completion_queue.qsize() == 1
    +
    +
    +def test_owns_event_callback_beats_restored_flag():
    +    """A positive-proof ownership callback consumes restored events it owns."""
    +    reg = _make_registry()
    +    reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True))
    +
    +    results = reg.drain_notifications(
    +        owns_event=lambda e: e.get("session_key") == "OWNER"
    +    )
    +
    +    assert len(results) == 1
    +    assert reg.completion_queue.empty()
    ```
  - `tools/async_delegation.py`

    ```diff
    @@ -264,7 +264,17 @@ def recover_abandoned_delegations() -> int:
     
     
     def restore_undelivered_completions(target_queue) -> int:
    -    """Enqueue durable pending completions as fresh turns after process start."""
    +    """Enqueue durable pending completions as fresh turns after process start.
    +
    +    Every restored event is stamped ``restored=True`` (in-memory only — the
    +    stamp is added after the durable payload is deserialized and is never
    +    persisted). Restored events originate from a *previous* process, so no
    +    consumer in THIS process implicitly owns them: drain paths that run
    +    without an ownership filter (the legacy single-session behavior) must
    +    leave them queued for a consumer that can positively prove ownership,
    +    otherwise a brand-new session adopts a dead session's delegation
    +    results seconds after boot (#64484).
    +    """
         recover_abandoned_delegations()
         with _DB_LOCK, _connect() as conn:
             rows = conn.execute(
    ```

    ```diff
    @@ -273,7 +283,10 @@ def restore_undelivered_completions(target_queue) -> int:
                    ORDER BY completed_at, delegation_id"""
             ).fetchall()
             for _delegation_id, payload in rows:
    -            target_queue.put(json.loads(payload))
    +            evt = json.loads(payload)
    +            if isinstance(evt, dict):
    +                evt["restored"] = True
    +            target_queue.put(evt)
         return len(rows)
     
     
    ```
  - `tools/delegate_tool.py`

    ```diff
    @@ -2844,6 +2844,20 @@ def _execute_and_aggregate() -> dict:
                         _session_key = _agent_session_id
             except Exception:
                 _origin_ui_session_id = ""
    +        if not _session_key:
    +            # CLI (single-process) path: the approval contextvar is only bound
    +            # during gateway/TUI turns and HERMES_SESSION_KEY is not in the CLI
    +            # environment, so the key resolves empty here. Since #64240 the CLI
    +            # drains completions through a positive-ownership filter keyed on
    +            # the durable AIAgent.session_id — an empty session_key would fail
    +            # closed and the CLI could never claim its own completions, while
    +            # a restored foreign event with an empty key could leak into any
    +            # unfiltered consumer (#64484). Stamp the parent's durable session
    +            # id instead; compression rotations are handled on the drain side
    +            # via resolve_resume_session_id lineage resolution.
    +            _agent_session_id = str(getattr(parent_agent, "session_id", "") or "")
    +            if _agent_session_id:
    +                _session_key = _agent_session_id
             _parent_session_id = getattr(parent_agent, "session_id", None)
             _child_agents = [c for (_, _, c) in children]
     
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -1211,6 +1211,19 @@ def drain_notifications(
                         if evt_session_key != session_key:
                             requeue.append(evt)
                             continue
    +                elif evt.get("restored"):
    +                    # Legacy unfiltered drain (no ownership callback, no
    +                    # session key). That behavior was safe when the in-memory
    +                    # queue could only hold events created by this very
    +                    # process — but durable restore (#63494) re-enqueues
    +                    # completions from PREVIOUS processes at startup, so an
    +                    # unfiltered consumer here would adopt a dead, unrelated
    +                    # session's conversation payload (#64484). Fail closed:
    +                    # leave restored events queued (still 'pending' on disk)
    +                    # for a consumer that can positively prove ownership,
    +                    # e.g. the owning session's --resume.
    +                    requeue.append(evt)
    +                    continue
                 text = format_process_notification(evt)
                 if text:
                     results.append((evt, text))
    ```
- **#65419: fix(tui): route background-process notifications to their owning session (#35652)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "2751738943",  # PR #54785 salvage (tui: post-turn completion ownership routing)
         "[redacted:email]": "Burgunthy",  # PR #20096 salvage (gateway: profile-based routing for inbound messages)
         "[redacted:email]": "webtecnica",  # PR #63360 salvage (nous: restore inference-api base_url)
         "[redacted:email]": "Epoxidex",  # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758)
    ```
  - `tests/test_tui_gateway_server.py`

    ```diff
    @@ -2284,7 +2284,611 @@ def replace_messages(self, key, messages):
             server._sessions.pop("trunc-sid", None)
     
     
    -def test_session_create_does_not_persist_empty_row(monkeypatch):
    +class _StopAfterOneNotificationPoll:
    +    def __init__(self):
    +        self._checks = 0
    +
    +    def is_set(self):
    +        self._checks += 1
    +        return self._checks > 1
    +
    +
    +def test_notification_poller_live_loop_requeues_foreign_completion_for_owner(
    +    monkeypatch,
    +):
    +    """A foreign live-loop dequeue is handed back to its proven owner."""
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    delivered = {"a": [], "b": []}
    +    emitted = []
    +    session_a = _session(session_key="session-a-live-handoff")
    +    session_b = _session(session_key="session-b-live-handoff")
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc-live-handoff",
    +        "session_key": "session-a-live-handoff",
    +        "command": "echo owner",
    +        "exit_code": 0,
    +        "output": "owner",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    monkeypatch.setattr(server, "_get_db", lambda: None)
    +    monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args))
    +
    +    def _deliver(_rid, sid, session, text):
    +        delivered["a" if sid == "sid-a-live-handoff" else "b"].append(text)
    +        session["running"] = False
    +
    +    monkeypatch.setattr(server, "_run_prompt_submit", _deliver)
    +    server._sessions.update(
    +        {
    +            "sid-a-live-handoff": session_a,
    +            "sid-b-live-handoff": session_b,
    +        }
    +    )
    +    process_registry._completion_consumed.discard(event["session_id"])
    +
    +    try:
    +        server._notification_poller_loop(
    +            _StopAfterOneNotificationPoll(), "sid-b-live-handoff", session_b
    +        )
    +
    +        assert delivered["b"] == []
    +        assert emitted == []
    +        assert isolated_queue.qsize() == 1
    +        assert isolated_queue.queue[0] is event
    +
    +        server._notification_poller_loop(
    +            _StopAfterOneNotificationPoll(), "sid-a-live-handoff", session_a
    +        )
    +
    +        assert len(delivered["a"]) == 1
    +        assert "proc-live-handoff completed normally" in delivered["a"][0]
    +        assert delivered["b"] == []
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid-a-live-handoff", None)
    +        server._sessions.pop("sid-b-live-handoff", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +        while not isolated_queue.empty():
    +            isolated_queue.get_nowait()
    +
    +
    +def test_completion_ownership_lineage_lookup_failure_fails_closed(monkeypatch):
    +    """A provenance lookup failure cannot turn an addressed event into ours."""
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    class _BrokenDB:
    +        def resolve_resume_session_id(self, _session_key):
    +            raise RuntimeError("lineage database unavailable")
    +
    +    session = _session(session_key="unrelated-live-session")
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc-unknown-lineage",
    +        "session_key": "unknown-parent",
    +        "command": "echo unknown",
    +        "exit_code": 0,
    +        "output": "unknown",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB())
    +
    +    drained = process_registry.drain_notifications(
    +        session_key="unrelated-live-session",
    +        owns_event=lambda candidate: server._session_owns_notification_event(
    +            "sid-unrelated-live", session, candidate
    +        ),
    +    )
    +
    +    assert drained == []
    +    assert isolated_queue.qsize() == 1
    +    assert isolated_queue.get_nowait() is event
    +
    +
    [redacted:email](
    +    "routing",
    +    [
    +        {"session_key": "missing-owner-key"},
    +        {"origin_ui_session_id": "missing-owner-sid"},
    +    ],
    +)
    +def test_notification_poller_live_loop_drops_addressed_orphan(
    +    monkeypatch, routing
    +):
    +    """A live poll never injects an addressed event whose owner is gone."""
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    delivered = []
    +    emitted = []
    +    session = _session(session_key="unrelated-live-key")
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc-live-orphan",
    +        "command": "echo orphan",
    +        "exit_code": 0,
    +        "output": "orphan",
    +        **routing,
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    monkeypatch.setattr(server, "_get_db", lambda: None)
    +    monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args))
    +    monkeypatch.setattr(
    +        server,
    +        "_run_prompt_submit",
    +        lambda _rid, _sid, _session, text: delivered.append(text),
    +    )
    +    server._sessions["sid-live-orphan"] = session
    +    process_registry._completion_consumed.discard(event["session_id"])
    +
    +    try:
    +        server._notification_poller_loop(
    +            _StopAfterOneNotificationPoll(), "sid-live-orphan", session
    +        )
    +
    +        assert delivered == []
    +        assert emitted == []
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid-live-orphan", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +        while not isolated_queue.empty():
    +            isolated_queue.get_nowait()
    +
    +
    [redacted:email](
    +    "routing",
    +    [
    +        {"session_key": "session-b"},
    +        {"origin_ui_session_id": "sid_gone"},
    +    ],
    +)
    +def test_notification_poller_drops_orphaned_events(monkeypatch, routing):
    +    """Addressed completions whose owner is gone are dropped, not hijacked."""
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    emitted = []
    +    delivered = []
    +    sess = _session(session_key="session-a")
    +    server._sessions["sid_a"] = sess
    +    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    +    monkeypatch.setattr(
    +        server,
    +        "_run_prompt_submit",
    +        lambda _rid, _sid, _session, text: delivered.append(text),
    +    )
    +    monkeypatch.setattr(server, "_get_db", lambda: None)
    +
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    process_registry._completion_consumed.discard("proc_ghost")
    +    isolated_queue.put(
    +        {
    +            "type": "completion",
    +            "session_id": "proc_ghost",
    +            "command": "echo from ghost",
    +            "exit_code": 0,
    +            "output": "ghost output",
    +            **routing,
    +        }
    +    )
    +
    +    stop = threading.Event()
    +    stop.set()
    +
    +    try:
    +        server._notification_poller_loop(stop, "sid_a", sess)
    +
    +        assert [a for a in emitted if a[0] == "status.update"] == []
    +        assert delivered == []
    +    finally:
    +        server._sessions.pop("sid_a", None)
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
    [redacted:email](
    +    ("routing", "resolved_key"),
    +    [
    +        ({"session_key": "session-a"}, None),
    +        (
    +            {
    +                "session_key": "stale-durable-key",
    +                "origin_ui_session_id": "sid_a",
    +            },
    +            None,
    +        ),
    +        ({"session_key": "old-parent-key"}, "session-a"),
    +    ],
    +)
    +def test_notification_poller_delivers_owned_events(
    +    monkeypatch, routing, resolved_key
    +):
    +    """Direct, UI-origin, and compression-lineage owners are delivered."""
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    class _CompressionDB:
    +        def resolve_resume_session_id(self, key):
    +            return resolved_key if key == "old-parent-key" and resolved_key else key
    +
    +    delivered = []
    +    emitted = []
    +    sess = _session(session_key="session-a")
    +    server._sessions["sid_a"] = sess
    +    monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
    +    monkeypatch.setattr(
    +        server,
    +        "_run_prompt_submit",
    +        lambda _rid, _sid, _session, text: delivered.append(text),
    +    )
    +    monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB())
    +
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    process_registry._completion_consumed.discard("proc_mine")
    +    isolated_queue.put(
    +        {
    +            "type": "completion",
    +            "session_id": "proc_mine",
    +            "command": "echo mine",
    +            "exit_code": 0,
    +            "output": "mine",
    +            **routing,
    +        }
    +    )
    +
    +    stop = threading.Event()
    +    stop.set()
    +
    +    try:
    +        server._notification_poller_loop(stop, "sid_a", sess)
    +
    +        status_calls = [a for a in emitted if a[0] == "status.update"]
    +        assert len(status_calls) == 1
    +        assert status_calls[0][2]["kind"] == "process"
    +        assert len(delivered) == 1
    +        assert "proc_mine" in delivered[0]
    +    finally:
    +        server._sessions.pop("sid_a", None)
    +        while not process_registry.completion_queue.empty():
    +            process_registry.completion_queue.get_nowait()
    +
    +
    +def _configure_immediate_prompt_run(
    +    monkeypatch, tmp_path, *, immediate_threads=True
    +):
    +    class _ImmediateThread:
    +        def __init__(self, target=None, daemon=None, **_kwargs):
    +            self._target = target
    +
    +        def start(self):
    +            if self._target is not None:
    +                self._target()
    +
    +        def is_alive(self):
    +            return False
    +
    +    if immediate_threads:
    +        monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
    +    monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None)
    +    monkeypatch.setattr(server, "make_stream_renderer", lambda _cols: None)
    +    monkeypatch.setattr(server, "render_message", lambda _raw, _cols: None)
    +    monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
    +    monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda *_args: None)
    +    monkeypatch.setattr(server, "_session_cwd", lambda _session: str(tmp_path))
    +    monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)
    +    monkeypatch.setattr(server, "_set_session_context", lambda *_args, **_kwargs: [])
    +    monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None)
    +    monkeypatch.setattr(server, "_session_info", lambda *_args: {})
    +    monkeypatch.setattr(server, "_get_usage", lambda _agent: {})
    +    monkeypatch.setattr(
    +        server, "_sync_session_key_after_compress", lambda *_args, **_kwargs: None
    +    )
    +    monkeypatch.setattr(server, "_drain_queued_prompt", lambda *_args: False)
    +    monkeypatch.setattr(server, "_voice_tts_enabled", lambda: False)
    +    monkeypatch.setattr(server, "_get_db", lambda: None)
    +
    +
    +class _RecordingAgent:
    +    model = "test-model"
    +    provider = "test-provider"
    +
    +    def __init__(self, turns):
    +        self._turns = turns
    +
    +    def clear_interrupt(self):
    +        return None
    +
    +    def run_conversation(
    +        self, prompt, conversation_history=None, stream_callback=None
    +    ):
    +        self._turns.append(prompt)
    +        return {"final_response": "", "messages": []}
    +
    +
    [redacted:email]("exit_code", [0, 7])
    +def test_run_prompt_submit_requeues_foreign_completion(
    +    monkeypatch, tmp_path, exit_code
    +):
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    +    turns = []
    +    session_a = _session(session_key="session-a")
    +    session_b = _session(
    +        session_key="session-b",
    +        agent=_RecordingAgent(turns),
    +        running=True,
    +    )
    +    event = {
    +        "type": "completion",
    +        "session_id": f"proc_foreign_{exit_code}",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": exit_code,
    +        "output": "foreign",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    server._sessions["sid_a"] = session_a
    +    server._sessions["sid_b"] = session_b
    +
    +    try:
    +        server._run_prompt_submit("rid-b", "sid_b", session_b, "session-b-turn")
    +
    +        assert turns == ["session-b-turn"]
    +        assert isolated_queue.get_nowait() == event
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid_a", None)
    +        server._sessions.pop("sid_b", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +
    +
    +def test_run_prompt_submit_delivers_completion_observed_by_poll(monkeypatch, tmp_path):
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    +    turns = []
    +    session = _session(
    +        session_key="session-a",
    +        agent=_RecordingAgent(turns),
    +        running=True,
    +    )
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_polled",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "observed but not consumed",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    process_registry._completion_consumed.discard(event["session_id"])
    +    process_registry._poll_observed.add(event["session_id"])
    +    server._sessions["sid_a"] = session
    +
    +    try:
    +        server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn")
    +
    +        assert turns[0] == "session-a-turn"
    +        assert len(turns) == 2
    +        assert "proc_polled" in turns[1]
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid_a", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +        process_registry._poll_observed.discard(event["session_id"])
    +
    +
    +def test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading(
    +    monkeypatch, tmp_path
    +):
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    _configure_immediate_prompt_run(
    +        monkeypatch, tmp_path, immediate_threads=False
    +    )
    +    real_thread_class = threading.Thread
    +    threads = []
    +    nested_started = threading.Event()
    +    release_nested = threading.Event()
    +    turns = []
    +
    +    def _recording_thread(*args, **kwargs):
    +        thread = real_thread_class(*args, **kwargs)
    +        threads.append(thread)
    +        return thread
    +
    +    class _BlockingNotificationAgent(_RecordingAgent):
    +        def run_conversation(
    +            self, prompt, conversation_history=None, stream_callback=None
    +        ):
    +            turns.append(prompt)
    +            if "proc_batch_1" in prompt:
    +                nested_started.set()
    +                if not release_nested.wait(timeout=5):
    +                    raise TimeoutError("notification turn was not released")
    +            return {"final_response": "", "messages": []}
    +
    +    monkeypatch.setattr(server.threading, "Thread", _recording_thread)
    +    session = _session(
    +        session_key="session-a",
    +        agent=_BlockingNotificationAgent(turns),
    +        running=True,
    +    )
    +    events = [
    +        {
    +            "type": "completion",
    +            "session_id": f"proc_batch_{index}",
    +            "session_key": "session-a",
    +            "command": "safe-test-command",
    +            "exit_code": 0,
    +            "output": f"owned-{index}",
    +        }
    +        for index in range(1, 4)
    +    ]
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    for event in events:
    +        isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    server._sessions["sid_a"] = session
    +
    +    try:
    +        server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn")
    +
    +        assert nested_started.wait(timeout=5)
    +        threads[0].join(timeout=5)
    +        assert not threads[0].is_alive()
    +        queued = []
    +        while not isolated_queue.empty():
    +            queued.append(isolated_queue.get_nowait())
    +        assert [event["session_id"] for event in queued] == [
    +            "proc_batch_2",
    +            "proc_batch_3",
    +        ]
    +    finally:
    +        release_nested.set()
    +        for thread in threads:
    +            thread.join(timeout=5)
    +        server._sessions.pop("sid_a", None)
    +        while not isolated_queue.empty():
    +            isolated_queue.get_nowait()
    +        for event in events:
    +            process_registry._completion_consumed.discard(event["session_id"])
    +            process_registry._poll_observed.discard(event["session_id"])
    +
    +
    +def test_run_prompt_submit_delivers_completion_owned_through_compression_lineage(
    +    monkeypatch, tmp_path
    +):
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    class _CompressionDB:
    +        def resolve_resume_session_id(self, key):
    +            return "new-child-key" if key == "old-parent-key" else key
    +
    +    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    +    monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB())
    +    ownership_checks = []
    +    original_owns_event = server._session_owns_notification_event
    +
    +    def _record_ownership_check(sid, checked_session, checked_event):
    +        ownership_checks.append(checked_event["session_id"])
    +        return original_owns_event(sid, checked_session, checked_event)
    +
    +    monkeypatch.setattr(
    +        server, "_session_owns_notification_event", _record_ownership_check
    +    )
    +    turns = []
    +    session = _session(
    +        session_key="new-child-key",
    +        agent=_RecordingAgent(turns),
    +        running=True,
    +    )
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_precompression",
    +        "session_key": "old-parent-key",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "owned",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    server._sessions["sid_b"] = session
    +
    +    try:
    +        server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn")
    +
    +        assert turns[0] == "session-b-turn"
    +        assert len(turns) == 2
    +        assert "proc_precompression" in turns[1]
    +        assert ownership_checks == ["proc_precompression"]
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid_b", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +
    +
    +def test_run_prompt_submit_prefers_origin_ui_session_id(monkeypatch, tmp_path):
    +    import queue as _queue_mod
    +
    +    from tools.process_registry import process_registry
    +
    +    _configure_immediate_prompt_run(monkeypatch, tmp_path)
    +    ownership_checks = []
    +    original_owns_event = server._session_owns_notification_event
    +
    +    def _record_ownership_check(sid, checked_session, checked_event):
    +        ownership_checks.append(checked_event["session_id"])
    +        return original_owns_event(sid, checked_session, checked_event)
    +
    +    monkeypatch.setattr(
    +        server, "_session_owns_notification_event", _record_ownership_check
    +    )
    +    turns = []
    +    session = _session(
    +        session_key="current-key",
    +        agent=_RecordingAgent(turns),
    +        running=True,
    +    )
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_origin_owned",
    +        "session_key": "stale-durable-key",
    +        "origin_ui_session_id": "sid_b",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "owned",
    +    }
    +    isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
    +    isolated_queue.put(event)
    +    monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
    +    server._sessions["sid_b"] = session
    +
    +    try:
    +        server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn")
    +
    +        assert turns[0] == "session-b-turn"
    +        assert len(turns) == 2
    +        assert "proc_origin_owned" in turns[1]
    +        assert ownership_checks == ["proc_origin_owned"]
    +        assert isolated_queue.empty()
    +    finally:
    +        server._sessions.pop("sid_b", None)
    +        process_registry._completion_consumed.discard(event["session_id"])
    +
    +
    +
         """session.create must NOT eagerly write a DB row.
     
         Every TUI/desktop launch opens a session here just to paint the composer;
    ```
  - `tests/tools/test_process_registry.py`

    ```diff
    @@ -1336,6 +1336,66 @@ def test_drain_notifications_skips_consumed():
                 process_registry.completion_queue.get_nowait()
     
     
    +def test_drain_notifications_can_deliver_poll_observed_for_gateway(registry):
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_polled",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "observed but not consumed",
    +    }
    +    registry._poll_observed.add(event["session_id"])
    +    registry.completion_queue.put(event)
    +
    +    try:
    +        results = registry.drain_notifications(
    +            session_key="session-a",
    +            owns_event=lambda _event: True,
    +            skip_poll_observed=False,
    +        )
    +
    +        assert [raw for raw, _ in results] == [event]
    +    finally:
    +        registry._poll_observed.discard(event["session_id"])
    +
    +
    [redacted:email](
    +    "skip_state", ["_poll_observed", "_completion_consumed"]
    +)
    +def test_drain_notifications_routes_foreign_before_local_skip(
    +    registry, skip_state
    +):
    +    event = {
    +        "type": "completion",
    +        "session_id": f"proc_foreign_{skip_state}",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "foreign",
    +    }
    +    ownership_calls = []
    +    getattr(registry, skip_state).add(event["session_id"])
    +    registry.completion_queue.put(event)
    +
    +    def owns_event(checked_event):
    +        ownership_calls.append(checked_event)
    +        return False
    +
    +    try:
    +        results = registry.drain_notifications(
    +            session_key="session-b",
    +            owns_event=owns_event,
    +        )
    +
    +        assert results == []
    +        assert ownership_calls == [event]
    +        assert registry.completion_queue.get_nowait() == event
    +        assert registry.completion_queue.empty()
    +    finally:
    +        getattr(registry, skip_state).discard(event["session_id"])
    +
    +
     def test_drain_notifications_empty_queue():
         from tools.process_registry import process_registry
     
    ```

    ```diff
    @@ -1346,6 +1406,151 @@ def test_drain_notifications_empty_queue():
         assert results == []
     
     
    [redacted:email]("exit_code", [0, 7])
    +def test_drain_notifications_filters_addressed_completion_by_owns_event(
    +    registry, exit_code
    +):
    +    owned = {
    +        "type": "completion",
    +        "session_id": f"proc_owned_{exit_code}",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": exit_code,
    +        "output": "owned",
    +    }
    +    foreign = {
    +        "type": "completion",
    +        "session_id": f"proc_foreign_{exit_code}",
    +        "session_key": "session-b",
    +        "command": "safe-test-command",
    +        "exit_code": exit_code,
    +        "output": "foreign",
    +    }
    +    registry.completion_queue.put(owned)
    +    registry.completion_queue.put(foreign)
    +
    +    results = registry.drain_notifications(
    +        session_key="session-a",
    +        owns_event=lambda event: event.get("session_key") == "session-a",
    +    )
    +
    +    assert [event["session_id"] for event, _ in results] == [
    +        f"proc_owned_{exit_code}"
    +    ]
    +    assert registry.completion_queue.get_nowait() == foreign
    +    assert registry.completion_queue.empty()
    +
    +
    +def test_drain_notifications_filters_addressed_completion_by_session_key(registry):
    +    owned = {
    +        "type": "completion",
    +        "session_id": "proc_owned",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "owned",
    +    }
    +    foreign = {
    +        "type": "completion",
    +        "session_id": "proc_foreign",
    +        "session_key": "session-b",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "foreign",
    +    }
    +    registry.completion_queue.put(owned)
    +    registry.completion_queue.put(foreign)
    +
    +    results = registry.drain_notifications(session_key="session-a")
    +
    +    assert [event["session_id"] for event, _ in results] == ["proc_owned"]
    +    assert registry.completion_queue.get_nowait() == foreign
    +    assert registry.completion_queue.empty()
    +
    +
    +def test_drain_notifications_session_key_filter_requeues_origin_only_event(registry):
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_origin_only",
    +        "origin_ui_session_id": "ui-session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "done",
    +    }
    +    registry.completion_queue.put(event)
    +
    +    results = registry.drain_notifications(session_key="session-a")
    +
    +    assert results == []
    +    assert registry.completion_queue.get_nowait() == event
    +    assert registry.completion_queue.empty()
    +
    +
    +def test_drain_notifications_ownerless_completion_preserves_legacy_delivery(registry):
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_ownerless",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "ownerless",
    +    }
    +    registry.completion_queue.put(event)
    +
    +    results = registry.drain_notifications(
    +        session_key="session-a",
    +        owns_event=lambda _event: False,
    +    )
    +
    +    assert [raw for raw, _ in results] == [event]
    +    assert registry.completion_queue.empty()
    +
    +
    +def test_drain_notifications_ownerless_async_delegation_still_requires_proof(registry):
    +    event = {
    +        "type": "async_delegation",
    +        "delegation_id": "deleg_ownerless",
    +        "goal": "task",
    +        "status": "completed",
    +        "summary": "done",
    +        "api_calls": 1,
    +        "duration_seconds": 0.1,
    +    }
    +    registry.completion_queue.put(event)
    +
    +    results = registry.drain_notifications(
    +        session_key="session-a",
    +        owns_event=lambda _event: False,
    +    )
    +
    +    assert results == []
    +    assert registry.completion_queue.get_nowait() == event
    +    assert registry.completion_queue.empty()
    +
    +
    +def test_drain_notifications_completion_callback_exception_fails_closed(registry):
    +    event = {
    +        "type": "completion",
    +        "session_id": "proc_callback_error",
    +        "session_key": "session-a",
    +        "command": "safe-test-command",
    +        "exit_code": 0,
    +        "output": "done",
    +    }
    +    registry.completion_queue.put(event)
    +
    +    def broken(_event):
    +        raise RuntimeError("ownership check exploded")
    +
    +    results = registry.drain_notifications(
    +        session_key="session-a",
    +        owns_event=broken,
    +    )
    +
    +    assert results == []
    +    assert registry.completion_queue.get_nowait() == event
    +    assert registry.completion_queue.empty()
    +
    +
     def test_drain_notifications_filters_async_delegation_by_session_key():
         """Async-delegation events should only be consumed by the matching session's drain.
     
    ```
  - `tools/process_registry.py`

    ```diff
    @@ -1146,8 +1146,10 @@ def is_session_waiting(self, session_id: str) -> bool:
                     return False
             return True
     
    -    def _drain_should_skip(self, session_id: str) -> bool:
    -        """Whether the CLI drain should skip a completion event for this session.
    +    def _drain_should_skip(
    +        self, session_id: str, *, skip_poll_observed: bool = True
    +    ) -> bool:
    +        """Whether this drain should skip a completion event for this session.
     
             Skips when the agent has either truly consumed the output (wait/log →
             ``_completion_consumed``) or observed the exit inline via poll()
    ```

    ```diff
    @@ -1157,32 +1159,45 @@ def _drain_should_skip(self, session_id: str) -> bool:
             check only ``is_completion_consumed`` so a read-only poll never
             suppresses their autonomous delivery turn (#10156).
             """
    -        return session_id in self._completion_consumed or session_id in self._poll_observed
    +        return session_id in self._completion_consumed or (
    +            skip_poll_observed and session_id in self._poll_observed
    +        )
     
         def drain_notifications(
    -        self, session_key: str = "", owns_event=None,
    +        self,
    +        session_key: str = "",
    +        owns_event=None,
    +        *,
    +        skip_poll_observed: bool = True,
         ) -> "list[tuple[dict, str]]":
             """Pop all pending notification events and return formatted pairs.
     
             Returns a list of (raw_event, formatted_text) tuples.
             Skips completion events the agent already consumed via wait/log or
    -        observed inline via poll() (see ``_drain_should_skip``).
    +        observed inline via poll() (see ``_drain_should_skip``). Gateway/TUI
    +        callers pass ``skip_poll_observed=False`` because read-only polling must
    +        not suppress autonomous delivery there.
     
    -        Async-delegation events carry a conversation payload, so draining one
    -        into the wrong session is a cross-chat leak (#58684, #55578). Two
    -        filter modes, strongest wins:
    +        When a routing filter is supplied, addressed notifications must not be
    +        drained into the wrong session. Async-delegation events always require
    +        conversation payload; ordinary notifications require routing when they
    +        carry ``session_key`` or ``origin_ui_session_id`` metadata. Two filter
    +        modes are supported, strongest first:
     
             - ``owns_event(evt) -> bool``: positive-proof ownership callback.
    -          When provided, an async-delegation event is consumed ONLY if the
    -          callback returns True; everything else is re-queued for its owner.
    +          When provided, a routed event is consumed ONLY if the callback
    +          returns True; everything else is re-queued for its owner.
               The TUI passes its compression-chain-aware ownership check here so
               a post-compression session still claims its own pre-compression
               dispatches.
             - ``session_key``: plain key equality (CLI and other single-session
    -          callers). Non-matching async-delegation events are re-queued.
    +          callers). Non-matching addressed events are re-queued.
     
             With neither set, all events are consumed (legacy single-session
    -        behavior, backward compatible).
    +        behavior, backward compatible). Ownerless ordinary notifications also
    +        retain that legacy behavior even when a filter is provided. When a
    +        filter is provided, ownerless async-delegation events remain
    +        fail-closed and require positive proof.
             """
             results: "list[tuple[dict, str]]" = []
             requeue: "list[dict]" = []
    ```

    ```diff
    @@ -1191,39 +1206,43 @@ def drain_notifications(
                     evt = self.completion_queue.get_nowait()
                 except Exception:
                     break
    -            _evt_sid = evt.get("session_id", "")
    -            if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid):
    -                continue
    -            # Filter async-delegation events so they are not delivered to the
    -            # wrong session/thread (#58684). Positive-proof callback beats
    -            # bare key equality when the caller can provide one.
    -            if evt.get("type") == "async_delegation":
    -                if owns_event is not None:
    -                    try:
    -                        owned = bool(owns_event(evt))
    -                    except Exception:
    -                        owned = False  # fail closed — never leak on a broken check
    -                    if not owned:
    -                        requeue.append(evt)
    -                        continue
    -                elif session_key:
    -                    evt_session_key = evt.get("session_key", "") or ""
    -                    if evt_session_key != session_key:
    -                        requeue.append(evt)
    -                        continue
    -                elif evt.get("restored"):
    -                    # Legacy unfiltered drain (no ownership callback, no
    -                    # session key). That behavior was safe when the in-memory
    -                    # queue could only hold events created by this very
    -                    # process — but durable restore (#63494) re-enqueues
    -                    # completions from PREVIOUS processes at startup, so an
    -                    # unfiltered consumer here would adopt a dead, unrelated
    -                    # session's conversation payload (#64484). Fail closed:
    -                    # leave restored events queued (still 'pending' on disk)
    -                    # for a consumer that can positively prove ownership,
    -                    # e.g. the owning session's --resume.
    +            # Positive-proof ownership beats bare key equality. Delegation
    +            # payloads always require proof; ordinary events require it once
    +            # they carry routing metadata. Ownerless ordinary events preserve
    +            # legacy single-session delivery.
    +            is_async_delegation = evt.get("type") == "async_delegation"
    +            evt_session_key = str(evt.get("session_key") or "")
    +            evt_origin_sid = str(evt.get("origin_ui_session_id") or "")
    +            requires_positive_proof = is_async_delegation or bool(
    +                evt_session_key or evt_origin_sid
    +            )
    +            if owns_event is not None and requires_positive_proof:
    +                try:
    +                    owned = bool(owns_event(evt))
    +                except Exception:
    +                    owned = False  # fail closed — never leak on a broken check
    +                if not owned:
    +                    requeue.append(evt)
    +                    continue
    +            elif session_key and requires_positive_proof:
    +                if evt_session_key != session_key:
                         requeue.append(evt)
                         continue
    +            elif is_async_delegation and evt.get("restored"):
    +                # Durable restore can enqueue previous-process payloads into a
    +                # fresh registry. An unfiltered legacy drain cannot prove
    +                # ownership, so leave those events queued for the owner.
    +                requeue.append(evt)
    +                continue
    +            # Local consumed/observed state may suppress only events this
    +            # session owns (or legacy ownerless ordinary events). Routing must
    +            # happen first so a foreign session cannot drop the owner's event.
    +            _evt_sid = evt.get("session_id", "")
    +            if evt.get("type") == "completion" and self._drain_should_skip(
    +                _evt_sid, skip_poll_observed=skip_poll_observed
    +            ):
    +                continue
    +
                 text = format_process_notification(evt)
                 if text:
                     results.append((evt, text))
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -8652,9 +8652,9 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool
         minus its orphan-adoption fallback. An event owns-matches when its
         ``origin_ui_session_id`` is this live session, or its ``session_key``
         (raw or resolved through the compression chain) matches this session's
    -    key/lineage. Used as a fail-closed gate for async-delegation payloads:
    -    "not provably elsewhere" is NOT good enough to inject a conversation
    -    payload into this chat (#55578).
    +    key/lineage. Used as the fail-closed gate for every addressed notification:
    +    "not provably elsewhere" is NOT good enough to inject a payload into this
    +    chat (#55578).
         """
         if session.get("_finalized"):
             return False
    ```

    ```diff
    @@ -8679,6 +8679,14 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool
         return resolved_key in current_keys
     
     
    +def _notification_event_requires_owner(evt: dict) -> bool:
    +    """Whether ``evt`` must be positively claimed before TUI delivery."""
    +    return evt.get("type") == "async_delegation" or bool(
    +        str(evt.get("origin_ui_session_id") or "")
    +        or str(evt.get("session_key") or "")
    +    )
    +
    +
     def _notification_event_dedup_key(evt: dict) -> tuple:
         """Return the UI-emission identity for a process notification event.
     
    ```

    ```diff
    @@ -8725,10 +8733,9 @@ def _notification_poller_loop(
         status.update (kind=process) for user visibility, then chains an
         agent turn via _run_prompt_submit if the session is idle.
     
    -    NOTE: The completion_queue is global (one per process). If multiple
    -    TUI sessions coexist, whichever poller wakes first grabs the event,
    -    even if the process was started by a different session. This matches
    -    CLI/gateway behavior (single session per process).
    +    The completion_queue is process-global. In multi-session Desktop each
    +    poller requeues events owned by another live session and drops addressed
    +    events whose owner is gone; ownerless legacy notifications remain global.
         """
         from tools.process_registry import process_registry, format_process_notification
     
    ```

    ```diff
    @@ -8749,26 +8756,22 @@ def _notification_poller_loop(
                 time.sleep(0.1)
                 continue
     
    -        # Fail closed for async-delegation results (#55578): these carry a
    -        # conversation payload, and injecting one into any chat other than the
    -        # one that commissioned it is a hard cross-session leak. The
    -        # belongs-elsewhere check above already re-queued events owned by
    -        # another LIVE session; what reaches here is either ours or an
    -        # orphan whose owner is gone. Orphaned delegation payloads are
    -        # DROPPED, not adopted — the subagent's summary is already persisted
    -        # in the delegation records/output store, so nothing is lost, whereas
    -        # a wrong-chat injection is unrecoverable. Non-delegation events
    -        # (background process completions etc.) keep the historical
    -        # adopt-orphans behavior.
    -        if evt.get("type") == "async_delegation" and not _session_owns_notification_event(
    -            sid, session, evt
    -        ):
    -            logger.warning(
    -                "async-delegation completion %s has no live owner "
    -                "(origin=%r key=%r); dropping from injection instead of "
    -                "delivering to session %s (#55578 fail-closed; result "
    -                "remains in the delegation records)",
    -                evt.get("delegation_id", "?"),
    +        # What reaches here is not owned by another LIVE session. Addressed
    +        # events still require positive proof before injection: exact UI origin,
    +        # direct durable key, or compression lineage. If none proves ownership,
    +        # the event is orphaned and must not be adopted by this chat. Truly
    +        # ownerless ordinary notifications retain legacy global delivery.
    +        requires_owner = _notification_event_requires_owner(evt)
    +        if requires_owner and not _session_owns_notification_event(sid, session, evt):
    +            log = (
    +                logger.warning
    +                if evt.get("type") == "async_delegation"
    +                else logger.debug
    +            )
    +            log(
    +                "Dropping unowned %s notification (origin=%r key=%r) instead "
    +                "of delivering to session %s",
    +                evt.get("type", "completion"),
                     str(evt.get("origin_ui_session_id") or ""),
                     str(evt.get("session_key") or ""),
                     sid,
    ```

    ```diff
    @@ -8830,6 +8833,7 @@ def _notification_poller_loop(
         # Drain any remaining events after stop signal (process all pending
         # before exiting so nothing is lost on shutdown). Events owned by other
         # live sessions are set aside and re-queued so their poller still sees them.
    +    # Orphaned events (owner gone) are dropped — same guard as the main loop.
         deferred: list = []
         while not process_registry.completion_queue.empty():
             try:
    ```

    ```diff
    @@ -8839,14 +8843,21 @@ def _notification_poller_loop(
             if _notification_event_belongs_elsewhere(sid, session, evt):
                 deferred.append(evt)
                 continue
    -        # Same fail-closed rule as the live loop: an orphaned async-delegation
    -        # payload is never adopted by a foreign session — defer it (a later
    -        # resume of the owner's lineage can still claim it) rather than
    -        # injecting another chat's conversation here (#55578).
    -        if evt.get("type") == "async_delegation" and not _session_owns_notification_event(
    -            sid, session, evt
    -        ):
    -            deferred.append(evt)
    +        # Same positive-proof rule as the live loop. Preserve the existing
    +        # shutdown behavior for orphaned delegation payloads by deferring them
    +        # for a later resume; ordinary addressed orphans are dropped.
    +        requires_owner = _notification_event_requires_owner(evt)
    +        if requires_owner and not _session_owns_notification_event(sid, session, evt):
    +            if evt.get("type") == "async_delegation":
    +                deferred.append(evt)
    +            else:
    +                logger.debug(
    +                    "Dropping unowned %s notification during shutdown drain "
    +                    "(origin=%r key=%r)",
    +                    evt.get("type", "completion"),
    +                    str(evt.get("origin_ui_session_id") or ""),
    +                    str(evt.get("session_key") or ""),
    +                )
                 continue
             _evt_sid = evt.get("session_id", "")
             if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid):
    ```

    ```diff
    @@ -9411,21 +9422,29 @@ def _stream(delta):
             # Drain completion notifications that arrived during this turn.
             # The background poller handles between-turn delivery; this is
             # the safety net for events that arrived mid-turn.
    +        #
    +        # Ownership filter (#42674, #35652): a turn finishing in session B
    +        # must not consume an event that belongs to session A. The registry
    +        # requeues every addressed event this session cannot positively claim;
    +        # the poller then delivers it to a live owner or drops an orphan.
             try:
                 from tools.process_registry import process_registry
     
                 # Positive-proof ownership (compression-chain aware) — the same
                 # fail-closed gate the poller uses, so the post-turn drain can't
    -            # adopt another session's (or an orphan's) delegation payload,
    -            # while a post-compression session still claims its own
    -            # pre-compression dispatches (#55578).
    -            for _evt, synth in process_registry.drain_notifications(
    +            # adopt another session's addressed notification while a
    +            # post-compression session still claims its own pre-compression
    +            # dispatches (#55578).
    +            drained = process_registry.drain_notifications(
                     session_key=session.get("session_key", ""),
                     owns_event=lambda e: _session_owns_notification_event(sid, session, e),
    -            ):
    +                skip_poll_observed=False,
    +            )
    +            for index, (_evt, synth) in enumerate(drained):
                     with session["history_lock"]:
                         if session.get("running"):
    -                        process_registry.completion_queue.put(_evt)
    +                        for pending_evt, _pending_synth in drained[index:]:
    +                            process_registry.completion_queue.put(pending_evt)
                             break
                         session["running"] = True
                     from tools.async_delegation import (
    ```
- **#1573: fix(gateway): persist watcher metadata in checkpoint for crash recovery**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#1706`
- **#7391: fix(gateway): replace os.environ session state with contextvars to prevent concurrent message cross-contamination**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7454`
- **#9537: fix(gateway): route synthetic background events to their originating session**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#10460`, `#10446`
- **#10446: fix(gateway): route watch-pattern notifications to originating session**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#10460`, `#9537`
- **#12918: fix(gateway): route watch notifications to correct Discord thread (#10411)**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `dee592a0b`
  - files: `gateway/run.py`, `tests/gateway/test_watch_notification_thread_routing.py`
- **#18337: fix: resolve 7 identified issues \[automated\]**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `88bd1c01e`, `9caa12f4e`
  - files: `agent/auxiliary_client.py`, `gateway/platforms/email.py`, `gateway/run.py`, `hermes_cli/profiles.py`, `optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py`, `tools/approval.py`, `tools/skills_tool.py`
- **#26945: fix(telegram): preserve DM topic routing for synthetic background process events**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#27212`, `#9532`, `#27212`, `#27937`, `#9532`, `#27212`
- **#27212: fix(telegram): reply-anchor background-process notifications into DM topics**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#26945`
- **#35667: fix(tui): route completion notifications to owner live session**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#60863`
- **#35672: fix(tui): route process notifications to owner session**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `85b65e29f`, `#37099`, `#35667`
- **#42731: fix(tui): keep background notifications in owning session (#42674)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#65419`
- **#57535: fix(delegate): pin async completion to spawning parent session (#57498)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#60871`
- **#57586: fix(tui): route async delegation results across the compression chain**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#60863`, `#59767`
- **#58690: fix(delegation): route async delegate_task results back to originating session**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#60869`
- **#60441: fix(gateway): prevent background process notifications from being injected into wrong sessions**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `dee592a0b1c4ce7ae65a5bea3e996dc42a817308`, `#60426`, `#42674`
- **#61719: fix(tui): isolate background notifications by UI session**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `54d0948d3`, `#54785`, `#73351`, `#73351`
  - files: `gateway/session_context.py`, `tests/test_tui_gateway_server.py`, `tests/tools/test_notify_on_complete.py`, `tests/tools/test_process_registry.py`, `tests/tools/test_terminal_task_cwd.py`, `tests/tools/test_watch_patterns.py`, `tests/tools/test_zombie_process_cleanup.py`, `tools/delegate_tool.py`, `tools/process_registry.py`, `tools/terminal_tool.py`, `tui_gateway/server.py`
- **#65409: fix(session): prefer ContextVar over process env for session identity**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `agent/background_review.py`, `agent/conversation_compression.py`, `agent/prompt_builder.py`, `agent/skill_commands.py`, `agent/skill_utils.py`, `gateway/session_context.py`, `hermes_cli/cli_commands_mixin.py`, `hermes_cli/cli_session_mixin.py`, `tests/gateway/test_session_env_contextvar_priority.py`, `tests/tools/test_kanban_tools.py`, `tools/kanban_tools.py`, `tools/skills_tool.py`

## Fix status

- Merged to main in PR #1706 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `agent`
