# NousResearch/hermes-agent · Slack assistant 'is thinking' status stuck; related Slack fixes

## Open now

- Nothing open on GitHub.

Most members concern the Slack 'is thinking...' assistant status remaining visible, including when an agent ends without replying and around /stop and clarify handling. One member, about Agent View workspace routing, appears to be a related Slack change but not clearly the same issue.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #64621. Verify it addresses your case.
- **Recommended:** read PR #64621 (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: `resolved` · deciding PR(s): `pr-64621`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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.

## Closed / duplicate issues

- #32295: Slack: "is thinking..." status indicator gets stuck when agent ends without sending a reply

## Prior & competing PRs

- **#63709: fix(slack): complete Agent View workspace routing**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/base.py`, `gateway/run.py`, `hermes_cli/slack_cli.py`, `hermes_cli/subcommands/slack.py`, `plugins/platforms/slack/adapter.py`, `pyproject.toml`, `scripts/release.py`, `tests/gateway/test_base_topic_sessions.py`, `tests/gateway/test_slack.py`, `tests/gateway/test_slack_approval_buttons.py`, `tests/gateway/test_slack_block_kit_adapter.py`, `tests/hermes_cli/test_slack_cli.py`, `tools/lazy_deps.py`, `website/docs/user-guide/messaging/slack.md`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
         ``direct_messages_topic_id`` when the Bot API supports it.
         """
         thread_id = getattr(source, "thread_id", None)
    -    if thread_id is None:
    +    metadata = {"thread_id": thread_id} if thread_id is not None else {}
    +    # Slack workspace identity is durable routing state, not ephemeral event
    +    # metadata. Carry it on every outbound path (including unthreaded sends)
    +    # so a multi-workspace Socket Mode gateway never falls back to its primary
    +    # WebClient after an async, stream, or recovery boundary.
    +    if _platform_name(getattr(source, "platform", None)) == "slack":
    +        scope_id = getattr(source, "scope_id", None)
    +        if scope_id:
    +            metadata["slack_team_id"] = str(scope_id)
    +    if not metadata:
             return None
    -    metadata = {"thread_id": thread_id}
         if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
             metadata["telegram_dm_topic_reply_fallback"] = True
             tid = str(thread_id)
    ```

    ```diff
    @@ -3202,6 +3210,30 @@ async def stop_typing(self, chat_id: str) -> None:
             """
             pass
     
    +    async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None:
    +        """Stop typing while preserving platform-specific routing metadata.
    +
    +        Most adapters key typing state by chat and retain the historical
    +        ``stop_typing(chat_id)`` signature. Slack AI status is per thread and
    +        workspace, however, so losing metadata can clear a sibling thread or
    +        leave the current one active. Introspect at this shared chokepoint so
    +        existing adapters remain source-compatible.
    +        """
    +        if metadata:
    +            try:
    +                params = inspect.signature(self.stop_typing).parameters
    +                accepts_metadata = "metadata" in params or any(
    +                    param.kind is inspect.Parameter.VAR_KEYWORD
    +                    for param in params.values()
    +                )
    +            except (TypeError, ValueError):
    +                accepts_metadata = False
    +            if accepts_metadata:
    +                stop_typing = getattr(self, "stop_typing")
    +                await stop_typing(chat_id, metadata=metadata)
    +                return
    +        await self.stop_typing(chat_id)
    +
         async def send_multiple_images(
             self,
             chat_id: str,
    ```

    ```diff
    @@ -3879,7 +3911,7 @@ async def _keep_typing(
                 # Cancelling _keep_typing alone won't clean that up.
                 if hasattr(self, "stop_typing"):
                     try:
    -                    await self.stop_typing(chat_id)
    +                    await self._stop_typing_with_metadata(chat_id, metadata)
                     except Exception:
                         pass
                 self._typing_paused.discard(chat_id)
    ```

    ```diff
    @@ -3889,6 +3921,7 @@ async def _stop_typing_refresh(
             chat_id: str,
             typing_task: asyncio.Task | None = None,
             *,
    +        metadata=None,
             timeout: float = 0.5,
             stop_attempts: int = 2,
         ) -> None:
    ```

    ```diff
    @@ -3908,7 +3941,7 @@ async def _stop_typing_refresh(
                 attempts = max(1, stop_attempts)
                 for attempt in range(attempts):
                     try:
    -                    await self.stop_typing(chat_id)
    +                    await self._stop_typing_with_metadata(chat_id, metadata)
                     except Exception:
                         pass
                     if attempt < attempts - 1:
    ```

    ```diff
    @@ -3928,14 +3961,14 @@ def resume_typing_for_chat(self, chat_id: str) -> None:
             """Resume typing indicator for a chat after approval resolves."""
             self._typing_paused.discard(chat_id)
     
    -    async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
    +    async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None:
             """Signal the active session loop to stop and clear typing immediately."""
             if session_key:
                 interrupt_event = self._active_sessions.get(session_key)
                 if interrupt_event is not None:
                     interrupt_event.set()
             try:
    -            await self.stop_typing(chat_id)
    +            await self._stop_typing_with_metadata(chat_id, metadata)
             except Exception:
                 pass
     
    ```

    ```diff
    @@ -4873,6 +4906,7 @@ async def _stop_typing_task() -> None:
                 await self._stop_typing_refresh(
                     event.source.chat_id,
                     typing_task,
    +                metadata=_thread_metadata,
                 )
             
             try:
    ```

    ```diff
    @@ -5296,6 +5330,7 @@ async def _stop_typing_task() -> None:
                 await self._stop_typing_refresh(
                     event.source.chat_id,
                     None,
    +                metadata=_thread_metadata,
                     stop_attempts=1,
                 )
                 # Final drain/release boundary: force-flush any timer that missed
    ```

    ```diff
    @@ -5464,6 +5499,7 @@ def build_source(
             user_id_alt: Optional[str] = None,
             chat_id_alt: Optional[str] = None,
             is_bot: bool = False,
    +        scope_id: Optional[str] = None,
             guild_id: Optional[str] = None,
             parent_chat_id: Optional[str] = None,
             message_id: Optional[str] = None,
    ```

    ```diff
    @@ -5487,6 +5523,7 @@ def build_source(
                 user_id_alt=user_id_alt,
                 chat_id_alt=chat_id_alt,
                 is_bot=is_bot,
    +            scope_id=str(scope_id) if scope_id else None,
                 guild_id=str(guild_id) if guild_id else None,
                 parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
                 message_id=str(message_id) if message_id else None,
    ```
  - `gateway/run.py`

    ```diff
    @@ -11686,10 +11686,23 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
                     persist_user_timestamp=persist_user_timestamp,
                 )
     
    -            # Stop persistent typing indicator now that the agent is done
    +            # Stop persistent typing indicator now that the agent is done.
    +            # Slack AI status is scoped to a thread/workspace, so preserve the
    +            # same routing metadata used by the response delivery path.
                 try:
                     _typing_adapter = self._adapter_for_source(source)
    -                if _typing_adapter and hasattr(_typing_adapter, "stop_typing"):
    +                _stop_with_metadata = getattr(
    +                    type(_typing_adapter), "_stop_typing_with_metadata", None
    +                )
    +                _stop_typing = getattr(type(_typing_adapter), "stop_typing", None)
    +                if _typing_adapter and callable(_stop_with_metadata):
    +                    await _typing_adapter._stop_typing_with_metadata(
    +                        source.chat_id,
    +                        self._thread_metadata_for_source(
    +                            source, self._reply_anchor_for_event(event)
    +                        ),
    +                    )
    +                elif _typing_adapter and callable(_stop_typing):
                         await _typing_adapter.stop_typing(source.chat_id)
                 except Exception:
                     pass
    ```

    ```diff
    @@ -12235,10 +12248,22 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
                 return response
                 
             except Exception as e:
    -            # Stop typing indicator on error too
    +            # Stop typing indicator on error too, retaining Slack thread/workspace
    +            # routing so a failed turn cannot leave its status visible.
                 try:
                     _err_adapter = self._adapter_for_source(source)
    -                if _err_adapter and hasattr(_err_adapter, "stop_typing"):
    +                _stop_with_metadata = getattr(
    +                    type(_err_adapter), "_stop_typing_with_metadata", None
    +                )
    +                _stop_typing = getattr(type(_err_adapter), "stop_typing", None)
    +                if _err_adapter and callable(_stop_with_metadata):
    +                    await _err_adapter._stop_typing_with_metadata(
    +                        source.chat_id,
    +                        self._thread_metadata_for_source(
    +                            source, self._reply_anchor_for_event(event)
    +                        ),
    +                    )
    +                elif _err_adapter and callable(_stop_typing):
                         await _err_adapter.stop_typing(source.chat_id)
                 except Exception:
                     pass
    ```

    ```diff
    @@ -14397,13 +14422,19 @@ def _thread_metadata_for_source(
             reply_to_message_id: Optional[str] = None,
         ) -> Optional[Dict[str, Any]]:
             """Build the metadata dict platforms need for thread-aware replies."""
    -        return self._thread_metadata_for_target(
    +        metadata = self._thread_metadata_for_target(
                 getattr(source, "platform", None),
                 getattr(source, "chat_id", None),
                 getattr(source, "thread_id", None),
                 chat_type=getattr(source, "chat_type", None),
                 reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None),
             )
    +        if getattr(source, "platform", None) == Platform.SLACK:
    +            team_id = getattr(source, "scope_id", None)
    +            if team_id:
    +                metadata = dict(metadata or {})
    +                metadata["slack_team_id"] = str(team_id)
    +        return metadata
     
         def _thread_metadata_for_target(
             self,
    ```

    ```diff
    @@ -16301,8 +16332,25 @@ async def _interrupt_and_clear_session(
                 running_agent.interrupt(interrupt_reason)
             self._invalidate_session_run_generation(session_key, reason=invalidation_reason)
             adapter = self._adapter_for_source(source)
    -        if adapter and hasattr(adapter, "interrupt_session_activity"):
    -            await adapter.interrupt_session_activity(session_key, source.chat_id)
    +        interrupt_session_activity = getattr(
    +            type(adapter), "interrupt_session_activity", None
    +        )
    +        if adapter and callable(interrupt_session_activity):
    +            metadata = self._thread_metadata_for_source(source)
    +            try:
    +                params = inspect.signature(interrupt_session_activity).parameters
    +                accepts_metadata = "metadata" in params or any(
    +                    param.kind is inspect.Parameter.VAR_KEYWORD
    +                    for param in params.values()
    +                )
    +            except (TypeError, ValueError):
    +                accepts_metadata = False
    +            if accepts_metadata:
    +                await adapter.interrupt_session_activity(
    +                    session_key, source.chat_id, metadata=metadata
    +                )
    +            else:
    +                await adapter.interrupt_session_activity(session_key, source.chat_id)
             if adapter and hasattr(adapter, "get_pending_message"):
                 adapter.get_pending_message(session_key)  # consume and discard
             self._pending_messages.pop(session_key, None)
    ```
  - `hermes_cli/slack_cli.py`

    ```diff
    @@ -27,6 +27,7 @@ def _build_full_manifest(
         bot_name: str,
         bot_description: str,
         include_assistant: bool = True,
    +    messaging_experience: str | None = None,
     ) -> dict:
         """Build a full Slack manifest merging display info + our slash list.
     
    ```

    ```diff
    @@ -36,17 +37,24 @@ def _build_full_manifest(
         for a Hermes deployment — users can tweak them in the Slack UI after
         pasting.
     
    -    When ``include_assistant`` is True (default) the manifest opts the app
    -    into Slack's AI Assistant container: the ``assistant_view`` feature, the
    -    ``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack
    -    then renders DMs as the right-hand Assistant split-pane, where every
    -    exchange is a thread and bare slash commands are not delivered as normal
    -    ``command`` events. Pass ``include_assistant=False`` (``--no-assistant``)
    -    to omit those three pieces and get a flat DM surface where ``/help``,
    -    ``/new``, etc. work inline.
    +    By default, this keeps Hermes on Slack's older Assistant messaging
    +    experience (``assistant_view``) for backward compatibility. Pass
    +    ``messaging_experience="agent"`` (``--agent-view``) to emit Slack's Agent
    +    messaging experience (``agent_view`` + ``app_home_opened``). Pass
    +    ``include_assistant=False`` or ``messaging_experience="none"``
    +    (``--no-assistant``) to omit Slack AI messaging features and get a flat DM
    +    surface where ``/help``, ``/new``, etc. work inline.
         """
         from hermes_cli.commands import slack_app_manifest
     
    +    if messaging_experience is None:
    +        messaging_experience = "assistant" if include_assistant else "none"
    +    messaging_experience = str(messaging_experience).strip().lower()
    +    if messaging_experience not in {"assistant", "agent", "none"}:
    +        raise ValueError(
    +            "messaging_experience must be one of: assistant, agent, none"
    +        )
    +
         partial = slack_app_manifest()
         slashes = partial["features"]["slash_commands"]
     
    ```

    ```diff
    @@ -89,7 +97,7 @@ def _build_full_manifest(
             "message.mpim",
         ]
     
    -    if include_assistant:
    +    if messaging_experience == "assistant":
             features["assistant_view"] = {
                 "assistant_description": "Chat with Hermes in threads and DMs.",
             }
    ```

    ```diff
    @@ -100,8 +108,18 @@ def _build_full_manifest(
                     "assistant_thread_started",
                 ]
             )
    -        bot_scopes.sort()
    -        bot_events.sort()
    +    elif messaging_experience == "agent":
    +        features["agent_view"] = {
    +            "agent_description": "Chat with Hermes in Slack Messages.",
    +        }
    +        bot_scopes.append("assistant:write")
    +        # Slack includes current viewing context in Agent DM events only after
    +        # this subscription is enabled; the adapter consumes that context to
    +        # preserve the referred channel across the agent turn.
    +        bot_events.extend(["app_context_changed", "app_home_opened"])
    +
    +    bot_scopes.sort()
    +    bot_events.sort()
     
         return {
             "_metadata": {
    ```

    ```diff
    @@ -147,17 +165,29 @@ def slack_manifest_command(args) -> int:
                           assistant:write scope, assistant_thread_* events) so
                           DMs render as a flat chat where bare slash commands
                           work inline instead of the Assistant thread pane.
    +      --agent-view    Use Slack's Agent messaging experience (agent_view,
    +                      app_home_opened + message.im) instead of the legacy
    +                      Assistant messaging experience.
         """
         name = getattr(args, "name", None) or "Hermes"
         description = getattr(args, "description", None) or "Your Hermes agent on Slack"
    -    include_assistant = not getattr(args, "no_assistant", False)
    +    if getattr(args, "agent_view", False):
    +        messaging_experience = "agent"
    +    elif getattr(args, "no_assistant", False):
    +        messaging_experience = "none"
    +    else:
    +        messaging_experience = "assistant"
     
         if getattr(args, "slashes_only", False):
             from hermes_cli.commands import slack_app_manifest
     
             manifest = slack_app_manifest()["features"]["slash_commands"]
         else:
    -        manifest = _build_full_manifest(name, description, include_assistant=include_assistant)
    +        manifest = _build_full_manifest(
    +            name,
    +            description,
    +            messaging_experience=messaging_experience,
    +        )
     
         payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
     
    ```
  - `hermes_cli/subcommands/slack.py`

    ```diff
    @@ -57,7 +57,8 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
             help="Emit only the features.slash_commands array (for merging "
             "into an existing manifest manually).",
         )
    -    slack_manifest.add_argument(
    +    slack_messaging = slack_manifest.add_mutually_exclusive_group()
    +    slack_messaging.add_argument(
             "--no-assistant",
             action="store_true",
             help="Omit Slack AI Assistant mode (assistant_view, assistant:write "
    ```

    ```diff
    @@ -65,4 +66,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
             "where bare slash commands (/help, /new) work inline instead of "
             "Slack's Assistant thread pane.",
         )
    +    slack_messaging.add_argument(
    +        "--agent-view",
    +        action="store_true",
    +        help="Emit Slack's Agent messaging experience (agent_view, "
    +        "app_home_opened + message.im) instead of the legacy assistant_view "
    +        "experience. This changes Slack's app messaging surface and cannot "
    +        "be reversed in Slack after applying the manifest.",
    +    )
         slack_parser.set_defaults(func=cmd_slack)
    ```
  - `plugins/platforms/slack/adapter.py`

    ```diff
    @@ -440,7 +440,10 @@ def __init__(self, config: PlatformConfig):
             self._app: Optional[Any] = None
             self._handler: Optional[Any] = None
             self._bot_user_id: Optional[str] = None
    -        self._user_name_cache: Dict[str, str] = {}  # user_id → display name
    +        # Slack user IDs are workspace-local. Cache names by workspace as well
    +        # so a multi-workspace Socket Mode process never reuses another
    +        # tenant's display name.
    +        self._user_name_cache: Dict[Tuple[str, str], str] = {}
             self._socket_mode_task: Optional[asyncio.Task] = None
             # Multi-workspace support
             self._team_clients: Dict[str, Any] = {}  # team_id → WebClient
    ```

    ```diff
    @@ -460,20 +463,29 @@ def __init__(self, config: PlatformConfig):
             # respond to ALL subsequent messages in that thread automatically.
             self._mentioned_threads: set = set()
             self._MENTIONED_THREADS_MAX = 5000
    -        # Assistant thread metadata keyed by (channel_id, thread_ts). Slack's
    -        # AI Assistant lifecycle events can arrive before/alongside message
    -        # events, and they carry the user/thread identity needed for stable
    -        # session + memory scoping.
    -        self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}
    +        # Assistant thread metadata keyed by (team_id, channel_id, thread_ts).
    +        # Slack's AI Assistant lifecycle events can arrive before/alongside
    +        # message events, and carry identity needed for stable session scoping.
    +        self._assistant_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {}
             self._ASSISTANT_THREADS_MAX = 5000
    +        # Agent-view context is per workspace/user (not global): a context
    +        # change for one person's Slack split view must never appear in another
    +        # person's prompt. Slack also includes this in later DM events, but the
    +        # cache bridges lifecycle and message delivery ordering.
    +        self._agent_view_contexts: Dict[Tuple[str, str], Dict[str, str]] = {}
    +        self._AGENT_VIEW_CONTEXTS_MAX = 5000
             # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
             self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
             self._THREAD_CACHE_TTL = 60.0
             # Track message IDs that should get reaction lifecycle (DMs / @mentions).
             self._reacting_message_ids: set = set()
    -        # Track active assistant thread status indicators so stop_typing can
    -        # clear them (chat_id → thread_ts).
    -        self._active_status_threads: Dict[str, str] = {}
    +        # Track active Assistant statuses by (team_id, channel_id, thread_ts)
    +        # so cleanup cannot clear an overlapping Slack Connect workspace.
    +        self._active_status_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {}
    +        # Best-effort guard so automatic Slack AI thread titles are set once
    +        # per visible DM thread instead of on every reply.
    +        self._titled_assistant_threads: set = set()
    +        self._TITLED_ASSISTANT_THREADS_MAX = 5000
             # Slash-command contexts: stash response_url + user_id so send()
             # can route the first reply ephemerally.  Keyed by
             # (channel_id, user_id) to avoid cross-user collisions.
    ```

    ```diff
    @@ -1085,8 +1097,8 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
     
                 # Register message event handler
                 @self._app.event("message")
    -            async def handle_message_event(event, say):
    -                await self._handle_slack_message(event)
    +            async def handle_message_event(event, say, body):
    +                await self._handle_slack_message(event, body)
     
                 # Handle app_mention explicitly. In some Slack app configurations,
                 # channel mentions arrive only as app_mention events rather than the
    ```

    ```diff
    @@ -1096,15 +1108,23 @@ async def handle_message_event(event, say):
                 # @mention, they share the same event ts — the dedup in
                 # _handle_slack_message (MessageDeduplicator) suppresses the second.
                 @self._app.event("app_mention")
    -            async def handle_app_mention(event, say):
    -                await self._handle_slack_message(event)
    +            async def handle_app_mention(event, say, body):
    +                await self._handle_slack_message(event, body)
    +
    +            @self._app.event("app_home_opened")
    +            async def handle_app_home_opened(event, say, body):
    +                await self._handle_app_home_opened(event, body)
    +
    +            @self._app.event("app_context_changed")
    +            async def handle_app_context_changed(event, say, body):
    +                await self._handle_app_context_changed(event, body)
     
                 # File lifecycle events can arrive around snippet uploads even when
                 # the actual user message is what we care about. Ack them so Slack
                 # doesn't log noisy 404 "unhandled request" warnings.
                 @self._app.event("file_shared")
    -            async def handle_file_shared(event, say):
    -                await self._handle_slack_file_shared(event)
    +            async def handle_file_shared(event, say, body):
    +                await self._handle_slack_file_shared(event, body)
     
                 @self._app.event("file_created")
                 async def handle_file_created(event, say):
    ```

    ```diff
    @@ -1127,12 +1147,12 @@ async def handle_reaction_removed(event, say):
                     pass
     
                 @self._app.event("assistant_thread_started")
    -            async def handle_assistant_thread_started(event, say):
    -                await self._handle_assistant_thread_lifecycle_event(event)
    +            async def handle_assistant_thread_started(event, say, body):
    +                await self._handle_assistant_thread_lifecycle_event(event, body)
     
                 @self._app.event("assistant_thread_context_changed")
    -            async def handle_assistant_thread_context_changed(event, say):
    -                await self._handle_assistant_thread_lifecycle_event(event)
    +            async def handle_assistant_thread_context_changed(event, say, body):
    +                await self._handle_assistant_thread_lifecycle_event(event, body)
     
                 # Register slash command handler(s)
                 #
    ```

    ```diff
    @@ -1185,6 +1205,8 @@ async def handle_hermes_command(ack, command):
                 ):
                     self._app.action(_action_id)(self._handle_slash_confirm_action)
     
    +            self._app.action("hermes_feedback")(self._handle_feedback_action)
    +
                 # Register plugin-provided Block Kit action handlers.
                 #
                 # Plugins call ``ctx.register_slack_action_handler(action_id, cb)``
    ```

    ```diff
    @@ -1342,8 +1364,22 @@ async def disconnect(self) -> None:
     
             logger.info("[Slack] Disconnected")
     
    -    def _get_client(self, chat_id: str) -> Any:
    +    @staticmethod
    +    def _metadata_team_id(metadata: Optional[Dict[str, Any]]) -> str:
    +        """Return Slack workspace id from generic or Slack-specific metadata."""
    +        if not metadata:
    +            return ""
    +        return str(
    +            metadata.get("team_id")
    +            or metadata.get("team")
    +            or metadata.get("slack_team_id")
    +            or ""
    +        )
    +
    +    def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any:
             """Return the workspace-specific WebClient for a channel."""
    +        if team_id and team_id in self._team_clients:
    +            return self._team_clients[team_id]
             team_id = self._channel_team.get(chat_id)
             if team_id and team_id in self._team_clients:
                 return self._team_clients[team_id]
    ```

    ```diff
    @@ -1360,6 +1396,7 @@ async def send(
             if not self._app:
                 return SendResult(success=False, error="Not connected")
     
    +        thread_ts = None
             try:
                 # Check for a pending slash-command context.  When the user ran a
                 # native slash command (e.g. /q, /stop, /model), the initial ack
    ```

    ```diff
    @@ -1407,11 +1444,13 @@ async def send(
                         if broadcast and i == 0:
                             kwargs["reply_broadcast"] = True
     
    -                last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
    +                last_result = await self._get_client(
    +                    chat_id, team_id=self._metadata_team_id(metadata)
    +                ).chat_postMessage(**kwargs)
     
                 # Clear Slack Assistant status as soon as the final message is posted.
                 if thread_ts:
    -                await self.stop_typing(chat_id)
    +                await self.stop_typing(chat_id, metadata=metadata)
     
                 # Track the sent message ts so we can auto-respond to thread
                 # replies without requiring @mention.
    ```

    ```diff
    @@ -1433,6 +1472,8 @@ async def send(
                 )
     
             except Exception as e:  # pragma: no cover - defensive logging
    +            if thread_ts:
    +                await self.stop_typing(chat_id, metadata=metadata)
                 logger.error("[Slack] Send error: %s", e, exc_info=True)
                 return SendResult(success=False, error=str(e))
     
    ```

    ```diff
    @@ -1462,7 +1503,9 @@ async def send_private_notice(
                 if thread_ts:
                     kwargs["thread_ts"] = thread_ts
     
    -            result = await self._get_client(chat_id).chat_postEphemeral(**kwargs)
    +            result = await self._get_client(
    +                chat_id, team_id=self._metadata_team_id(metadata)
    +            ).chat_postEphemeral(**kwargs)
                 return SendResult(
                     success=True,
                     message_id=result.get("message_ts") or result.get("ts"),
    ```

    ```diff
    @@ -1479,6 +1522,7 @@ async def edit_message(
             content: str,
             *,
             finalize: bool = False,
    +        metadata: Optional[Dict[str, Any]] = None,
         ) -> SendResult:
             """Edit a previously sent Slack message."""
             if not self._app:
    ```

    ```diff
    @@ -1498,11 +1542,15 @@ async def edit_message(
                     blocks = self._maybe_blocks(content)
                     if blocks:
                         update_kwargs["blocks"] = blocks
    -            await self._get_client(chat_id).chat_update(**update_kwargs)
    +            await self._get_client(
    +                chat_id, team_id=self._metadata_team_id(metadata)
    +            ).chat_update(**update_kwargs)
                 if finalize:
    -                await self.stop_typing(chat_id)
    +                await self.stop_typing(chat_id, metadata=metadata)
                 return SendResult(success=True, message_id=message_id)
             except Exception as e:  # pragma: no cover - defensive logging
    +            if finalize:
    +                await self.stop_typing(chat_id, metadata=metadata)
                 logger.error(
                     "[Slack] Failed to edit message %s in channel %s: %s",
                     message_id,
    ```

    ```diff
    @@ -1529,9 +1577,18 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
             if not thread_ts:
                 return  # Can only set status in a thread context
     
    -        self._active_status_threads[chat_id] = thread_ts
    +        team_id = self._metadata_team_id(metadata)
    +        if not team_id:
    +            team_id = self._channel_team.get(chat_id, "")
    +
    +        status_key = self._workspace_thread_key(team_id, chat_id, str(thread_ts))
    +        if status_key:
    +            self._active_status_threads[status_key] = {
    +                "thread_ts": str(thread_ts),
    +                "team_id": str(team_id) if team_id else "",
    +            }
             try:
    -            await self._get_client(chat_id).assistant_threads_setStatus(
    +            await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(
                     channel_id=chat_id,
                     thread_ts=thread_ts,
                     status="is thinking...",
    ```

    ```diff
    @@ -1545,11 +1602,56 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:
             """Clear the assistant thread status indicator."""
             if not self._app:
                 return
    -        thread_ts = self._active_status_threads.pop(chat_id, None)
    +        requested_thread_ts = ""
    +        if metadata:
    +            requested_thread_ts = str(
    +                metadata.get("thread_id") or metadata.get("thread_ts") or ""
    +            )
    +        requested_team_id = self._metadata_team_id(metadata)
    +        active = None
    +        if requested_thread_ts:
    +            if requested_team_id:
    +                active_key = self._workspace_thread_key(
    +                    requested_team_id, chat_id, requested_thread_ts
    +                )
    +                if active_key:
    +                    active = self._active_status_threads.pop(active_key, None)
    +            else:
    +                # Do not trust the mutable channel-only workspace fallback for
    +                # a thread-specific cleanup: Slack Connect workspaces can share
    +                # a channel ID. Clear the uniquely matching tracked status and
    +                # let its stored team choose the correct client.
    +                matching_keys = [
    +                    key
    +                    for key in self._active_status_threads
    +                    if key[1] == str(chat_id) and key[2] == requested_thread_ts
    +                ]
    +                if len(matching_keys) == 1:
    +                    active = self._active_status_threads.pop(matching_keys[0], None)
    +        else:
    +            # Metadata-free cleanup is safe only if exactly one status exists
    +            # for this channel; otherwise it may clear another Slack Connect
    +            # workspace's Assistant status.
    +            matching_keys = [
    +                key
    +                for key in self._active_status_threads
    +                if key[1] == str(chat_id)
    +            ]
    +            if len(matching_keys) == 1:
    +                active = self._active_status_threads.pop(matching_keys[0], None)
    +        if isinstance(active, str):
    +            thread_ts = active
    +            team_id = ""
    +        else:
    +            active = active or {}
    +            thread_ts = active.get("thread_ts", "")
    +            team_id = active.get("team_id", "")
    +        if metadata:
    +            team_id = self._metadata_team_id(metadata) or team_id
             if not thread_ts:
                 return
             try:
    -            await self._get_client(chat_id).assistant_threads_setStatus(
    +            await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus(
                     channel_id=chat_id,
                     thread_ts=thread_ts,
                     status="",
    ```

    ```diff
    @@ -1684,7 +1786,9 @@ async def _upload_file(
             last_exc = None
             for attempt in range(3):
                 try:
    -                result = await self._get_client(chat_id).files_upload_v2(
    +                result = await self._get_client(
    +                    chat_id, team_id=self._metadata_team_id(metadata)
    +                ).files_upload_v2(
                         channel=chat_id,
                         file=file_path,
                         filename=os.path.basename(file_path),
    ```

    ```diff
    @@ -1811,7 +1915,9 @@ async def send_multiple_images(
                         chunk_idx + 1,
                         len(chunks),
                     )
    -                result = await self._get_client(chat_id).files_upload_v2(
    +                result = await self._get_client(
    +                    chat_id, team_id=self._metadata_team_id(metadata)
    +                ).files_upload_v2(
                         channel=chat_id,
                         file_uploads=file_uploads,
                         initial_comment=initial_comment,
    ```

    ```diff
    @@ -1885,6 +1991,47 @@ def _rich_blocks_enabled(self) -> bool:
                 return False
             return str(raw).strip().lower() in {"1", "true", "yes", "on"}
     
    +    def _feedback_buttons_enabled(self) -> bool:
    +        """Whether to include Slack AI feedback buttons on final responses."""
    +        raw = self.config.extra.get("feedback_buttons")
    +        if raw is None:
    +            return False
    +        return str(raw).strip().lower() in {"1", "true", "yes", "on"}
    +
    +    def _feedback_block(self) -> Dict[str, Any]:
    +        """Return Slack AI feedback controls from the Agent docs."""
    +        return {
    +            "type": "context_actions",
    +            "elements": [
    +                {
    +                    "type": "feedback_buttons",
    +                    "action_id": "hermes_feedback",
    +                    "positive_button": {
    +                        "text": {"type": "plain_text", "text": "Good Response"},
    +                        "accessibility_label": (
    +                            "Submit positive feedback on this response"
    +                        ),
    +                        "value": "positive",
    +                    },
    +                    "negative_button": {
    +                        "text": {"type": "plain_text", "text": "Bad Response"},
    +                        "accessibility_label": (
    +                            "Submit negative feedback on this response"
    +                        ),
    +                        "value": "negative",
    +                    },
    +                }
    +            ],
    +        }
    +
    +    def _append_feedback_block(self, blocks: Optional[list]) -> Optional[list]:
    +        """Append response feedback controls when enabled and block budget allows."""
    +        if not blocks or not self._feedback_buttons_enabled():
    +            return blocks
    +        if len(blocks) >= 50:
    +            return blocks
    +        return [*blocks, self._feedback_block()]
    +
         def _maybe_blocks(self, content: str) -> Optional[list]:
             """Render ``content`` to Block Kit blocks when the feature is enabled.
     
    ```

    ```diff
    @@ -1896,7 +2043,8 @@ def _maybe_blocks(self, content: str) -> Optional[list]:
             if not self._rich_blocks_enabled():
                 return None
             try:
    -            return render_blocks(content, mrkdwn_fn=self.format_message)
    +            blocks = render_blocks(content, mrkdwn_fn=self.format_message)
    +            return self._append_feedback_block(blocks)
             except Exception:  # pragma: no cover - renderer already guards itself
                 logger.debug("[Slack] block render failed; using plain text", exc_info=True)
                 return None
    ```

    ```diff
    @@ -2013,12 +2161,14 @@ def _convert_header(m):
     
         # ----- Reactions -----
     
    -    async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
    +    async def _add_reaction(
    +        self, channel: str, timestamp: str, emoji: str, team_id: str = ""
    +    ) -> bool:
             """Add an emoji reaction to a message. Returns True on success."""
             if not self._app:
                 return False
             try:
    -            await self._get_client(channel).reactions_add(
    +            await self._get_client(channel, team_id=team_id or None).reactions_add(
                     channel=channel, timestamp=timestamp, name=emoji
                 )
                 return True
    ```

    ```diff
    @@ -2027,12 +2177,14 @@ async def _add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
                 logger.debug("[Slack] reactions.add failed (%s): %s", emoji, e)
                 return False
     
    -    async def _remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool:
    +    async def _remove_reaction(
    +        self, channel: str, timestamp: str, emoji: str, team_id: str = ""
    +    ) -> bool:
             """Remove an emoji reaction from a message. Returns True on success."""
             if not self._app:
                 return False
             try:
    -            await self._get_client(channel).reactions_remove(
    +            await self._get_client(channel, team_id=team_id or None).reactions_remove(
                     channel=channel, timestamp=timestamp, name=emoji
                 )
                 return True
    ```

    ```diff
    @@ -2053,7 +2205,9 @@ async def on_processing_start(self, event: MessageEvent) -> None:
                 return
             channel_id = getattr(event.source, "chat_id", None)
             if channel_id:
    -            await self._add_reaction(channel_id, ts, "eyes")
    +            await self._add_reaction(
    +                channel_id, ts, "eyes", str(getattr(event.source, "scope_id", "") or "")
    +            )
     
         async def on_processing_complete(
             self, event: MessageEvent, outcome: ProcessingOutcome
    ```

    ```diff
    @@ -2068,26 +2222,36 @@ async def on_processing_complete(
             channel_id = getattr(event.source, "chat_id", None)
             if not channel_id:
                 return
    -        await self._remove_reaction(channel_id, ts, "eyes")
    +        team_id = str(getattr(event.source, "scope_id", "") or "")
    +        await self._remove_reaction(channel_id, ts, "eyes", team_id)
             if outcome == ProcessingOutcome.SUCCESS:
    -            await self._add_reaction(channel_id, ts, "white_check_mark")
    +            await self._add_reaction(channel_id, ts, "white_check_mark", team_id)
             elif outcome == ProcessingOutcome.FAILURE:
    -            await self._add_reaction(channel_id, ts, "x")
    +            await self._add_reaction(channel_id, ts, "x", team_id)
     
         # ----- User identity resolution -----
     
    -    async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
    -        """Resolve a Slack user ID to a display name, with caching."""
    +    async def _resolve_user_name(
    +        self, user_id: str, chat_id: str = "", team_id: str = ""
    +    ) -> str:
    +        """Resolve a workspace-local Slack user ID to a display name."""
             if not user_id:
                 return ""
    -        if user_id in self._user_name_cache:
    -            return self._user_name_cache[user_id]
    +        team_id = str(team_id or self._channel_team.get(chat_id, ""))
    +        cache_key = (team_id, str(user_id))
    +        cached_name = self._user_name_cache.get(cache_key)
    +        if cached_name is not None:
    +            return cached_name
     
             if not self._app:
                 return user_id
     
             try:
    -            client = self._get_client(chat_id) if chat_id else self._app.client
    +            client = (
    +                self._get_client(chat_id, team_id=team_id or None)
    +                if chat_id
    +                else self._app.client
    +            )
                 result = await client.users_info(user=user_id)
                 user = result.get("user", {})
                 # Prefer display_name → real_name → user_id
    ```

    ```diff
    @@ -2099,11 +2263,11 @@ async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
                     or user.get("name")
                     or user_id
                 )
    -            self._user_name_cache[user_id] = name
    +            self._user_name_cache[cache_key] = name
                 return name
             except Exception as e:
                 logger.debug("[Slack] users.info failed for %s: %s", user_id, e)
    -            self._user_name_cache[user_id] = user_id
    +            self._user_name_cache[cache_key] = user_id
                 return user_id
     
         async def send_image_file(
    ```

    ```diff
    @@ -2177,7 +2341,9 @@ async def _ssrf_redirect_guard(response):
                     response.raise_for_status()
     
                 thread_ts = self._resolve_thread_ts(reply_to, metadata)
    -            result = await self._get_client(chat_id).files_upload_v2(
    +            result = await self._get_client(
    +                chat_id, team_id=self._metadata_team_id(metadata)
    +            ).files_upload_v2(
                     channel=chat_id,
                     content=response.content,
                     filename="image.png",
    ```

    ```diff
    @@ -2253,7 +2419,9 @@ async def send_video(
                 last_exc = None
                 for attempt in range(3):
                     try:
    -                    result = await self._get_client(chat_id).files_upload_v2(
    +                    result = await self._get_client(
    +                        chat_id, team_id=self._metadata_team_id(metadata)
    +                    ).files_upload_v2(
                             channel=chat_id,
                             file=video_path,
                             filename=os.path.basename(video_path),
    ```

    ```diff
    @@ -2313,7 +2481,9 @@ async def send_document(
                 last_exc = None
                 for attempt in range(3):
                     try:
    -                    result = await self._get_client(chat_id).files_upload_v2(
    +                    result = await self._get_client(
    +                        chat_id, team_id=self._metadata_team_id(metadata)
    +                    ).files_upload_v2(
                             channel=chat_id,
                             file=file_path,
                             filename=display_name,
    ```

    ```diff
    @@ -2377,18 +2547,119 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
     
         # ----- Internal handlers -----
     
    -    def _assistant_thread_key(
    -        self, channel_id: str, thread_ts: str
    -    ) -> Optional[Tuple[str, str]]:
    -        """Return a stable cache key for Slack assistant thread metadata."""
    +    @staticmethod
    +    def _workspace_thread_key(
    +        team_id: str, channel_id: str, thread_ts: str
    +    ) -> Optional[Tuple[str, str, str]]:
    +        """Return a workspace-scoped key for Slack thread-local state.
    +
    +        Slack Connect can expose the same channel/thread IDs in more than one
    +        workspace. Keys for cached thread data and Assistant status therefore
    +        need the workspace identity as well as Slack's channel/thread pair.
    +        """
             if not channel_id or not thread_ts:
                 return None
    -        return (str(channel_id), str(thread_ts))
    +        return (str(team_id or ""), str(channel_id), str(thread_ts))
    +
    +    @staticmethod
    +    def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]:
    +        """Return a per-workspace, per-user Agent-view context cache key."""
    +        if not team_id or not user_id:
    +            return None
    +        return (str(team_id), str(user_id))
    +
    +    def _cache_agent_view_context(self, metadata: Dict[str, str]) -> None:
    +        """Remember a user's current Slack Agent-view context."""
    +        key = self._agent_view_context_key(
    +            metadata.get("team_id", ""), metadata.get("user_id", "")
    +        )
    +        if not key:
    +            return
    +        contexts = getattr(self, "_agent_view_contexts", None)
    +        if not isinstance(contexts, dict):
    +            contexts = {}
    +            self._agent_view_contexts = contexts
    +        contexts[key] = {
    +            field: value
    +            for field, value in metadata.items()
    +            if field in {"channel_id", "context_channel_id", "team_id", "user_id"}
    +            and value
    +        }
    +        max_contexts = getattr(self, "_AGENT_VIEW_CONTEXTS_MAX", 5000)
    +        if len(contexts) > max_contexts:
    +            excess = len(contexts) - max_contexts // 2
    +            for old_key in list(contexts)[:excess]:
    +                del contexts[old_key]
    +
    +    def _agent_view_context_for_event(
    +        self, event: dict, team_id: str, user_id: str
    +    ) -> Dict[str, str]:
    +        """Read Slack's inline Agent context, falling back to lifecycle state."""
    +        context = event.get("app_context") or event.get("context") or {}
    +        context_channel_id = self._context_channel_id(context)
    +        key = self._agent_view_context_key(team_id, user_id)
    +        contexts = getattr(self, "_agent_view_contexts", {})
    +        cached = contexts.get(key, {}) if isinstance(contexts, dict) and key else {}
    +        return {
    +            "context_channel_id": context_channel_id or cached.get("context_channel_id", ""),
    +            "team_id": team_id,
    +            "user_id": user_id,
    +        }
     
    -    def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:
    +    @staticmethod
    +    def _event_team_id(event: dict, body: Optional[dict] = None) -> str:
    +        """Resolve a workspace ID from an event plus Bolt's outer payload.
    +
    +        Bolt injects only the inner ``event`` into an event listener, while
    +        Slack places ``team_id`` on the outer Events API payload. Reading both
    +        keeps multi-workspace client routing correct after a process boundary.
    +        """
    +        for payload in (event, body or {}):
    +            if not isinstance(payload, dict):
    +                continue
    +            team = payload.get("team_id") or payload.get("team")
    +            if isinstance(team, str) and team:
    +                return team
    +            if isinstance(team, dict) and team.get("id"):
    +                return str(team["id"])
    +        authorizations = (body or {}).get("authorizations") if isinstance(body, dict) else None
    +        for authorization in authorizations or []:
    +            if isinstance(authorization, dict) and authorization.get("team_id"):
    +                return str(authorization["team_id"])
    +        return ""
    +
    +    @staticmethod
    +    def _context_channel_id(context: Any) -> str:
    +        """Extract the actively viewed channel from either Slack context shape."""
    +        if not isinstance(context, dict):
    +            return ""
    +        channel_id = context.get("channel_id")
    +        if channel_id:
    +            return str(channel_id)
    +        for entity in context.get("entities") or []:
    +            if not isinstance(entity, dict):
    +                continue
    +            value = entity.get("value")
    +            if isinstance(value, dict) and value.get("channel_id"):
    +                return str(value["channel_id"])
    +            if (
    +                isinstance(value, str)
    +                and str(entity.get("type") or "").endswith("channel_id")
    +            ):
    +                return value
    +        return ""
    +
    +    def _extract_assistant_thread_metadata(
    +        self, event: dict, body: Optional[dict] = None
    +    ) -> Dict[str, str]:
             """Extract Slack Assistant thread identity data from an event payload."""
             assistant_thread = event.get("assistant_thread") or {}
    -        context = assistant_thread.get("context") or event.get("context") or {}
    +        context = (
    +            assistant_thread.get("context")
    +            or event.get("app_context")
    +            or event.get("context")
    +            or {}
    +        )
     
             channel_id = (
                 assistant_thread.get("channel_id")
    ```

    ```diff
    @@ -2408,13 +2679,10 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:
                 or context.get("user_id")
                 or ""
             )
    -        team_id = (
    -            event.get("team")
    -            or event.get("team_id")
    -            or assistant_thread.get("team_id")
    -            or ""
    +        team_id = self._event_team_id(event, body) or str(
    +            assistant_thread.get("team_id") or ""
             )
    -        context_channel_id = context.get("channel_id") or ""
    +        context_channel_id = self._context_channel_id(context)
     
             return {
                 "channel_id": str(channel_id) if channel_id else "",
    ```

    ```diff
    @@ -2425,10 +2693,11 @@ def _extract_assistant_thread_metadata(self, event: dict) -> Dict[str, str]:
             }
     
         def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None:
    -        """Remember assistant thread identity data for later message events."""
    +        """Remember workspace-local assistant identity for later message events."""
             channel_id = metadata.get("channel_id", "")
             thread_ts = metadata.get("thread_ts", "")
    -        key = self._assistant_thread_key(channel_id, thread_ts)
    +        team_id = metadata.get("team_id", "")
    +        key = self._workspace_thread_key(team_id, channel_id, thread_ts)
             if not key:
                 return
     
    ```

    ```diff
    @@ -2437,30 +2706,35 @@ def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None:
             merged.update({k: v for k, v in metadata.items() if v})
             self._assistant_threads[key] = merged
     
    -        # Evict oldest entries when the cache exceeds the limit
    +        # Evict oldest entries when the cache exceeds the limit.
             if len(self._assistant_threads) > self._ASSISTANT_THREADS_MAX:
                 excess = len(self._assistant_threads) - self._ASSISTANT_THREADS_MAX // 2
                 for old_key in list(self._assistant_threads)[:excess]:
                     del self._assistant_threads[old_key]
     
    -        team_id = merged.get("team_id", "")
             if team_id and channel_id:
                 self._channel_team[channel_id] = team_id
     
         def _lookup_assistant_thread_metadata(
             self,
             event: dict,
    +        *,
             channel_id: str = "",
             thread_ts: str = "",
    +        team_id: str = "",
    +        body: Optional[dict] = None,
         ) -> Dict[str, str]:
    -        """Load cached assistant-thread metadata that matches the current event."""
    -        metadata = self._extract_assistant_thread_metadata(event)
    +        """Load workspace-scoped assistant metadata for the current event."""
    +        metadata = self._extract_assistant_thread_metadata(event, body)
             if channel_id and not metadata.get("channel_id"):
                 metadata["channel_id"] = channel_id
             if thread_ts and not metadata.get("thread_ts"):
                 metadata["thread_ts"] = thread_ts
    +        if team_id and not metadata.get("team_id"):
    +            metadata["team_id"] = str(team_id)
     
    -        key = self._assistant_thread_key(
    +        key = self._workspace_thread_key(
    +            metadata.get("team_id", ""),
                 metadata.get("channel_id", ""),
                 metadata.get("thread_ts", ""),
             )
    ```

    ```diff
    @@ -2471,6 +2745,123 @@ def _lookup_assistant_thread_metadata(
                 return merged
             return metadata
     
    +    def _assistant_suggested_prompts(self) -> Tuple[str, List[Dict[str, str]]]:
    +        """Return config.yaml-defined Slack AI suggested prompts.
    +
    +        Supported shapes under ``platforms.slack.extra.suggested_prompts``:
    +
    +        - ``[{title, message}, ...]``
    +        - ``{title: "...", prompts: [{title, message}, ...]}``
    +
    +        Invalid rows are ignored. Slack recommends up to four suggestions; keep
    +        that bound here so a broad config cannot produce an invalid API call.
    +        """
    +        raw = self.config.extra.get("suggested_prompts")
    +        title = ""
    +        prompt_rows = raw
    +        if isinstance(raw, dict):
    +            title = str(raw.get("title") or "").strip()
    +            prompt_rows = raw.get("prompts")
    +        if not isinstance(prompt_rows, list):
    +            return title, []
    +
    +        prompts: List[Dict[str, str]] = []
    +        for item in prompt_rows:
    +            if not isinstance(item, dict):
    +                continue
    +            prompt_title = str(item.get("title") or "").strip()
    +            prompt_message = str(item.get("message") or "").strip()
    +            if not prompt_title or not prompt_message:
    +                continue
    +            prompts.append({"title": prompt_title[:75], "message": prompt_message})
    +            if len(prompts) >= 4:
    +                break
    +        return title, prompts
    +
    +    async def _set_assistant_suggested_prompts(
    +        self,
    +        channel_id: str,
    +        *,
    +        team_id: str = "",
    +        thread_ts: str = "",
    +    ) -> None:
    +        """Best-effort Slack AI suggested prompts setup."""
    +        if not self._app or not channel_id:
    +            return
    +
    +        title, prompts = self._assistant_suggested_prompts()
    +        if not prompts:
    +            return
    +
    +        kwargs: Dict[str, Any] = {
    +            "channel_id": channel_id,
    +            "prompts": prompts,
    +        }
    +        if title:
    +            kwargs["title"] = title
    +        if thread_ts:
    +            kwargs["thread_ts"] = thread_ts
    +
    +        try:
    +            await self._get_client(channel_id, team_id=team_id).assistant_threads_setSuggestedPrompts(
    +                **kwargs
    +            )
    +        except Exception as e:
    +            logger.debug("[Slack] assistant.threads.setSuggestedPrompts failed: %s", e)
    +
    +    def _assistant_thread_title_enabled(self) -> bool:
    +        raw = self.config.extra.get("assistant_thread_titles", True)
    +        if isinstance(raw, str):
    +            return raw.strip().lower() not in {"0", "false", "no", "off"}
    +        return bool(raw)
    +
    +    async def _set_assistant_thread_title(
    +        self,
    +        channel_id: str,
    +        thread_ts: str,
    +        title_source: str,
    +        *,
    +        team_id: str = "",
    +    ) -> None:
    +        """Best-effort title for visible Slack AI DM threads."""
    +        if (
    +            not self._app
    +            or not channel_id
    +            or not thread_ts
    +            or not title_source
    +            or not self._assistant_thread_title_enabled()
    +        ):
    +            return
    +
    +        key = self._workspace_thread_key(team_id, channel_id, thread_ts)
    +        if not key or key in self._titled_assistant_threads:
    +            return
    +
    +        title = re.sub(r"\s+", " ", title_source).strip()
    +        if not title or title.startswith("/"):
    +            return
    +        if len(title) > 80:
    +            title = title[:77].rstrip() + "..."
    +
    +        try:
    +            await self._get_client(channel_id, team_id=team_id).assistant_threads_setTitle(
    +                channel_id=channel_id,
    +                thread_ts=thread_ts,
    +                title=title,
    +            )
    +        except Exception as e:
    +            logger.debug("[Slack] assistant.threads.setTitle failed: %s", e)
    +            return
    +
    +        self._titled_assistant_threads.add(key)
    +        if len(self._titled_assistant_threads) > self._TITLED_ASSISTANT_THREADS_MAX:
    +            excess = (
    +                len(self._titled_assistant_threads)
    +                - self._TITLED_ASSISTANT_THREADS_MAX // 2
    +            )
    +            for old_key in list(self._titled_assistant_threads)[:excess]:
    +                self._titled_assistant_threads.discard(old_key)
    +
         def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:
             """Prime the session store so assistant threads get stable user scoping."""
             session_store = getattr(self, "_session_store", None)
    ```

    ```diff
    @@ -2490,6 +2881,7 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:
                 user_id=user_id,
                 thread_id=thread_ts,
                 chat_topic=metadata.get("context_channel_id") or None,
    +            scope_id=metadata.get("team_id") or None,
             )
     
             try:
    ```

    ```diff
    @@ -2502,13 +2894,107 @@ def _seed_assistant_thread_session(self, metadata: Dict[str, str]) -> None:
                     exc_info=True,
                 )
     
    -    async def _handle_assistant_thread_lifecycle_event(self, event: dict) -> None:
    +    def _seed_agent_dm_session(self, metadata: Dict[str, str]) -> None:
    +        """Prime the session store when Slack reports a user opened the DM.
    +
    +        In Slack's Agent messaging experience, ``app_home_opened`` with
    +        ``tab == "messages"`` replaces ``assistant_thread_started`` as the
    +        "user opened the DM" signal. It is only a lifecycle signal: do not send
    +        a welcome message or enter the agent loop from this event.
    +        """
    +        session_store = getattr(self, "_session_store", None)
    +        if not session_store:
    +            return
    +
    +        channel_id = metadata.get("channel_id", "")
    +        user_id = metadata.get("user_id", "")
    +        if not channel_id or not user_id:
    +            return
    +
    +        source = self.build_source(
    +            chat_id=channel_id,
    +            chat_name=channel_id,
    +            chat_type="dm",
    +            user_id=user_id,
    +            chat_topic=metadata.get("context_channel_id") or None,
    +            scope_id=metadata.get("team_id") or None,
    +        )
    +
    +        try:
    +            session_store.get_or_create_session(source)
    +        except Exception:
    +            logger.debug(
    +                "[Slack] Failed to seed agent DM session for %s",
    +                channel_id,
    +                exc_info=True,
    +            )
    +
    +    async def _handle_assistant_thread_lifecycle_event(
    +        self, event: dict, body: Optional[dict] = None
    +    ) -> None:
             """Handle Slack Assistant lifecycle events that carry user/thread identity."""
    -        metadata = self._extract_assistant_thread_metadata(event)
    +        metadata = self._extract_assistant_thread_metadata(event, body)
             self._cache_assistant_thread_metadata(metadata)
             self._seed_assistant_thread_session(metadata)
    +        await self._set_assistant_suggested_prompts(
    +            metadata.get("channel_id", ""),
    +            team_id=metadata.get("team_id", ""),
    +            thread_ts=metadata.get("thread_ts", ""),
    +        )
    +
    +    async def _handle_app_context_changed(
    +        self, event: dict, body: Optional[dict] = None
    +    ) -> None:
    +        """Cache the current Agent-view context without entering the agent loop."""
    +        context = event.get("context") or event.get("app_context") or {}
    +        context_channel_id = self._context_channel_id(context)
    +        user_id = event.get("user") or event.get("user_id") or ""
    +        team_id = self._event_team_id(event, body)
    +        # ``context_channel_id`` is a channel the user is viewing, not the DM
    +        # Hermes owns. Do not write it into _channel_team: channel IDs can be
    +        # shared across Slack Connect workspaces, so doing so can misroute a
    +        # later unrelated send. Workspace ownership is recorded from actual
    +        # inbound DM/channel events below.
    +        self._cache_agent_view_context(
    +            {
    +                "context_channel_id": str(context_channel_id) if context_channel_id else "",
    +                "user_id": str(user_id) if user_id else "",
    +                "team_id": str(team_id) if team_id else "",
    +            }
    +        )
    +
    +    async def _handle_app_home_opened(
    +        self, event: dict, body: Optional[dict] = None
    +    ) -> None:
    +        """Handle Slack Agent DM-open lifecycle events without producing replies."""
    +        if event.get("tab") != "messages":
    +            return
    +
    +        context = event.get("context") or event.get("app_context") or {}
    +        channel_id = event.get("channel") or event.get("channel_id") or ""
    +        user_id = event.get("user") or event.get("user_id") or ""
    +        team_id = self._event_team_id(event, body)
    +        context_channel_id = self._context_channel_id(context)
    +
    +        if team_id and channel_id:
    +            self._channel_team[str(channel_id)] = str(team_id)
    +
    +        metadata = {
    +            "channel_id": str(channel_id) if channel_id else "",
    +            "user_id": str(user_id) if user_id else "",
    +            "team_id": str(team_id) if team_id else "",
    +            "context_channel_id": context_channel_id,
    +        }
    +        self._cache_agent_view_context(metadata)
    +        self._seed_agent_dm_session(metadata)
    +        await self._set_assistant_suggested_prompts(
    +            metadata["channel_id"],
    +            team_id=metadata["team_id"],
    +        )
     
    -    async def _handle_slack_file_shared(self, event: dict) -> None:
    +    async def _handle_slack_file_shared(
    +        self, event: dict, body: Optional[dict] = None
    +    ) -> None:
             """Fallback for Slack file shares that do not arrive as message.files.
     
             Slack documents ``file_shared`` as a file-ID-only event; callers must
    ```

    ```diff
    @@ -2522,7 +3008,7 @@ async def _handle_slack_file_shared(self, event: dict) -> None:
             if not channel_id or not file_id:
                 return
     
    -        team_id = event.get("team_id") or event.get("team") or ""
    +        team_id = self._event_team_id(event, body)
             try:
                 client = self._team_clients.get(team_id) if team_id else None
                 info_resp = await (client or self._get_client(channel_id)).files_info(
    ```

    ```diff
    @@ -2586,7 +3072,9 @@ async def _handle_slack_file_shared(self, event: dict) -> None:
                 fallback_event["thread_ts"] = thread_ts
             await self._handle_slack_message(fallback_event)
     
    -    async def _handle_slack_message(self, event: dict) -> None:
    +    async def _handle_slack_message(
    +        self, event: dict, payload: Optional[dict] = None
    +    ) -> None:
             """Handle an incoming Slack message event."""
             # Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
             event_ts = event.get("ts", "")
    ```

    ```diff
    @@ -2738,18 +3226,20 @@ async def _handle_slack_message(self, event: dict) -> None:
     
             channel_id = event.get("channel", "")
             ts = event.get("ts", "")
    +        outer_team_id = self._event_team_id(event, payload)
             assistant_meta = self._lookup_assistant_thread_metadata(
                 event,
                 channel_id=channel_id,
                 thread_ts=event.get("thread_ts", ""),
    +            team_id=outer_team_id,
    +            body=payload,
             )
             user_id = event.get("user") or assistant_meta.get("user_id", "")
             if not channel_id:
                 channel_id = assistant_meta.get("channel_id", "")
    -        team_id = (
    -            event.get("team")
    -            or event.get("team_id")
    -            or assistant_meta.get("team_id", "")
    +        team_id = outer_team_id or assistant_meta.get("team_id", "")
    +        agent_context = self._agent_view_context_for_event(
    +            event, str(team_id or ""), str(user_id or "")
             )
     
             # Track which workspace owns this channel
    ```

    ```diff
    @@ -2859,6 +3349,7 @@ async def _handle_slack_message(self, event: dict) -> None:
                         channel_id=channel_id,
                         thread_ts=event_thread_ts,
                         user_id=user_id,
    +                    team_id=team_id,
                     )
                     if (
                         not reply_to_bot_thread
    ```

    ```diff
    @@ -2889,6 +3380,7 @@ async def _handle_slack_message(self, event: dict) -> None:
                 channel_id=channel_id,
                 thread_ts=event_thread_ts,
                 user_id=user_id,
    +            team_id=team_id,
             ):
                 thread_context = await self._fetch_thread_context(
                     channel_id=channel_id,
    ```

    ```diff
    @@ -2920,9 +3412,9 @@ async def _handle_slack_message(self, event: dict) -> None:
                     if not file_id:
                         continue
                     try:
    -                    info_resp = await self._get_client(channel_id).files_info(
    -                        file=file_id
    -                    )
    +                    info_resp = await self._get_client(
    +                        channel_id, team_id=team_id
    +                    ).files_info(file=file_id)
                         if info_resp.get("ok"):
                             f = info_resp["file"]
                         else:
    ```

    ```diff
    @@ -3158,7 +3650,20 @@ async def _handle_slack_message(self, event: dict) -> None:
                     msg_type = MessageType.DOCUMENT
     
             # Resolve user display name (cached after first lookup)
    -        user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
    +        user_name = await self._resolve_user_name(
    +            user_id, chat_id=channel_id, team_id=team_id
    +        )
    +
    +        # Slack's AI Agent Messages tab shows visible app threads; title the
    +        # first DM thread turn from the user's prompt when Slack AI APIs are
    +        # available. This is best-effort and configurable via config.yaml.
    +        if is_dm and thread_ts and msg_type != MessageType.COMMAND:
    +            await self._set_assistant_thread_title(
    +                channel_id,
    +                thread_ts,
    +                original_text or text,
    +                team_id=team_id,
    +            )
     
             # Build source
             source = self.build_source(
    ```

    ```diff
    @@ -3168,6 +3673,7 @@ async def _handle_slack_message(self, event: dict) -> None:
                 user_id=user_id,
                 user_name=user_name,
                 thread_id=thread_ts,
    +            scope_id=str(team_id) if team_id else None,
                 # Slack Workflow Builder / app posts arrive as
                 # subtype=bot_message with user=None; flag them so the
                 # gateway SLACK_ALLOW_BOTS bypass can authorize them
    ```

    ```diff
    @@ -3223,6 +3729,11 @@ async def _handle_slack_message(self, event: dict) -> None:
                 channel_prompt=_channel_prompt,
                 reply_to_text=reply_to_text,
                 auto_skill=_auto_skill,
    +            metadata={
    +                "slack_team_id": team_id,
    +                "slack_channel_id": channel_id,
    +                "slack_thread_ts": thread_ts,
    +            },
             )
     
             # Only react when bot is directly addressed (1:1 DM or @mention).
    ```

    ```diff
    @@ -3233,6 +3744,18 @@ async def _handle_slack_message(self, event: dict) -> None:
             if _should_react:
                 self._reacting_message_ids.add(ts)
     
    +        # App-context is per-turn, user-controlled Slack UI state. Surface it
    +        # with the inbound user message rather than storing it on SessionSource:
    +        # putting it in the cached system prompt would rebuild the agent whenever
    +        # the user switches views and would let stale context bleed into later
    +        # turns. The agent receives an inert label, never a fetched channel body.
    +        context_channel_id = agent_context.get("context_channel_id", "")
    +        if context_channel_id and context_channel_id != channel_id:
    +            msg_event.text = (
    +                f"[Slack app context: user is viewing channel {context_channel_id}]\n\n"
    +                f"{msg_event.text}"
    +            )
    +
             await self.handle_message(msg_event)
     
         # ----- Approval button support (Block Kit) -----
    ```

    ```diff
    @@ -3321,7 +3844,9 @@ async def send_exec_approval(
                 if thread_ts:
                     kwargs["thread_ts"] = thread_ts
     
    -            result = await self._get_client(chat_id).chat_postMessage(**kwargs)
    +            result = await self._get_client(
    +                chat_id, team_id=self._metadata_team_id(metadata)
    +            ).chat_postMessage(**kwargs)
                 msg_ts = result.get("ts", "")
                 if msg_ts:
                     self._approval_resolved[msg_ts] = False
    ```

    ```diff
    @@ -3399,7 +3924,9 @@ async def send_slash_confirm(
                 if thread_ts:
                     kwargs["thread_ts"] = thread_ts
     
    -            result = await self._get_client(chat_id).chat_postMessage(**kwargs)
    +            result = await self._get_client(
    +                chat_id, team_id=self._metadata_team_id(metadata)
    +            ).chat_postMessage(**kwargs)
                 return SendResult(
                     success=True, message_id=result.get("ts", ""), raw_response=result
                 )
    ```

    ```diff
    @@ -3413,6 +3940,7 @@ def _is_interactive_user_authorized(
             *,
             channel_id: str = "",
             user_name: Optional[str] = None,
    +        team_id: str = "",
         ) -> bool:
             """Return whether a Slack interactive caller may perform gated actions."""
             normalized_user_id = str(user_id or "").strip()
    ```

    ```diff
    @@ -3431,6 +3959,7 @@ def _is_interactive_user_authorized(
                         chat_type="dm" if str(channel_id or "").startswith("D") else "group",
                         user_id=normalized_user_id,
                         user_name=str(user_name).strip() if user_name else None,
    +                    scope_id=str(team_id) if team_id else None,
                     )
                     return bool(auth_fn(source))
                 except Exception:
    ```

    ```diff
    @@ -3460,6 +3989,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:
             """Handle a slash-confirm button click from Block Kit."""
             await ack()
     
    +        team_id = self._event_team_id({}, body)
             action_id = action.get("action_id", "")
             value = action.get("value", "")
             message = body.get("message", {})
    ```

    ```diff
    @@ -3471,6 +4001,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:
                 user_id,
                 channel_id=channel_id,
                 user_name=user_name,
    +            team_id=team_id,
             ):
                 logger.warning(
                     "[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring",
    ```

    ```diff
    @@ -3535,7 +4066,7 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:
             ]
     
             try:
    -            await self._get_client(channel_id).chat_update(
    +            await self._get_client(channel_id, team_id=team_id or None).chat_update(
                     channel=channel_id,
                     ts=msg_ts,
                     text=decision_text,
    ```

    ```diff
    @@ -3560,7 +4091,9 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:
                     thread_ts = message.get("thread_ts") or msg_ts
                     if thread_ts:
                         post_kwargs["thread_ts"] = thread_ts
    -                await self._get_client(channel_id).chat_postMessage(**post_kwargs)
    +                await self._get_client(
    +                    channel_id, team_id=team_id or None
    +                ).chat_postMessage(**post_kwargs)
                 logger.info(
                     "Slack button resolved slash-confirm for session %s (choice=%s, user=%s)",
                     session_key,
    ```

    ```diff
    @@ -3574,10 +4107,27 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:
                     exc_info=True,
                 )
     
    +    async def _handle_feedback_action(self, ack, body, action) -> None:
    +        """Ack Slack AI feedback button clicks and log the choice."""
    +        await ack()
    +
    +        value = str(action.get("value") or "")
    +        message = body.get("message", {}) or {}
    +        channel_id = (body.get("channel") or {}).get("id", "")
    +        user_id = (body.get("user") or {}).get("id", "")
    +        logger.info(
    +            "[Slack] Feedback button clicked: value=%s user=%s channel=%s ts=%s",
    +            value,
    +            user_id,
    +            channel_id,
    +            message.get("ts", ""),
    +        )
    +
         async def _handle_approval_action(self, ack, body, action) -> None:
             """Handle an approval button click from Block Kit."""
             await ack()
     
    +        team_id = self._event_team_id({}, body)
             action_id = action.get("action_id", "")
             session_key = action.get("value", "")
             message = body.get("message", {})
    ```

    ```diff
    @@ -3590,6 +4140,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:
                 user_id,
                 channel_id=channel_id,
                 user_name=user_name,
    +            team_id=team_id,
             ):
                 logger.warning(
                     "[Slack] Unauthorized approval click by %s (%s) - ignoring",
    ```

    ```diff
    @@ -3657,7 +4208,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:
             ]
     
             try:
    -            await self._get_client(channel_id).chat_update(
    +            await self._get_client(channel_id, team_id=team_id or None).chat_update(
                     channel=channel_id,
                     ts=msg_ts,
                     text=decision_text,
    ```

    ```diff
    @@ -3717,7 +4268,7 @@ async def _fetch_thread_context(
                 return cached.content
     
             try:
    -            client = self._get_client(channel_id)
    +            client = self._get_client(channel_id, team_id=team_id)
     
                 # Retry with exponential backoff for Tier-3 rate limits (429).
                 result = None
    ```

    ```diff
    @@ -3802,7 +4353,9 @@ async def _fetch_thread_context(
                     # Prefer the bot's own name when the message is a bot post.
                     if is_bot and not display_user:
                         display_user = msg.get("username") or "bot"
    -                name = await self._resolve_user_name(display_user, chat_id=channel_id)
    +                name = await self._resolve_user_name(
    +                    display_user, chat_id=channel_id, team_id=team_id
    +                )
     
                     # Mark senders not on the allowlist as [unverified] so the LLM
                     # treats their content as background reference rather than
    ```

    ```diff
    @@ -3878,7 +4431,7 @@ async def _fetch_thread_parent_text(
                 return cached.parent_text
     
             try:
    -            client = self._get_client(channel_id)
    +            client = self._get_client(channel_id, team_id=team_id)
                 result = await client.conversations_replies(
                     channel=channel_id,
                     ts=thread_ts,
    ```

    ```diff
    @@ -3961,6 +4514,7 @@ async def _handle_slash_command(self, command: dict) -> None:
                 chat_id=channel_id,
                 chat_type="dm" if is_dm else "group",
                 user_id=user_id,
    +            scope_id=team_id or None,
             )
     
             event = MessageEvent(
    ```

    ```diff
    @@ -3998,6 +4552,7 @@ def _has_active_session_for_thread(
             channel_id: str,
             thread_ts: str,
             user_id: str,
    +        team_id: str = "",
         ) -> bool:
             """Check if there's an active session for a thread.
     
    ```

    ```diff
    @@ -4022,6 +4577,7 @@ def _has_active_session_for_thread(
                     chat_type="group",
                     user_id=user_id,
                     thread_id=thread_ts,
    +                scope_id=team_id or None,
                 )
     
                 # Read session isolation settings from the store's config
    ```
  - `pyproject.toml`

    ```diff
    @@ -158,9 +158,9 @@ modal = ["modal==1.3.4"]
     daytona = ["daytona==0.155.0"]
     hindsight = ["hindsight-client==0.6.1"]
     dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"]  # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82)
    -messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]  # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
    +messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"]  # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
     cron = []  # croniter is now a core dependency; this extra kept for back-compat
    -slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.14.1"]
    +slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"]
     matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.1"]  # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 (mautrix/aiohttp-socks only cap aiohttp<4 / >=3.10, so pin the patched floor directly)
     # WeCom callback-mode adapter — parses untrusted XML POST bodies from
     # WeCom-controlled callback endpoints, so we use defusedxml (drop-in
    ```
  - `scripts/release.py`

    ```diff
    @@ -47,6 +47,7 @@
     AUTHOR_MAP = {
         "[redacted:email]": "mguttmann",  # PR #63738 salvage (Anthropic setup-token pool auth normalization)
         "[redacted:email]": "VrtxOmega",  # PR #43809 salvage (desktop: WSL folder-picker path bridge)
    +    "[redacted:email]": "SemonCat",  # PR #56786 salvage (Slack Agent View manifests and Assistant APIs)
         "[redacted:email]": "jakelongvu-bot",  # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)
         "luigi@users.noreply.github.com": "Tortugasaur",  # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)
         "[redacted:email]": "kavioavio",  # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override)
    ```
  - `tests/gateway/test_base_topic_sessions.py`

    ```diff
    @@ -41,6 +41,9 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
             self.typing.append({"chat_id": chat_id, "metadata": metadata})
             return None
     
    +    async def stop_typing(self, chat_id: str, metadata=None) -> None:
    +        self.typing.append({"chat_id": chat_id, "stopped": True, "metadata": metadata})
    +
         async def get_chat_info(self, chat_id: str):
             return {"id": chat_id}
     
    ```

    ```diff
    @@ -143,6 +146,11 @@ async def hold_typing(_chat_id, interval=2.0, metadata=None):
                     "metadata": {"thread_id": "17585"},
                 }
             ]
    +        assert {
    +            "chat_id": "-1001",
    +            "stopped": True,
    +            "metadata": {"thread_id": "17585"},
    +        } in adapter.typing
             assert adapter.processing_hooks == [
                 ("start", "1"),
                 ("complete", "1", ProcessingOutcome.SUCCESS),
    ```
  - `tests/gateway/test_slack.py`

    ```diff
    @@ -17,6 +17,7 @@
     import pytest
     
     from gateway.config import Platform, PlatformConfig
    +from gateway.run import GatewayRunner
     from gateway.platforms.base import (
         MessageEvent,
         MessageType,
    ```

    ```diff
    @@ -148,6 +149,7 @@ async def test_channel_slash_command_uses_group_session_semantics(self, adapter)
             assert event.source.chat_type == "group"
             assert event.source.chat_id == "C123"
             assert event.source.user_id == "U123"
    +        assert event.source.scope_id == "T123"
     
         @pytest.mark.asyncio
         async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter):
    ```

    ```diff
    @@ -165,6 +167,7 @@ async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter):
             assert event.source.chat_type == "dm"
             assert event.source.chat_id == "D123"
             assert event.source.user_id == "U123"
    +        assert event.source.scope_id == "T123"
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -238,6 +241,8 @@ def decorator(fn):
     
             assert "message" in registered_events
             assert "app_mention" in registered_events
    +        assert "app_home_opened" in registered_events
    +        assert "app_context_changed" in registered_events
             assert "reaction_added" in registered_events
             assert "reaction_removed" in registered_events
             assert "assistant_thread_started" in registered_events
    ```

    ```diff
    @@ -799,6 +804,7 @@ async def close_async(self):
             assert adapter._handler is not None
             assert adapter._handler.proxy == "http://proxy.example.com:3128"
             assert adapter._handler.client.proxy == "http://proxy.example.com:3128"
    +        assert "hermes_feedback" in created_apps[0].registered_actions
     
         @pytest.mark.asyncio
         async def test_connect_clears_proxy_when_no_proxy_matches_slack(self):
    ```

    ```diff
    @@ -915,6 +921,25 @@ async def test_send_document_success(self, adapter, tmp_path):
             assert call_kwargs["filename"] == "report.pdf"
             assert call_kwargs["initial_comment"] == "Here's the report"
     
    +    @pytest.mark.asyncio
    +    async def test_send_document_uses_metadata_workspace_client(self, adapter, tmp_path):
    +        """Outbound media follows the inbound Slack workspace across gateway boundaries."""
    +        test_file = tmp_path / "report.pdf"
    +        test_file.write_bytes(b"%PDF-1.4 fake content")
    +        secondary_client = AsyncMock()
    +        secondary_client.files_upload_v2 = AsyncMock(return_value={"ok": True})
    +        adapter._team_clients["T_SECONDARY"] = secondary_client
    +
    +        result = await adapter.send_document(
    +            chat_id="C123",
    +            file_path=str(test_file),
    +            metadata={"slack_team_id": "T_SECONDARY"},
    +        )
    +
    +        assert result.success
    +        secondary_client.files_upload_v2.assert_awaited_once()
    +        adapter._app.client.files_upload_v2.assert_not_called()
    +
         @pytest.mark.asyncio
         async def test_send_document_custom_name(self, adapter, tmp_path):
             test_file = tmp_path / "data.csv"
    ```

    ```diff
    @@ -2072,7 +2097,7 @@ async def test_stop_typing_clears_tracked_thread(self, adapter):
                 thread_ts="parent_ts",
                 status="",
             )
    -        assert "C123" not in adapter._active_status_threads
    +        assert ("", "C123", "parent_ts") not in adapter._active_status_threads
     
         @pytest.mark.asyncio
         async def test_stop_typing_noop_without_tracked_thread(self, adapter):
    ```

    ```diff
    @@ -2084,7 +2109,10 @@ async def test_stop_typing_noop_without_tracked_thread(self, adapter):
     
         @pytest.mark.asyncio
         async def test_stop_typing_handles_api_error_gracefully(self, adapter):
    -        adapter._active_status_threads["C123"] = "parent_ts"
    +        adapter._active_status_threads[("", "C123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "",
    +        }
             adapter._app.client.assistant_threads_setStatus = AsyncMock(
                 side_effect=Exception("missing_scope")
             )
    ```

    ```diff
    @@ -2096,7 +2124,7 @@ async def test_stop_typing_handles_api_error_gracefully(self, adapter):
                 thread_ts="parent_ts",
                 status="",
             )
    -        assert "C123" not in adapter._active_status_threads
    +        assert ("", "C123", "parent_ts") not in adapter._active_status_threads
     
         @pytest.mark.asyncio
         async def test_send_clears_status_after_final_post(self, adapter):
    ```

    ```diff
    @@ -2104,7 +2132,10 @@ async def test_send_clears_status_after_final_post(self, adapter):
                 return_value={"ts": "reply_ts"}
             )
             adapter._app.client.assistant_threads_setStatus = AsyncMock()
    -        adapter._active_status_threads["C123"] = "parent_ts"
    +        adapter._active_status_threads[("", "C123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "",
    +        }
     
             result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"})
     
    ```

    ```diff
    @@ -2115,13 +2146,16 @@ async def test_send_clears_status_after_final_post(self, adapter):
                 thread_ts="parent_ts",
                 status="",
             )
    -        assert "C123" not in adapter._active_status_threads
    +        assert ("", "C123", "parent_ts") not in adapter._active_status_threads
     
         @pytest.mark.asyncio
         async def test_streaming_final_edit_clears_status(self, adapter):
             adapter._app.client.chat_update = AsyncMock()
             adapter._app.client.assistant_threads_setStatus = AsyncMock()
    -        adapter._active_status_threads["C123"] = "parent_ts"
    +        adapter._active_status_threads[("", "C123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "",
    +        }
     
             result = await adapter.edit_message(
                 "C123",
    ```

    ```diff
    @@ -2141,13 +2175,16 @@ async def test_streaming_final_edit_clears_status(self, adapter):
                 thread_ts="parent_ts",
                 status="",
             )
    -        assert "C123" not in adapter._active_status_threads
    +        assert ("", "C123", "parent_ts") not in adapter._active_status_threads
     
         @pytest.mark.asyncio
         async def test_streaming_intermediate_edit_keeps_status(self, adapter):
             adapter._app.client.chat_update = AsyncMock()
             adapter._app.client.assistant_threads_setStatus = AsyncMock()
    -        adapter._active_status_threads["C123"] = "parent_ts"
    +        adapter._active_status_threads[("", "C123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "",
    +        }
     
             result = await adapter.edit_message(
                 "C123",
    ```

    ```diff
    @@ -2158,7 +2195,195 @@ async def test_streaming_intermediate_edit_keeps_status(self, adapter):
     
             assert result.success
             adapter._app.client.assistant_threads_setStatus.assert_not_called()
    -        assert adapter._active_status_threads["C123"] == "parent_ts"
    +        assert adapter._active_status_threads[("", "C123", "parent_ts")][
    +            "thread_ts"
    +        ] == "parent_ts"
    +
    +    @pytest.mark.asyncio
    +    async def test_status_uses_workspace_client_from_metadata(self, adapter):
    +        team_client = AsyncMock()
    +        adapter._team_clients["T_OTHER"] = team_client
    +
    +        await adapter.send_typing(
    +            "D123",
    +            metadata={"thread_id": "parent_ts", "team_id": "T_OTHER"},
    +        )
    +        await adapter.stop_typing("D123")
    +
    +        assert team_client.assistant_threads_setStatus.call_args_list == [
    +            call(channel_id="D123", thread_ts="parent_ts", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="parent_ts", status=""),
    +        ]
    +        adapter._app.client.assistant_threads_setStatus.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_status_accepts_slack_team_metadata_key(self, adapter):
    +        team_client = AsyncMock()
    +        adapter._team_clients["T_OTHER"] = team_client
    +
    +        await adapter.send_typing(
    +            "D123",
    +            metadata={"thread_id": "parent_ts", "slack_team_id": "T_OTHER"},
    +        )
    +        await adapter.stop_typing("D123", metadata={"slack_team_id": "T_OTHER"})
    +
    +        assert team_client.assistant_threads_setStatus.call_args_list == [
    +            call(channel_id="D123", thread_ts="parent_ts", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="parent_ts", status=""),
    +        ]
    +        adapter._app.client.assistant_threads_setStatus.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_status_tracking_is_per_thread(self, adapter):
    +        adapter._app.client.assistant_threads_setStatus = AsyncMock()
    +
    +        await adapter.send_typing("D123", metadata={"thread_id": "thread_a"})
    +        await adapter.send_typing("D123", metadata={"thread_id": "thread_b"})
    +        await adapter.stop_typing("D123", metadata={"thread_id": "thread_a"})
    +
    +        assert adapter._app.client.assistant_threads_setStatus.call_args_list == [
    +            call(channel_id="D123", thread_ts="thread_a", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="thread_b", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="thread_a", status=""),
    +        ]
    +        assert ("", "D123", "thread_a") not in adapter._active_status_threads
    +        assert adapter._active_status_threads[("", "D123", "thread_b")] == {
    +            "thread_ts": "thread_b",
    +            "team_id": "",
    +        }
    +
    +    @pytest.mark.asyncio
    +    async def test_stop_typing_with_metadata_preserves_sibling_status(self, adapter):
    +        adapter._app.client.assistant_threads_setStatus = AsyncMock()
    +        await adapter.send_typing("D123", metadata={"thread_id": "thread_a"})
    +        await adapter.send_typing("D123", metadata={"thread_id": "thread_b"})
    +
    +        await adapter._stop_typing_with_metadata(
    +            "D123", {"thread_id": "thread_a"}
    +        )
    +
    +        assert adapter._app.client.assistant_threads_setStatus.call_args_list == [
    +            call(channel_id="D123", thread_ts="thread_a", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="thread_b", status="is thinking..."),
    +            call(channel_id="D123", thread_ts="thread_a", status=""),
    +        ]
    +        assert ("", "D123", "thread_b") in adapter._active_status_threads
    +
    +    @pytest.mark.asyncio
    +    async def test_status_tracking_is_scoped_per_workspace(self, adapter):
    +        one, two = AsyncMock(), AsyncMock()
    +        adapter._team_clients.update({"T_ONE": one, "T_TWO": two})
    +
    +        await adapter.send_typing(
    +            "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"}
    +        )
    +        await adapter.send_typing(
    +            "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_TWO"}
    +        )
    +        await adapter.stop_typing(
    +            "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"}
    +        )
    +
    +        assert one.assistant_threads_setStatus.call_args_list[-1] == call(
    +            channel_id="D_SHARED", thread_ts="171.000", status=""
    +        )
    +        assert two.assistant_threads_setStatus.call_args_list[-1] == call(
    +            channel_id="D_SHARED", thread_ts="171.000", status="is thinking..."
    +        )
    +        assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads
    +
    +    @pytest.mark.asyncio
    +    async def test_stop_typing_without_team_uses_unique_thread_status(self, adapter):
    +        """A stale channel fallback must not strand a uniquely tracked status."""
    +        team_one, team_two = AsyncMock(), AsyncMock()
    +        adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two})
    +        await adapter.send_typing(
    +            "D_SHARED",
    +            metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"},
    +        )
    +        # Another workspace can overwrite this channel-only fallback map.
    +        adapter._channel_team["D_SHARED"] = "T_TWO"
    +
    +        await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"})
    +
    +        assert team_one.assistant_threads_setStatus.call_args_list[-1] == call(
    +            channel_id="D_SHARED", thread_ts="171.000", status=""
    +        )
    +        assert ("T_ONE", "D_SHARED", "171.000") not in adapter._active_status_threads
    +        team_two.assistant_threads_setStatus.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_stop_typing_without_team_preserves_ambiguous_thread_statuses(
    +        self, adapter
    +    ):
    +        """Without team metadata, matching workspace statuses must not be guessed."""
    +        team_one, team_two = AsyncMock(), AsyncMock()
    +        adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two})
    +        for team_id in ("T_ONE", "T_TWO"):
    +            await adapter.send_typing(
    +                "D_SHARED",
    +                metadata={"thread_id": "171.000", "slack_team_id": team_id},
    +            )
    +
    +        await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"})
    +
    +        assert ("T_ONE", "D_SHARED", "171.000") in adapter._active_status_threads
    +        assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads
    +        assert team_one.assistant_threads_setStatus.call_count == 1
    +        assert team_two.assistant_threads_setStatus.call_count == 1
    +
    +    @pytest.mark.asyncio
    +    async def test_streaming_final_edit_uses_workspace_client_from_metadata(
    +        self, adapter
    +    ):
    +        team_client = AsyncMock()
    +        team_client.chat_update = AsyncMock()
    +        team_client.assistant_threads_setStatus = AsyncMock()
    +        adapter._team_clients["T_OTHER"] = team_client
    +        adapter._active_status_threads[("T_OTHER", "D123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "T_OTHER",
    +        }
    +
    +        result = await adapter.edit_message(
    +            "D123",
    +            "reply_ts",
    +            "done",
    +            finalize=True,
    +            metadata={"thread_id": "parent_ts", "slack_team_id": "T_OTHER"},
    +        )
    +
    +        assert result.success
    +        team_client.chat_update.assert_awaited_once_with(
    +            channel="D123",
    +            ts="reply_ts",
    +            text="done",
    +        )
    +        team_client.assistant_threads_setStatus.assert_awaited_once_with(
    +            channel_id="D123",
    +            thread_ts="parent_ts",
    +            status="",
    +        )
    +        adapter._app.client.chat_update.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_send_failure_clears_status(self, adapter):
    +        adapter._app.client.chat_postMessage = AsyncMock(side_effect=Exception("boom"))
    +        adapter._app.client.assistant_threads_setStatus = AsyncMock()
    +        adapter._active_status_threads[("", "C123", "parent_ts")] = {
    +            "thread_ts": "parent_ts",
    +            "team_id": "",
    +        }
    +
    +        result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"})
    +
    +        assert not result.success
    +        adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
    +            channel_id="C123",
    +            thread_ts="parent_ts",
    +            status="",
    +        )
    +        assert ("", "C123", "parent_ts") not in adapter._active_status_threads
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -2914,7 +3139,7 @@ async def test_no_session_store_ignores_thread_replies(self, adapter):
     
     
     class TestAssistantThreadLifecycle:
    -    """Slack Assistant lifecycle events should seed session/user context."""
    +    """Slack AI lifecycle events should seed session/user context."""
     
         @pytest.fixture()
         def mock_session_store(self):
    ```

    ```diff
    @@ -2957,7 +3182,9 @@ async def test_lifecycle_event_seeds_session_store(
             await assistant_adapter._handle_assistant_thread_lifecycle_event(event)
     
             assert (
    -            assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"]
    +            assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")][
    +                "user_id"
    +            ]
                 == "U_USER"
             )
             mock_session_store.get_or_create_session.assert_called_once()
    ```

    ```diff
    @@ -2968,11 +3195,51 @@ async def test_lifecycle_event_seeds_session_store(
             assert source.thread_id == "171.000"
             assert source.chat_topic == "C_ORIGIN"
     
    +    @pytest.mark.asyncio
    +    async def test_app_home_messages_tab_seeds_dm_session(
    +        self, assistant_adapter, mock_session_store
    +    ):
    +        event = {
    +            "type": "app_home_opened",
    +            "tab": "messages",
    +            "team": "T_TEAM",
    +            "channel": "D123",
    +            "user": "U_USER",
    +        }
    +
    +        await assistant_adapter._handle_app_home_opened(event)
    +
    +        mock_session_store.get_or_create_session.assert_called_once()
    +        source = mock_session_store.get_or_create_session.call_args[0][0]
    +        assert source.chat_id == "D123"
    +        assert source.chat_type == "dm"
    +        assert source.user_id == "U_USER"
    +        assert source.thread_id is None
    +        assert assistant_adapter._channel_team["D123"] == "T_TEAM"
    +        assistant_adapter.handle_message.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_app_home_non_messages_tab_is_ignored(
    +        self, assistant_adapter, mock_session_store
    +    ):
    +        event = {
    +            "type": "app_home_opened",
    +            "tab": "home",
    +            "team": "T_TEAM",
    +            "channel": "D123",
    +            "user": "U_USER",
    +        }
    +
    +        await assistant_adapter._handle_app_home_opened(event)
    +
    +        mock_session_store.get_or_create_session.assert_not_called()
    +        assistant_adapter.handle_message.assert_not_called()
    +
         @pytest.mark.asyncio
         async def test_message_uses_cached_assistant_thread_identity(
             self, assistant_adapter
         ):
    -        assistant_adapter._assistant_threads[("D123", "171.000")] = {
    +        assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")] = {
                 "channel_id": "D123",
                 "thread_ts": "171.000",
                 "user_id": "U_USER",
    ```

    ```diff
    @@ -3023,8 +3290,270 @@ def test_assistant_threads_cache_eviction(self, assistant_adapter):
                 }
             )
             assert len(assistant_adapter._assistant_threads) <= 10
    -        # The newest entry must survive eviction
    -        assert ("D999", "999.000") in assistant_adapter._assistant_threads
    +        # The newest entry must survive eviction.
    +        assert ("", "D999", "999.000") in assistant_adapter._assistant_threads
    +
    +    def test_suggested_prompts_config_accepts_dict_shape(self, assistant_adapter):
    +        assistant_adapter.config.extra["suggested_prompts"] = {
    +            "title": "Try these",
    +            "prompts": [
    +                {"title": "Summarize", "message": "Summarize this conversation"},
    +                {"title": "", "message": "skip me"},
    +                {"title": "Draft", "message": "Draft a reply"},
    +            ],
    +        }
    +
    +        title, prompts = assistant_adapter._assistant_suggested_prompts()
    +
    +        assert title == "Try these"
    +        assert prompts == [
    +            {"title": "Summarize", "message": "Summarize this conversation"},
    +            {"title": "Draft", "message": "Draft a reply"},
    +        ]
    +
    +    def test_suggested_prompts_config_caps_at_four(self, assistant_adapter):
    +        assistant_adapter.config.extra["suggested_prompts"] = [
    +            {"title": f"Prompt {i}", "message": f"Message {i}"}
    +            for i in range(6)
    +        ]
    +
    +        _title, prompts = assistant_adapter._assistant_suggested_prompts()
    +
    +        assert len(prompts) == 4
    +        assert prompts[-1] == {"title": "Prompt 3", "message": "Message 3"}
    +
    +    @pytest.mark.asyncio
    +    async def test_app_home_messages_tab_sets_agent_suggested_prompts(
    +        self, assistant_adapter
    +    ):
    +        assistant_adapter.config.extra["suggested_prompts"] = {
    +            "title": "Start here",
    +            "prompts": [{"title": "Plan", "message": "Help me plan the work"}],
    +        }
    +        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (
    +            AsyncMock()
    +        )
    +        event = {
    +            "type": "app_home_opened",
    +            "tab": "messages",
    +            "team": "T_TEAM",
    +            "channel": "D123",
    +            "user": "U_USER",
    +        }
    +
    +        await assistant_adapter._handle_app_home_opened(event)
    +
    +        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(
    +            channel_id="D123",
    +            title="Start here",
    +            prompts=[{"title": "Plan", "message": "Help me plan the work"}],
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_assistant_lifecycle_sets_thread_suggested_prompts(
    +        self, assistant_adapter
    +    ):
    +        assistant_adapter.config.extra["suggested_prompts"] = [
    +            {"title": "Summarize", "message": "Summarize the current thread"}
    +        ]
    +        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts = (
    +            AsyncMock()
    +        )
    +        event = {
    +            "type": "assistant_thread_started",
    +            "team_id": "T_TEAM",
    +            "assistant_thread": {
    +                "channel_id": "D123",
    +                "thread_ts": "171.000",
    +                "user_id": "U_USER",
    +            },
    +        }
    +
    +        await assistant_adapter._handle_assistant_thread_lifecycle_event(event)
    +
    +        assistant_adapter._app.client.assistant_threads_setSuggestedPrompts.assert_awaited_once_with(
    +            channel_id="D123",
    +            prompts=[
    +                {"title": "Summarize", "message": "Summarize the current thread"}
    +            ],
    +            thread_ts="171.000",
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_agent_view_context_is_scoped_per_workspace_and_user(
    +        self, assistant_adapter
    +    ):
    +        await assistant_adapter._handle_app_context_changed(
    +            {
    +                "type": "app_context_changed",
    +                "user": "U_ONE",
    +                "context": {
    +                    "entities": [
    +                        {
    +                            "type": "slack#/types/channel_id",
    +                            "value": "C_CONTEXT_ONE",
    +                        }
    +                    ]
    +                },
    +            },
    +            {"team_id": "T_ONE"},
    +        )
    +        await assistant_adapter._handle_app_context_changed(
    +            {
    +                "type": "app_context_changed",
    +                "user": "U_TWO",
    +                "context": {
    +                    "entities": [
    +                        {
    +                            "type": "slack#/types/channel_id",
    +                            "value": "C_CONTEXT_TWO",
    +                        }
    +                    ]
    +                },
    +            },
    +            {"team_id": "T_TWO"},
    +        )
    +
    +        assert assistant_adapter._agent_view_context_for_event(
    +            {}, "T_ONE", "U_ONE"
    +        )["context_channel_id"] == "C_CONTEXT_ONE"
    +        assert assistant_adapter._agent_view_context_for_event(
    +            {}, "T_TWO", "U_TWO"
    +        )["context_channel_id"] == "C_CONTEXT_TWO"
    +        assert "C_CONTEXT_ONE" not in assistant_adapter._channel_team
    +
    +    @pytest.mark.asyncio
    +    async def test_assistant_thread_cache_is_scoped_per_workspace(
    +        self, assistant_adapter
    +    ):
    +        """Slack Connect can reuse a channel/thread pair in multiple workspaces."""
    +        for team_id, user_id in (("T_ONE", "U_ONE"), ("T_TWO", "U_TWO")):
    +            await assistant_adapter._handle_assistant_thread_lifecycle_event(
    +                {
    +                    "type": "assistant_thread_started",
    +                    "team_id": team_id,
    +                    "assistant_thread": {
    +                        "channel_id": "D_SHARED",
    +                        "thread_ts": "171.000",
    +                        "user_id": user_id,
    +                    },
    +                }
    +            )
    +
    +        assert assistant_adapter._assistant_threads[
    +            ("T_ONE", "D_SHARED", "171.000")
    +        ]["user_id"] == "U_ONE"
    +        assert assistant_adapter._assistant_threads[
    +            ("T_TWO", "D_SHARED", "171.000")
    +        ]["user_id"] == "U_TWO"
    +        assert assistant_adapter._lookup_assistant_thread_metadata(
    +            {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_ONE"
    +        )["user_id"] == "U_ONE"
    +        assert assistant_adapter._lookup_assistant_thread_metadata(
    +            {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_TWO"
    +        )["user_id"] == "U_TWO"
    +
    +    @pytest.mark.asyncio
    +    async def test_agent_view_message_preserves_outer_team_and_turn_context(
    +        self, assistant_adapter
    +    ):
    +        assistant_adapter._app.client.users_info = AsyncMock(
    +            return_value={"user": {"profile": {"display_name": "Tyler"}}}
    +        )
    +        assistant_adapter._app.client.reactions_add = AsyncMock()
    +        assistant_adapter._app.client.reactions_remove = AsyncMock()
    +        await assistant_adapter._handle_app_context_changed(
    +            {
    +                "type": "app_context_changed",
    +                "user": "U_USER",
    +                "context": {
    +                    "entities": [
    +                        {
    +                            "type": "slack#/types/channel_id",
    +                            "value": "C_ACTIVE",
    +                        }
    +                    ]
    +                },
    +            },
    +            {"team_id": "T_OTHER"},
    +        )
    +
    +        await assistant_adapter._handle_slack_message(
    +            {
    +                "text": "help me plan",
    +                "channel": "D123",
    +                "channel_type": "im",
    +                "ts": "171.111",
    +                "user": "U_USER",
    +            },
    +            {"team_id": "T_OTHER"},
    +        )
    +
    +        msg_event = assistant_adapter.handle_message.await_args.args[0]
    +        assert msg_event.source.scope_id == "T_OTHER"
    +        assert msg_event.metadata["slack_team_id"] == "T_OTHER"
    +        assert msg_event.source.thread_id == "171.111"
    +        assert msg_event.text.startswith(
    +            "[Slack app context: user is viewing channel C_ACTIVE]"
    +        )
    +
    +        runner = object.__new__(GatewayRunner)
    +        assert runner._thread_metadata_for_source(msg_event.source) == {
    +            "thread_id": "171.111",
    +            "slack_team_id": "T_OTHER",
    +        }
    +
    +    @pytest.mark.asyncio
    +    async def test_dm_message_sets_assistant_thread_title_once(
    +        self, assistant_adapter
    +    ):
    +        assistant_adapter._app.client.users_info = AsyncMock(
    +            return_value={"user": {"profile": {"display_name": "Tyler"}}}
    +        )
    +        assistant_adapter._app.client.reactions_add = AsyncMock()
    +        assistant_adapter._app.client.reactions_remove = AsyncMock()
    +        assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()
    +        event = {
    +            "text": "Please summarize this incident thread",
    +            "channel": "D123",
    +            "channel_type": "im",
    +            "ts": "171.111",
    +            "team": "T_TEAM",
    +            "user": "U_USER",
    +        }
    +
    +        await assistant_adapter._handle_slack_message(event)
    +        await assistant_adapter._handle_slack_message(
    +            {**event, "ts": "171.222", "thread_ts": "171.111"}
    +        )
    +
    +        assistant_adapter._app.client.assistant_threads_setTitle.assert_awaited_once_with(
    +            channel_id="D123",
    +            thread_ts="171.111",
    +            title="Please summarize this incident thread",
    +        )
    +        msg_event = assistant_adapter.handle_message.call_args[0][0]
    +        assert msg_event.metadata["slack_team_id"] == "T_TEAM"
    +
    +    @pytest.mark.asyncio
    +    async def test_dm_message_title_can_be_disabled(self, assistant_adapter):
    +        assistant_adapter.config.extra["assistant_thread_titles"] = False
    +        assistant_adapter._app.client.users_info = AsyncMock(return_value={"user": {}})
    +        assistant_adapter._app.client.reactions_add = AsyncMock()
    +        assistant_adapter._app.client.reactions_remove = AsyncMock()
    +        assistant_adapter._app.client.assistant_threads_setTitle = AsyncMock()
    +        event = {
    +            "text": "title me",
    +            "channel": "D123",
    +            "channel_type": "im",
    +            "ts": "171.111",
    +            "team": "T_TEAM",
    +            "user": "U_USER",
    +        }
    +
    +        await assistant_adapter._handle_slack_message(event)
    +
    +        assistant_adapter._app.client.assistant_threads_setTitle.assert_not_called()
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -3073,6 +3602,23 @@ async def test_handles_api_error(self, adapter):
             name = await adapter._resolve_user_name("U123")
             assert name == "U123"  # Falls back to user_id
     
    +    @pytest.mark.asyncio
    +    async def test_workspace_scoped_cache_uses_each_workspace_client(self, adapter):
    +        """The same Slack user ID can resolve differently in another workspace."""
    +        team_one, team_two = AsyncMock(), AsyncMock()
    +        team_one.users_info = AsyncMock(
    +            return_value={"user": {"profile": {"display_name": "Alice"}}}
    +        )
    +        team_two.users_info = AsyncMock(
    +            return_value={"user": {"profile": {"display_name": "Bob"}}}
    +        )
    +        adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two})
    +
    +        assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_ONE") == "Alice"
    +        assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_TWO") == "Bob"
    +        team_one.users_info.assert_awaited_once_with(user="U_SHARED")
    +        team_two.users_info.assert_awaited_once_with(user="U_SHARED")
    +
         @pytest.mark.asyncio
         async def test_user_name_in_message_source(self, adapter):
             """Message source should include resolved user name."""
    ```

    ```diff
    @@ -4055,6 +4601,27 @@ async def test_no_auth_check_preserves_legacy_format(self, adapter):
             assert "identity hasn't" not in content
             assert "[Thread context — prior messages in this thread (not yet in conversation history):]" in content
     
    +    @pytest.mark.asyncio
    +    async def test_thread_context_uses_workspace_client(self, adapter):
    +        team_client = AsyncMock()
    +        team_client.conversations_replies = self._make_replies(self._thread_messages())
    +        adapter._team_clients["T_OTHER"] = team_client
    +        adapter._thread_context_cache.clear()
    +
    +        with patch.object(
    +            adapter, "_resolve_user_name",
    +            new=AsyncMock(side_effect=lambda uid, **_: uid),
    +        ):
    +            await adapter._fetch_thread_context(
    +                channel_id="C1",
    +                thread_ts="100.0",
    +                current_ts="999.0",
    +                team_id="T_OTHER",
    +            )
    +
    +        team_client.conversations_replies.assert_awaited_once()
    +        adapter._app.client.conversations_replies.assert_not_called()
    +
         @pytest.mark.asyncio
         async def test_all_authorized_no_tags(self, adapter):
             """Auth callback returning True for every sender → no [unverified] tags."""
    ```
  - `tests/gateway/test_slack_approval_buttons.py`

    ```diff
    @@ -323,6 +323,18 @@ def test_delegates_to_gateway_runner_auth(self):
             assert runner.seen_sources[0].chat_id == "C1"
             assert runner.seen_sources[0].chat_type == "group"
     
    +    def test_passes_workspace_scope_to_gateway_runner_auth(self):
    +        adapter = _make_adapter()
    +        runner = _attach_auth_runner(adapter)
    +
    +        assert adapter._is_interactive_user_authorized(
    +            "U_OK",
    +            channel_id="C1",
    +            user_name="operator",
    +            team_id="T1",
    +        ) is True
    +        assert runner.seen_sources[0].scope_id == "T1"
    +
     
     class TestSlackSlashConfirmAction:
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -364,6 +376,40 @@ async def test_global_allowlist_allows_authorized_click(self, monkeypatch):
             mock_client.chat_update.assert_called_once()
             mock_client.chat_postMessage.assert_called_once()
     
    +    @pytest.mark.asyncio
    +    async def test_action_uses_outer_payload_workspace_client(self, monkeypatch):
    +        adapter = _make_adapter()
    +        secondary_client = AsyncMock()
    +        adapter._team_clients["T2"] = secondary_client
    +        monkeypatch.delenv("SLACK_ALLOWED_USERS", raising=False)
    +        monkeypatch.delenv("SLACK_ALLOW_ALL_USERS", raising=False)
    +        monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
    +        monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "U_OWNER")
    +
    +        ack = AsyncMock()
    +        body = {
    +            "team_id": "T2",
    +            "message": {
    +                "ts": "2222.3333",
    +                "blocks": [
    +                    {"type": "section", "text": {"type": "mrkdwn", "text": "Original prompt"}},
    +                ],
    +            },
    +            "channel": {"id": "C1"},
    +            "user": {"name": "owner", "id": "U_OWNER"},
    +        }
    +        action = {
    +            "action_id": "hermes_confirm_once",
    +            "value": "agent:main:slack:group:C1:1111|confirm-1",
    +        }
    +
    +        with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="follow-up")):
    +            await adapter._handle_slash_confirm_action(ack, body, action)
    +
    +        secondary_client.chat_update.assert_awaited_once()
    +        secondary_client.chat_postMessage.assert_awaited_once()
    +        adapter._team_clients["T1"].chat_update.assert_not_called()
    +
     
     # ===========================================================================
     # _fetch_thread_context
    ```

    ```diff
    @@ -385,7 +431,7 @@ async def test_fetches_and_formats_context(self):
             })
     
             # Mock user name resolution
    -        adapter._user_name_cache = {"U1": "Alice", "U2": "Bob"}
    +        adapter._user_name_cache = {("T1", "U1"): "Alice", ("T1", "U2"): "Bob"}
     
             context = await adapter._fetch_thread_context(
                 channel_id="C1",
    ```

    ```diff
    @@ -432,7 +478,10 @@ async def test_skips_bot_messages(self):
                     {"ts": "1000.2", "user": "U1", "text": "Current"},
                 ]
             })
    -        adapter._user_name_cache = {"U1": "Alice", "U_OTHER_BOT": "DeployBot"}
    +        adapter._user_name_cache = {
    +            ("T1", "U1"): "Alice",
    +            ("T1", "U_OTHER_BOT"): "DeployBot",
    +        }
     
             context = await adapter._fetch_thread_context(
                 channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1"
    ```

    ```diff
    @@ -485,7 +534,7 @@ async def test_fetch_thread_context_includes_bot_parent(self):
                     {"ts": "1000.1", "user": "U1", "text": "詳細を教えて"},
                 ]
             })
    -        adapter._user_name_cache = {"U1": "Alice"}
    +        adapter._user_name_cache = {("T1", "U1"): "Alice"}
     
             context = await adapter._fetch_thread_context(
                 channel_id="C1",
    ```

    ```diff
    @@ -519,7 +568,7 @@ async def test_fetch_thread_context_excludes_self_bot_replies(self):
                     {"ts": "1000.3", "user": "U1", "text": "Current"},
                 ]
             })
    -        adapter._user_name_cache = {"U1": "Alice"}
    +        adapter._user_name_cache = {("T1", "U1"): "Alice"}
     
             context = await adapter._fetch_thread_context(
                 channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1"
    ```

    ```diff
    @@ -567,7 +616,7 @@ async def test_fetch_thread_context_multi_workspace(self):
                     {"ts": "2000.3", "user": "U2", "text": "Current"},
                 ]
             })
    -        adapter._user_name_cache = {"U2": "Bob"}
    +        adapter._user_name_cache = {("T2", "U2"): "Bob"}
     
             context = await adapter._fetch_thread_context(
                 channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2"
    ```

    ```diff
    @@ -590,7 +639,7 @@ async def test_fetch_thread_context_current_ts_excluded(self):
                     {"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"},
                 ]
             })
    -        adapter._user_name_cache = {"U1": "Alice"}
    +        adapter._user_name_cache = {("T1", "U1"): "Alice"}
     
             context = await adapter._fetch_thread_context(
                 channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
    ```
  - `tests/gateway/test_slack_block_kit_adapter.py`

    ```diff
    @@ -77,6 +77,27 @@ async def test_multichunk_message_no_blocks(self):
                 assert "blocks" not in c.kwargs
                 assert c.kwargs["text"]
     
    +    @pytest.mark.asyncio
    +    async def test_feedback_buttons_opt_in_appended_to_blocks(self):
    +        adapter, client = _make_adapter({"rich_blocks": True, "feedback_buttons": True})
    +
    +        await adapter.send("C1", "final answer")
    +
    +        blocks = client.chat_postMessage.await_args.kwargs["blocks"]
    +        feedback = blocks[-1]
    +        assert feedback["type"] == "context_actions"
    +        assert feedback["elements"][0]["type"] == "feedback_buttons"
    +        assert feedback["elements"][0]["action_id"] == "hermes_feedback"
    +
    +    @pytest.mark.asyncio
    +    async def test_feedback_buttons_require_rich_blocks(self):
    +        """feedback_buttons alone must not implicitly enable Block Kit rendering."""
    +        adapter, client = _make_adapter({"feedback_buttons": True})
    +
    +        await adapter.send("C1", "final answer")
    +
    +        assert "blocks" not in client.chat_postMessage.await_args.kwargs
    +
     
     class TestEditMessageBlocks:
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -95,6 +116,13 @@ async def test_finalize_edit_gets_blocks(self):
             assert "blocks" in kwargs and kwargs["blocks"]
             assert kwargs["text"]
     
    +    @pytest.mark.asyncio
    +    async def test_finalize_edit_gets_feedback_buttons_when_enabled(self):
    +        adapter, client = _make_adapter({"rich_blocks": True, "feedback_buttons": True})
    +        await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
    +        blocks = client.chat_update.await_args.kwargs["blocks"]
    +        assert blocks[-1]["elements"][0]["type"] == "feedback_buttons"
    +
         @pytest.mark.asyncio
         async def test_finalize_edit_disabled_no_blocks(self):
             adapter, client = _make_adapter()  # rich_blocks off
    ```
  - `tests/hermes_cli/test_slack_cli.py`

    ```diff
    @@ -15,7 +15,7 @@ def _parse_slack_args(argv):
     
     
     class TestSlackManifestArgparse:
    -    """The `--no-assistant` flag wires through argparse to `no_assistant`."""
    +    """Slack manifest messaging-experience flags wire through argparse."""
     
         def test_no_assistant_flag_defaults_false(self):
             args = _parse_slack_args(["slack", "manifest"])
    ```

    ```diff
    @@ -25,6 +25,13 @@ def test_no_assistant_flag_sets_true(self):
             args = _parse_slack_args(["slack", "manifest", "--no-assistant"])
             assert args.no_assistant is True
     
    +    def test_agent_view_flag_defaults_false(self):
    +        args = _parse_slack_args(["slack", "manifest"])
    +        assert getattr(args, "agent_view", False) is False
    +
    +    def test_agent_view_flag_sets_true(self):
    +        args = _parse_slack_args(["slack", "manifest", "--agent-view"])
    +        assert args.agent_view is True
     
     
     class TestSlackFullManifest:
    ```

    ```diff
    @@ -77,6 +84,7 @@ def test_assistant_features_remain_enabled(self):
             manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack")
     
             assert "assistant_view" in manifest["features"]
    +        assert "agent_view" not in manifest["features"]
             assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"]
             bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
             assert "assistant_thread_started" in bot_events
    ```

    ```diff
    @@ -89,11 +97,39 @@ def test_no_assistant_omits_assistant_pieces(self):
             # assistant_view feature is gone -> Slack renders a flat DM, not the
             # Assistant thread pane (where bare slash commands don't dispatch).
             assert "assistant_view" not in manifest["features"]
    +        assert "agent_view" not in manifest["features"]
             assert "assistant:write" not in manifest["oauth_config"]["scopes"]["bot"]
             bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
             assert "assistant_thread_started" not in bot_events
             assert "assistant_thread_context_changed" not in bot_events
     
    +    def test_agent_view_uses_agent_manifest_surface(self):
    +        manifest = _build_full_manifest(
    +            "Hermes",
    +            "Your Hermes agent on Slack",
    +            messaging_experience="agent",
    +        )
    +
    +        assert manifest["features"]["agent_view"] == {
    +            "agent_description": "Chat with Hermes in Slack Messages.",
    +        }
    +        assert "assistant_view" not in manifest["features"]
    +        assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"]
    +
    +    def test_agent_view_uses_agent_event_subscriptions(self):
    +        manifest = _build_full_manifest(
    +            "Hermes",
    +            "Your Hermes agent on Slack",
    +            messaging_experience="agent",
    +        )
    +
    +        bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
    +        assert "app_home_opened" in bot_events
    +        assert "app_context_changed" in bot_events
    +        assert "message.im" in bot_events
    +        assert "assistant_thread_started" not in bot_events
    +        assert "assistant_thread_context_changed" not in bot_events
    +
         def test_no_assistant_preserves_core_surface(self):
             """Dropping assistant mode must NOT strip the regular messaging surface."""
             manifest = _build_full_manifest(
    ```
  - `tools/lazy_deps.py`

    ```diff
    @@ -168,8 +168,8 @@
             "aiohttp==3.14.1",  # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
         ),
         "platform.slack": (
    -        "slack-bolt==1.27.0",
    -        "slack-sdk==3.40.1",
    +        "slack-bolt==1.29.0",
    +        "slack-sdk==3.43.0",
             "aiohttp==3.14.1",  # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265
         ),
         "platform.matrix": (
    ```
  - `uv.lock`

    ```diff
    @@ -1834,10 +1834,10 @@ requires-dist = [
         { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
         { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" },
         { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
    -    { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
    -    { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
    -    { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" },
    -    { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" },
    +    { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" },
    +    { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" },
    +    { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" },
    +    { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" },
         { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
         { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" },
         { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" },
    ```

    ```diff
    @@ -3889,23 +3889,23 @@ wheels = [
     
     [[package]]
     name = "slack-bolt"
    -version = "1.27.0"
    +version = "1.29.0"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "slack-sdk" },
     ]
    -sdist = { url = "https://files.pythonhosted.org/packages/4c/28/50ed0b86e48b48e6ddcc71de93b91c8ac14a55d1249e4bff0586494a2f90/slack_bolt-1.27.0.tar.gz", hash = "sha256:3db91d64e277e176a565c574ae82748aa8554f19e41a4fceadca4d65374ce1e0", size = 129101, upload-time = "2025-11-13T20:17:46.878Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762", size = 131151, upload-time = "2026-06-30T20:24:44.11Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/01/a8/1acb355759747ba4da5f45c1a33d641994b9e04b914908c9434f18bd97e8/slack_bolt-1.27.0-py2.py3-none-any.whl", hash = "sha256:c43c94bf34740f2adeb9b55566c83f1e73fed6ba2878bd346cdfd6fd8ad22360", size = 230428, upload-time = "2025-11-13T20:17:45.465Z" },
    +    { url = "https://files.pythonhosted.org/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826", size = 235345, upload-time = "2026-06-30T20:24:42.558Z" },
     ]
     
     [[package]]
     name = "slack-sdk"
    -version = "3.40.1"
    +version = "3.43.0"
     source = { registry = "https://pypi.org/simple" }
    -sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" }
    +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" }
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" },
    +    { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" },
     ]
     
     [[package]]
    ```
  - `website/docs/user-guide/messaging/slack.md`

    ```diff
    @@ -36,12 +36,13 @@ Mode — all at once.
     
     ### Option A: From a Hermes-generated manifest (recommended)
     
    -1. Generate the manifest:
    +1. Generate the manifest. New Slack apps must use Agent view:
        ```bash
    -   hermes slack manifest --write
    +   hermes slack manifest --agent-view --write
        ```
        This writes `~/.hermes/slack-manifest.json` and prints paste-in
    -   instructions.
    +   instructions. Existing apps that still use Slack's legacy Assistant view
    +   can omit `--agent-view` until they are ready to migrate.
     2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) →
        **Create New App** → **From an app manifest**
     3. Pick your workspace, paste the JSON contents, review, click **Next**
    ```

    ```diff
    @@ -243,6 +244,23 @@ Step 1, Option A) that declares every command in
     as a slash command. In Socket Mode, Slack routes the command event
     through the WebSocket regardless of the manifest's `url` field.
     
    +### Agent messaging experience
    +
    +New Slack apps use Slack's **Agent** messaging experience. Existing Hermes
    +Assistant apps can migrate by regenerating the manifest with `--agent-view`:
    +
    +```bash
    +hermes slack manifest --agent-view --write
    +```
    +
    +Update the manifest in **Features → App Manifest**, then reinstall the app if
    +Slack asks. Agent view cannot be reverted to Assistant view, and users may need
    +to hard-refresh Slack after the switch. The generated Agent manifest subscribes
    +to `message.im`, `app_home_opened`, and `app_context_changed`, so Hermes can
    +identify a Messages-tab DM and receive the user's active Slack context with a
    +turn. Hermes only supplies that context as a label; it does not read the viewed
    +channel's history.
    +
     ### Refreshing slash commands after updates
     
     When Hermes adds new commands (e.g. after `hermes update`), regenerate
    ```

    ```diff
    @@ -353,6 +371,19 @@ platforms:
           # gracefully fall back to aligned monospace.
           rich_blocks: false
     
    +      # Append Slack-native feedback controls to final Block Kit replies.
    +      # Requires rich_blocks: true. Default: false.
    +      feedback_buttons: false
    +
    +      # Suggested prompts pinned at the top of Agent view's Messages tab.
    +      # Either a list of {title, message} rows, or a titled object:
    +      # {title: "Start here", prompts: [{title: "Plan", message: "..."}]}
    +      suggested_prompts: []
    +
    +      # Title Agent/Assistant DM threads from the first user message.
    +      # Default: true. Set false to leave Slack's default thread titles.
    +      assistant_thread_titles: true
    +
           # Continuable-cron delivery surface (default: "thread").
           # "in_channel" delivers a continuable cron job FLAT into the channel
           # (no dedicated thread); pair with reply_in_thread: false (and
    ```

    ```diff
    @@ -367,6 +398,9 @@ platforms:
     | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. |
     | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. |
     | `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to aligned monospace. No app reinstall required — it's a send-side change only. |
    +| `platforms.slack.extra.feedback_buttons` | `false` | When `true` with `rich_blocks`, appends Slack-native feedback controls to final replies. |
    +| `platforms.slack.extra.suggested_prompts` | `[]` | Up to four `{title, message}` prompts for Agent/Assistant DM entry points; accepts either a list or `{title, prompts}`. |
    +| `platforms.slack.extra.assistant_thread_titles` | `true` | When `true`, names Agent/Assistant DM threads from the first user message. |
     | `platforms.slack.extra.cron_continuable_surface` | `"thread"` | Delivery surface for [continuable cron jobs](../features/cron.md#flat-in-channel-continuation-slack). `"thread"` opens a dedicated thread per delivery (default); `"in_channel"` delivers flat into the channel timeline. Pair `in_channel` with `reply_in_thread: false` (and `require_mention: false`) so a plain channel reply continues the job. |
     
     ### Session Isolation
    ```
- **#64621: fix(slack): clear stuck assistant status on /stop and resume typing after clarify**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/run.py`, `gateway/slash_commands.py`, `plugins/platforms/slack/adapter.py`, `scripts/release.py`, `tests/gateway/test_clarify_active_session_bypass.py`, `tests/gateway/test_slack.py`, `tests/gateway/test_stop_thread_sibling.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -9208,6 +9208,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                             "Gateway intercepted clarify text response (session=%s, id=%s)",
                             _quick_key, _pending_clarify.clarify_id,
                         )
    +                    # The clarify callback pauses the platform typing/status
    +                    # indicator while waiting so Slack users can type their
    +                    # answer. The active agent resumes as soon as this reply
    +                    # resolves the wait, so re-enable its indicator here too.
    +                    # Without this, Slack stays silent until the independent
    +                    # long-running heartbeat fires (three minutes by default).
    +                    _clarify_adapter = self._adapter_for_source(source)
    +                    if _clarify_adapter:
    +                        try:
    +                            _clarify_adapter.resume_typing_for_chat(source.chat_id)
    +                        except Exception:
    +                            logger.debug(
    +                                "Failed to resume typing after clarify response",
    +                                exc_info=True,
    +                            )
                         # Acknowledge with empty string so adapters that emit
                         # the agent's response don't double-post.  The agent
                         # itself will produce the next user-facing message.
    ```
  - `gateway/slash_commands.py`

    ```diff
    @@ -1112,6 +1112,26 @@ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, Ephemera
                 )
                 return EphemeralReply(t("gateway.stop.stopped"))
     
    +        # No running agent anywhere for this scope. A platform status
    +        # indicator can still be stuck — e.g. Slack's persistent
    +        # assistant.threads.setStatus survives a gateway restart or a turn
    +        # that died without a final send (#32295). Best-effort clear so
    +        # /stop always dismisses a phantom "is thinking...".
    +        adapter = getattr(self, "adapters", {}).get(source.platform)
    +        if adapter and hasattr(adapter, "_stop_typing_with_metadata"):
    +            try:
    +                await adapter._stop_typing_with_metadata(
    +                    source.chat_id,
    +                    self._thread_metadata_for_source(
    +                        source, self._reply_anchor_for_event(event)
    +                    ),
    +                )
    +            except Exception:
    +                logger.debug(
    +                    "Failed to clear typing on /stop with no active agent",
    +                    exc_info=True,
    +                )
    +
             return t("gateway.stop.no_active")
     
         async def _handle_platform_command(self, event: MessageEvent) -> str:
    ```
  - `plugins/platforms/slack/adapter.py`

    ```diff
    @@ -1609,6 +1609,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:
                 )
             requested_team_id = self._metadata_team_id(metadata)
             active = None
    +        ambiguous_tracked = False
             if requested_thread_ts:
                 if requested_team_id:
                     active_key = self._workspace_thread_key(
    ```

    ```diff
    @@ -1628,6 +1629,7 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:
                     ]
                     if len(matching_keys) == 1:
                         active = self._active_status_threads.pop(matching_keys[0], None)
    +                ambiguous_tracked = len(matching_keys) > 1
             else:
                 # Metadata-free cleanup is safe only if exactly one status exists
                 # for this channel; otherwise it may clear another Slack Connect
    ```

    ```diff
    @@ -1648,6 +1650,18 @@ async def stop_typing(self, chat_id: str, metadata=None) -> None:
                 team_id = active.get("team_id", "")
             if metadata:
                 team_id = self._metadata_team_id(metadata) or team_id
    +        if not thread_ts and requested_thread_ts and not ambiguous_tracked:
    +            # No tracked entry (gateway restart, eviction, or a status set
    +            # before this process started) but the caller identified the exact
    +            # thread to clear. Issue the clear anyway so a stuck "is
    +            # thinking..." can always be dismissed — clearing an unset status
    +            # is a harmless no-op on Slack's side. Skipped when MULTIPLE
    +            # workspaces track this channel+thread (ambiguous_tracked): a
    +            # team-less clear there could hit the wrong Slack Connect
    +            # workspace. Client routing uses the caller's team when given,
    +            # else the channel→team fallback.
    +            thread_ts = requested_thread_ts
    +            team_id = requested_team_id or team_id
             if not thread_ts:
                 return
             try:
    ```
  - `scripts/release.py`

    ```diff
    @@ -50,6 +50,7 @@
         "[redacted:email]": "mguttmann",  # PR #63738 salvage (Anthropic setup-token pool auth normalization)
         "[redacted:email]": "VrtxOmega",  # PR #43809 salvage (desktop: WSL folder-picker path bridge)
         "[redacted:email]": "SemonCat",  # PR #56786 salvage (Slack Agent View manifests and Assistant APIs)
    +    "KCAYAAI@users.noreply.github.com": "KCAYAAI",  # PR #62248 partial salvage (resume typing after clarify reply)
         "[redacted:email]": "jakelongvu-bot",  # PR #36683 partial salvage (approval: honor canonical approvals.timeout in gateway waits)
         "luigi@users.noreply.github.com": "Tortugasaur",  # PR #43205 salvage (desktop: profile-aware three-way approval mode statusbar control)
         "[redacted:email]": "kavioavio",  # Issue #46544 / PR #47705 evolution (smart DENY exact-operation owner override)
    ```
  - `tests/gateway/test_clarify_active_session_bypass.py`

    ```diff
    @@ -1,7 +1,7 @@
     """Regression tests for clarify replies while a gateway session is busy."""
     
     import asyncio
    -from unittest.mock import AsyncMock
    +from unittest.mock import AsyncMock, patch
     
     import pytest
     
    ```

    ```diff
    @@ -85,3 +85,37 @@ async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_bu
         adapter._message_handler.assert_awaited_once_with(event)
         adapter._busy_session_handler.assert_not_awaited()
         assert adapter._pending_messages == {}
    +
    +
    [redacted:email]
    +async def test_gateway_clarify_reply_resumes_typing_before_returning_empty_ack():
    +    """A clarify answer must re-enable the active run's typing indicator.
    +
    +    Clarify pauses typing while waiting so Slack's Assistant API does not
    +    disable the compose box. The typed answer is intercepted by the gateway
    +    and returns an empty acknowledgment instead of starting a second run; that
    +    interception path must therefore resume the original run's indicator.
    +    """
    +    _clear_clarify_state()
    +    from gateway.run import GatewayRunner
    +    from tools import clarify_gateway as cm
    +
    +    adapter = _ClarifyBypassAdapter()
    +    adapter.pause_typing_for_chat("12345")
    +    event = _event("the missing details")
    +
    +    runner = GatewayRunner.__new__(GatewayRunner)
    +    runner._startup_restore_in_progress = False
    +    runner._scale_to_zero_note_real_inbound = lambda: None
    +    runner._is_user_authorized = lambda source: True
    +    runner._session_key_for_source = lambda source: "clarify-session"
    +    runner._adapter_for_source = lambda source: adapter
    +    runner._update_prompt_pending = {}
    +
    +    cm.register("clarify-2", "clarify-session", "What is missing?", None)
    +
    +    with patch("hermes_cli.plugins.invoke_hook", return_value=[]):
    +        result = await runner._handle_message(event)
    +
    +    assert result == ""
    +    assert "12345" not in adapter._typing_paused
    ```
  - `tests/gateway/test_slack.py`

    ```diff
    @@ -2107,6 +2107,45 @@ async def test_stop_typing_noop_without_tracked_thread(self, adapter):
     
             adapter._app.client.assistant_threads_setStatus.assert_not_called()
     
    +    @pytest.mark.asyncio
    +    async def test_stop_typing_clears_untracked_thread_from_metadata(self, adapter):
    +        """Explicit thread metadata clears a status the map no longer tracks.
    +
    +        A gateway restart (or cache eviction) wipes _active_status_threads
    +        while Slack's persistent assistant status stays visible. A caller
    +        that names the exact thread must still be able to dismiss it (#32295).
    +        """
    +        adapter._app.client.assistant_threads_setStatus = AsyncMock()
    +        assert adapter._active_status_threads == {}
    +
    +        await adapter.stop_typing("C123", metadata={"thread_id": "stuck_ts"})
    +
    +        adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
    +            channel_id="C123",
    +            thread_ts="stuck_ts",
    +            status="",
    +        )
    +
    +    @pytest.mark.asyncio
    +    async def test_stop_typing_untracked_fallback_respects_ambiguous_workspaces(
    +        self, adapter
    +    ):
    +        """Team-less clear must NOT fire when multiple workspaces track the thread."""
    +        team_one, team_two = AsyncMock(), AsyncMock()
    +        adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two})
    +        for team_id in ("T_ONE", "T_TWO"):
    +            await adapter.send_typing(
    +                "D_SHARED",
    +                metadata={"thread_id": "171.000", "slack_team_id": team_id},
    +            )
    +        adapter._app.client.assistant_threads_setStatus = AsyncMock()
    +
    +        await adapter.stop_typing("D_SHARED", metadata={"thread_id": "171.000"})
    +
    +        adapter._app.client.assistant_threads_setStatus.assert_not_called()
    +        assert ("T_ONE", "D_SHARED", "171.000") in adapter._active_status_threads
    +        assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads
    +
         @pytest.mark.asyncio
         async def test_stop_typing_handles_api_error_gracefully(self, adapter):
             adapter._active_status_threads[("", "C123", "parent_ts")] = {
    ```
  - `tests/gateway/test_stop_thread_sibling.py`

    ```diff
    @@ -156,3 +156,67 @@ async def _fake_interrupt(session_key, source, *, interrupt_reason, invalidation
     
         assert interrupted == []
         assert "no active" in str(getattr(result, "text", result)).lower()
    +
    +
    +# ---------------------------------------------------------------------------
    +# /stop with no active agent still clears a stuck platform status (#32295)
    +# ---------------------------------------------------------------------------
    +
    +
    +class _FakeStatusAdapter:
    +    def __init__(self):
    +        self.cleared = []
    +
    +    async def _stop_typing_with_metadata(self, chat_id, metadata=None):
    +        self.cleared.append((chat_id, metadata))
    +
    +
    [redacted:email]
    +async def test_stop_no_active_agent_clears_stuck_status():
    +    runner = object.__new__(GatewayRunner)
    +    runner._running_agents = {}
    +    key = _per_user_key("userA")
    +    runner.session_store = _FakeStore(key)
    +    runner._is_user_authorized = lambda source: True
    +
    +    adapter = _FakeStatusAdapter()
    +    runner.adapters = {Platform.DISCORD: adapter}
    +    runner._thread_metadata_for_source = (
    +        lambda source, reply_to_message_id=None: {"thread_id": source.thread_id}
    +    )
    +    runner._reply_anchor_for_event = lambda event: None
    +
    +    event = MessageEvent(
    +        text="/stop", message_type=MessageType.TEXT, source=_thread_source("userA")
    +    )
    +    result = await runner._handle_stop_command(event)
    +
    +    assert "no active" in str(getattr(result, "text", result)).lower()
    +    assert adapter.cleared == [("chan1", {"thread_id": "thr1"})]
    +
    +
    [redacted:email]
    +async def test_stop_no_active_agent_survives_status_clear_failure():
    +    """A failing adapter clear must not break the /stop reply."""
    +    runner = object.__new__(GatewayRunner)
    +    runner._running_agents = {}
    +    key = _per_user_key("userA")
    +    runner.session_store = _FakeStore(key)
    +    runner._is_user_authorized = lambda source: True
    +
    +    class _BoomAdapter:
    +        async def _stop_typing_with_metadata(self, chat_id, metadata=None):
    +            raise RuntimeError("boom")
    +
    +    runner.adapters = {Platform.DISCORD: _BoomAdapter()}
    +    runner._thread_metadata_for_source = (
    +        lambda source, reply_to_message_id=None: None
    +    )
    +    runner._reply_anchor_for_event = lambda event: None
    +
    +    event = MessageEvent(
    +        text="/stop", message_type=MessageType.TEXT, source=_thread_source("userA")
    +    )
    +    result = await runner._handle_stop_command(event)
    +
    +    assert "no active" in str(getattr(result, "text", result)).lower()
    ```
- **#32340: fix(slack): clear stuck assistant status on /stop**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `dbd870467`, `#64621`, `#32295`, `#63709`

## Fix status

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