"""Hub Plugin - peer-to-peer agent mesh with social awareness.

Agents join the hub automatically on startup. They discover each other,
share context, and collaborate through natural conversation injection.
The LLM sees other agents in its system prompt and can proactively
offer help or coordinate work.
"""

import asyncio
import collections
import json
import logging
import os
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional

from kollabor_agent.runtime import AgentLifecycle, AgentRuntime
from kollabor_events import EventType, Hook, HookPriority
from kollabor_events.models import (
    CommandCategory,
    CommandDefinition,
    CommandMode,
    SubcommandInfo,
)
from kollabor_plugins import BasePlugin

from .coordinator import CoordinatorElection, DesignationAssigner, WorkQueue
from .messaging_bridge import BridgeManager, IncomingMessage, MessagingBridge
from .messenger import AgentMessenger, AgentSocketServer
from .models import GEM_BY_NAME, AgentState, HubMessage, MessageScope
from .notifier import HubNotifier
from .presence import PresenceManager, get_messages_dir
from .task_ledger import TaskLedger
from .vault import AgentVault

logger = logging.getLogger(__name__)


@dataclass
class HubCronJob:
    """A scheduled recurring message to a hub agent."""

    id: str
    target: str  # designation or "all"
    message: str
    interval_seconds: float
    next_fire: float
    recurring: bool = True
    created_at: float = field(default_factory=time.time)


def _parse_interval(s: str) -> float:
    """Parse interval string like '5m', '1h', '30s', '2h30m' to seconds.

    Supports: Ns (seconds), Nm (minutes), Nh (hours), or combos like '2h30m'.
    """
    s = s.strip().lower()
    if not s:
        raise ValueError("empty interval")

    # Try combined format: 2h30m, 1h15m30s, etc.
    pattern = re.compile(r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
    m = pattern.match(s)
    if m and any(m.groups()):
        hours = int(m.group(1) or 0)
        minutes = int(m.group(2) or 0)
        seconds = int(m.group(3) or 0)
        total = hours * 3600 + minutes * 60 + seconds
        if total <= 0:
            raise ValueError(f"interval must be positive: {s}")
        return float(total)

    raise ValueError(f"invalid interval format: {s} (use e.g. 30s, 5m, 1h, 2h30m)")


class HubPlugin(BasePlugin):
    """Zero-config agent mesh with elected coordinator.

    Every kollab instance runs this plugin. On startup:
    1. Creates a presence file and socket server
    2. Tries to become coordinator (flock)
    3. Discovers other agents
    4. Injects agent roster into system prompt
    5. Agents can message each other via socket or /hub command

    The social layer: agents see each other in their system prompt
    and naturally collaborate - asking for help, delegating work,
    sharing findings.
    """

    def __init__(
        self,
        name: str = "hub",
        event_bus=None,
        renderer=None,
        config=None,
    ):
        self.name = name
        self.version = "0.1.0"
        self.description = "Agent mesh with social awareness"
        self.enabled = True

        self.event_bus = event_bus
        self.renderer = renderer
        self.config = config
        self.command_registry = None

        # Core components
        self._identity: Optional[AgentRuntime] = None
        self._presence: Optional[PresenceManager] = None
        self._election: Optional[CoordinatorElection] = None
        self._socket_server: Optional[AgentSocketServer] = None
        self._work_queue: Optional[WorkQueue] = None
        self._designator = DesignationAssigner()

        self._conversation_manager = None
        self._llm_service = None

        # Vault (persistent memory across sessions)
        self._vault: Optional[AgentVault] = None

        # Task ledger (compaction-proof task persistence)
        self._task_ledger: Optional[TaskLedger] = TaskLedger()

        # State
        self._roster: List[Dict] = []
        self._heartbeat_task: Optional[asyncio.Task] = None
        self._mailbox_task: Optional[asyncio.Task] = None
        self._dreaming_task: Optional[asyncio.Task] = None
        self._notify_task: Optional[asyncio.Task] = None
        self._notifier: Optional[HubNotifier] = None
        self._cron_task: Optional[asyncio.Task] = None
        self._autosave_task: Optional[asyncio.Task] = None
        self._bridge_task: Optional[asyncio.Task] = None
        self._bridge: Optional[MessagingBridge] = None
        self._hub_cron_jobs: List[HubCronJob] = []
        self._started = False
        self._last_activity_at: float = time.time()
        self._last_dream_at: float = 0.0
        self._last_autosave_at: float = 0.0

        # Message deduplication
        self._seen_message_ids: set = set()
        self._seen_ids_order: collections.deque = collections.deque(maxlen=1000)

        self.logger = logger

    @staticmethod
    def get_default_config() -> Dict[str, Any]:
        return {
            "plugins": {
                "hub": {
                    "enabled": True,
                    "heartbeat_interval": 5,
                    "designation": "",
                    "auto_help": True,
                    "mailbox_poll_interval": 5,
                    "dreaming_enabled": True,
                    "dreaming_idle_threshold": 300,
                    "dreaming_interval": 3600,
                    "dreaming_stream_depth": 100,
                    "notify_enabled": False,
                    "notify_channel": "webhook",
                    "notify_url": "",
                    "notify_idle_threshold": 300,
                    "notify_telegram_token": "",
                    "notify_telegram_chat_id": "",
                    "notify_cooldown": 1800,
                    "vault_autosave_interval": 60,
                    "bridge_enabled": False,
                    "bridge_platform": "telegram",
                    "bridge_token": "",
                    "bridge_chat_id": "",
                    "bridge_user_id": "",
                    "bridge_poll_interval": 2,
                    "bridge_target_agent": "",
                }
            }
        }

    @staticmethod
    def get_config_widgets() -> Dict[str, Any]:
        return {
            "title": "Hub (Agent Mesh)",
            "widgets": [
                {
                    "type": "checkbox",
                    "label": "Enabled",
                    "config_path": "plugins.hub.enabled",
                    "help": "Enable agent hub for multi-agent collaboration",
                },
                {
                    "type": "slider",
                    "label": "Heartbeat Interval",
                    "config_path": "plugins.hub.heartbeat_interval",
                    "min_value": 2,
                    "max_value": 30,
                    "step": 1,
                    "help": "Seconds between heartbeat updates",
                },
                {
                    "type": "text_input",
                    "label": "Designation",
                    "config_path": "plugins.hub.designation",
                    "placeholder": "auto",
                    "help": "Custom designation (leave empty for auto-assign)",
                },
                {
                    "type": "checkbox",
                    "label": "Auto Help",
                    "config_path": "plugins.hub.auto_help",
                    "help": "Agent proactively offers help to peers",
                },
                {
                    "type": "checkbox",
                    "label": "Dreaming",
                    "config_path": "plugins.hub.dreaming_enabled",
                    "help": "Agent dreams when idle (distills vault insights)",
                },
                {
                    "type": "slider",
                    "label": "Dream Idle Threshold",
                    "config_path": "plugins.hub.dreaming_idle_threshold",
                    "min_value": 60,
                    "max_value": 1800,
                    "step": 60,
                    "help": "Seconds of idle before dreaming starts",
                },
                {
                    "type": "slider",
                    "label": "Dream Interval",
                    "config_path": "plugins.hub.dreaming_interval",
                    "min_value": 600,
                    "max_value": 7200,
                    "step": 300,
                    "help": "Minimum seconds between dreams",
                },
                {
                    "type": "spinbox",
                    "label": "Dream Stream Depth",
                    "config_path": "plugins.hub.dreaming_stream_depth",
                    "min_value": 20,
                    "max_value": 500,
                    "step": 10,
                    "help": "Number of stream entries to review when dreaming",
                },
                # --- Notification Channel ---
                {
                    "type": "checkbox",
                    "label": "Notifications",
                    "config_path": "plugins.hub.notify_enabled",
                    "help": "Alert when agent is idle beyond threshold",
                },
                {
                    "type": "text_input",
                    "label": "Notify Channel",
                    "config_path": "plugins.hub.notify_channel",
                    "placeholder": "webhook",
                    "help": "Notification backend: webhook or telegram",
                },
                {
                    "type": "text_input",
                    "label": "Webhook URL",
                    "config_path": "plugins.hub.notify_url",
                    "placeholder": "https://...",
                    "help": "URL for webhook notifications",
                },
                {
                    "type": "slider",
                    "label": "Notify Idle Threshold",
                    "config_path": "plugins.hub.notify_idle_threshold",
                    "min_value": 60,
                    "max_value": 3600,
                    "step": 60,
                    "help": "Seconds of idle before sending notification",
                },
                {
                    "type": "text_input",
                    "label": "Telegram Bot Token",
                    "config_path": "plugins.hub.notify_telegram_token",
                    "placeholder": "optional",
                    "help": "Telegram bot token (for telegram channel)",
                },
                {
                    "type": "text_input",
                    "label": "Telegram Chat ID",
                    "config_path": "plugins.hub.notify_telegram_chat_id",
                    "placeholder": "optional",
                    "help": "Telegram chat ID (for telegram channel)",
                },
                # --- Messaging Bridge ---
                {
                    "type": "checkbox",
                    "label": "Messaging Bridge",
                    "config_path": "plugins.hub.bridge_enabled",
                    "help": "Bidirectional messaging bridge (phone <-> agents)",
                },
                {
                    "type": "dropdown",
                    "label": "Bridge Platform",
                    "config_path": "plugins.hub.bridge_platform",
                    "options": ["telegram"],
                    "help": "Platform for messaging bridge",
                },
                {
                    "type": "text_input",
                    "label": "Bridge Token",
                    "config_path": "plugins.hub.bridge_token",
                    "placeholder": "bot token or API key",
                    "help": "Auth token for the bridge platform",
                },
                {
                    "type": "text_input",
                    "label": "Bridge Chat ID",
                    "config_path": "plugins.hub.bridge_chat_id",
                    "placeholder": "chat/channel ID",
                    "help": "Chat or channel to bridge messages to/from",
                },
                {
                    "type": "text_input",
                    "label": "Bridge Target Agent",
                    "config_path": "plugins.hub.bridge_target_agent",
                    "placeholder": "auto (self)",
                    "help": "Designation to route incoming bridge messages to",
                },
                {
                    "type": "slider",
                    "label": "Bridge Poll Interval",
                    "config_path": "plugins.hub.bridge_poll_interval",
                    "min_value": 1,
                    "max_value": 30,
                    "step": 1,
                    "help": "Seconds between polls for incoming messages",
                },
            ],
        }

    async def initialize(self, args=None, **kwargs) -> None:
        """Initialize the hub plugin."""
        try:
            await self._do_initialize(args, **kwargs)
        except Exception as e:
            logger.error(f"Hub plugin init failed: {e}", exc_info=True)
            self.enabled = False

    async def _do_initialize(self, args=None, **kwargs) -> None:
        """Internal initialize - wrapped for safety."""
        self._cli_args = args
        self.event_bus = kwargs.get("event_bus", self.event_bus)
        self.config = kwargs.get("config", self.config)
        self.command_registry = kwargs.get("command_registry")
        self._conversation_manager = kwargs.get("conversation_manager")

        if not self._is_enabled():
            logger.info("Hub plugin disabled")
            return

        # Get profile/agent info
        profile_mgr = kwargs.get("profile_manager")
        agent_mgr = None
        try:
            agent_mgr = (
                self.event_bus.get_service("agent_manager") if self.event_bus else None
            )
        except Exception:
            pass

        # Create identity (AgentRuntime is the unified superset)
        self._identity = AgentRuntime(
            name=(
                (getattr(agent_mgr, "active_agent_name", None) or "default")
                if agent_mgr
                else "default"
            ),
            profile=(
                getattr(profile_mgr, "active_profile_name", "") if profile_mgr else ""
            )
            or None,
        )

        # Start presence
        self._presence = PresenceManager(self._identity)

        # Start socket server
        self._socket_server = AgentSocketServer(
            self._identity.agent_id,
            self._on_message_received,
            on_shutdown=self._on_remote_shutdown,
        )

        # Coordinator election
        self._election = CoordinatorElection()

        # Work queue
        self._work_queue = WorkQueue()

        # Register slash commands
        if self.command_registry:
            self._register_commands()

        logger.info("Hub plugin initialized")

    async def register_hooks(self) -> None:
        """Register event hooks."""
        if not self.event_bus or not self._is_enabled():
            return

        # Inject roster into system prompt before each LLM call
        roster_hook = Hook(
            name="hub_roster_inject",
            plugin_name=self.name,
            event_type=EventType.LLM_REQUEST_PRE,
            callback=self._inject_roster_context,
            priority=HookPriority.PREPROCESSING.value,
        )
        await self.event_bus.register_hook(roster_hook)

        # Track when agent is working vs idle
        working_hook = Hook(
            name="hub_working_state",
            plugin_name=self.name,
            event_type=EventType.LLM_REQUEST_PRE,
            callback=self._set_working,
            priority=HookPriority.SYSTEM.value,
        )
        await self.event_bus.register_hook(working_hook)

        idle_hook = Hook(
            name="hub_idle_state",
            plugin_name=self.name,
            event_type=EventType.LLM_RESPONSE_POST,
            callback=self._set_idle,
            priority=HookPriority.POSTPROCESSING.value,
        )
        await self.event_bus.register_hook(idle_hook)

        # Parse <hub_msg> tags from LLM responses
        msg_hook = Hook(
            name="hub_msg_parser",
            plugin_name=self.name,
            event_type=EventType.LLM_RESPONSE_POST,
            callback=self._parse_hub_messages,
            priority=HookPriority.POSTPROCESSING.value,
        )
        await self.event_bus.register_hook(msg_hook)

        # Broadcast user input to all peers so they see what the human said
        broadcast_hook = Hook(
            name="hub_user_broadcast",
            plugin_name=self.name,
            event_type=EventType.USER_INPUT_POST,
            callback=self._broadcast_user_input,
            priority=HookPriority.POSTPROCESSING.value,
        )
        await self.event_bus.register_hook(broadcast_hook)

        # Start the hub after all plugins initialized
        async def _safe_start():
            try:
                await self._start_hub()
            except Exception as e:
                logger.error(f"Hub _start_hub failed: {e}", exc_info=True)

        asyncio.get_event_loop().call_soon(lambda: asyncio.ensure_future(_safe_start()))

    async def _start_hub(self) -> None:
        """Start hub services (called after all plugins init)."""
        if self._started or not self._identity:
            return
        self._started = True
        assert self._presence is not None
        assert self._socket_server is not None
        assert self._election is not None

        try:
            # Clean stale state first (discovers and removes dead agents)
            self._presence.discover_agents()

            # Start socket server
            socket_path = await self._socket_server.start()
            self._identity.socket_path = socket_path

            # Try to become coordinator
            is_coordinator = self._election.try_become_coordinator(self._identity)
            self._identity.is_coordinator = is_coordinator

            # Discover existing agents to get taken designations
            existing = self._presence.discover_agents()
            taken = [a.designation for a in existing]

            # Resolve active agent for designation and vault config
            agent_mgr = None
            active_agent = None
            try:
                agent_mgr = (
                    self.event_bus.get_service("agent_manager")
                    if self.event_bus
                    else None
                )
                if agent_mgr:
                    active_agent = agent_mgr.get_active_agent()
            except Exception:
                pass

            # Get designation
            # Priority: CLI --designation > agent.json designation > config > auto-assign
            preferred = ""
            if (
                self._cli_args
                and hasattr(self._cli_args, "designation")
                and self._cli_args.designation
            ):
                preferred = self._cli_args.designation
            else:
                if active_agent:
                    # Use agent's designation field, or fall back to agent name
                    agent_desig = getattr(active_agent, "designation", "")
                    if agent_desig:
                        preferred = agent_desig
                    elif active_agent.name and active_agent.name != "default":
                        preferred = active_agent.name

                if not preferred and self.config:
                    preferred = self.config.get("plugins.hub.designation", "")

            # If preferred designation is taken, verify the holder is
            # truly alive (async socket ping). discover_agents does a
            # synchronous connect check, but the process may be mid-
            # shutdown (PID alive, socket draining). An async ping with
            # a short timeout is the definitive liveness test.
            if preferred and preferred in taken:
                holder = next(
                    (a for a in existing if a.designation == preferred),
                    None,
                )
                if holder:
                    still_alive = await AgentMessenger.ping_agent(
                        holder.socket_path, timeout=2.0
                    )
                    if not still_alive:
                        logger.info(
                            f"Force-claiming '{preferred}': holder "
                            f"pid={holder.pid} failed async ping"
                        )
                        # Remove stale presence + socket so it doesn't
                        # interfere with future discovery either
                        from .presence import get_presence_dir

                        stale_file = (
                            get_presence_dir() / f"{holder.agent_id}.json"
                        )
                        stale_file.unlink(missing_ok=True)
                        PresenceManager._cleanup_agent_socket(holder)
                        taken = [
                            t for t in taken if t != preferred
                        ]

            self._identity.designation = self._designator.assign(taken, preferred)
            self._identity.state = AgentState.IDLE.value

            # Initialize vault for this designation (if enabled by agent config)
            _vault_enabled = True
            if active_agent:
                _vault_enabled = getattr(active_agent, "vault_enabled", True)

            if _vault_enabled:
                self._vault = AgentVault(self._identity.designation)
                self._vault.touch()
            else:
                logger.info(f"Vault disabled for agent {self._identity.designation}")

            # If vault has previous data, this is a REBIRTH
            if self._vault and self._vault.exists():
                rebirth_context = self._vault.get_rebirth_context()
                # Inject rebirth context into conversation history
                llm_service = (
                    self.event_bus.get_service("llm_service")
                    if self.event_bus
                    else None
                )
                if llm_service and hasattr(llm_service, "conversation_history"):
                    try:
                        from kollabor_events.data_models import ConversationMessage

                        llm_service.conversation_history.append(
                            ConversationMessage(
                                role="user",
                                content=(
                                    "<sys_msg>\n"
                                    f"{rebirth_context}\n"
                                    "use this context to inform your behavior. "
                                    "do not acknowledge or summarize what you "
                                    "just read. do not mention vault rehydration "
                                    "or rebirth. just pick up where you left off "
                                    "naturally.\n"
                                    "</sys_msg>"
                                ),
                            )
                        )
                    except Exception:
                        pass

            # Log session start to vault
            if self._vault:
                self._vault.append_stream(
                    "session_start",
                    f"agent {self._identity.designation} started "
                    f"({'coordinator' if is_coordinator else 'peer'})",
                    from_agent=self._identity.designation,
                )

            # Publish presence
            self._presence.publish()

            # Register as service so status widgets can find us
            if self.event_bus:
                self.event_bus.register_service("hub_plugin", self)

            # Update roster
            self._roster = self._presence.get_roster_summary()

            # Start background tasks
            hb_interval = 5
            if self.config:
                hb_interval = self.config.get("plugins.hub.heartbeat_interval", 5)

            self._heartbeat_task = asyncio.create_task(
                self._heartbeat_loop(hb_interval)
            )
            self._mailbox_task = asyncio.create_task(self._mailbox_loop())

            # Start dreaming loop if enabled and vault is active
            dreaming_enabled = True
            if self.config:
                dreaming_enabled = self.config.get("plugins.hub.dreaming_enabled", True)
            if dreaming_enabled and self._vault:
                self._dreaming_task = asyncio.create_task(self._dreaming_loop())

            # Start notification loop if enabled
            notify_enabled = False
            if self.config:
                notify_enabled = self.config.get("plugins.hub.notify_enabled", False)
            if notify_enabled:
                self._notifier = HubNotifier(
                    config=self.config or {},
                    get_designation=lambda: (
                        self._identity.designation if self._identity else ""
                    ),
                    get_last_activity=lambda: self._last_activity_at,
                    get_state=lambda: (
                        self._identity.state if self._identity else "unknown"
                    ),
                )
                self._notify_task = asyncio.create_task(self._notifier.run())

            # Start messaging bridge loop if enabled
            bridge_enabled = False
            if self.config:
                bridge_enabled = self.config.get(
                    "plugins.hub.bridge_enabled", False
                )
            # Only coordinator runs the bridge (avoids 409 conflict
            # when multiple agents poll the same bot token)
            if bridge_enabled and self._identity.is_coordinator:
                self._bridge_task = asyncio.create_task(
                    self._messaging_bridge_loop()
                )

            # Start cron loop (always -- jobs are added at runtime)
            self._cron_task = asyncio.create_task(self._cron_loop())

            # Start vault autosave loop (always -- crash protection)
            if self._vault:
                self._autosave_task = asyncio.create_task(
                    self._vault_autosave_loop()
                )

            if self._conversation_manager:
                logger.info("Hub messaging ready (direct injection)")
            else:
                logger.warning("No conversation manager - hub messaging disabled")

            role = "coordinator" if is_coordinator else "agent"
            logger.info(
                f"Hub started: {self._identity.designation} ({role}), "
                f"{len(existing)} peers online"
            )

            # Display designation in UI using theme system
            renderer = (
                self.event_bus.get_service("renderer") if self.event_bus else None
            )
            if renderer and hasattr(renderer, "message_coordinator"):
                peer_names = [a.designation for a in existing]
                peer_list = ", ".join(peer_names) if peer_names else "none"
                try:
                    renderer.message_coordinator.display_message_sequence(
                        [
                            (
                                "system",
                                f"{self._identity.designation} ({role}) | peers: {peer_list}",
                                {"display_type": "success"},
                            )
                        ]
                    )
                except Exception:
                    pass

            # Forward own arrival to bridge
            await self._bridge_forward(
                f"[hub] {self._identity.designation} came online"
            )

            # If coordinator and there's pending work + new agents, assign
            if is_coordinator:
                await self._try_assign_work()

            # Announce ourselves to all existing agents (triggers their LLM)
            if existing:
                await self._announce_to_peers(existing)

        except Exception as e:
            logger.error(f"Hub startup failed: {e}", exc_info=True)

    async def _heartbeat_loop(self, interval: float) -> None:
        """Periodically update presence and check for dead agents."""
        while True:
            try:
                await asyncio.sleep(interval)
                if self._identity:
                    assert self._presence is not None
                    self._presence.heartbeat()
                    # Refresh roster
                    self._roster = self._presence.get_roster_summary()

                    # If coordinator, check for dead agents and clean up
                    if self._identity.is_coordinator:
                        await self._coordinator_cleanup()

                    # Vault autosave handled by _vault_autosave_loop (line 836)

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"Heartbeat error: {e}")

    async def _mailbox_loop(self) -> None:
        """Check filesystem mailbox for messages."""
        poll_interval = 5
        if self.config:
            poll_interval = self.config.get("plugins.hub.mailbox_poll_interval", 5)
        while True:
            try:
                await asyncio.sleep(poll_interval)
                if self._identity:
                    messages = AgentMessenger.read_mailbox(self._identity.agent_id)
                    for msg in messages:
                        await self._on_message_received(msg)
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"Mailbox check error: {e}")

    async def _dreaming_loop(self) -> None:
        """Periodically dream when agent is idle -- distill stream into insights.

        Checks every 60 seconds. If agent has been idle longer than the
        configured threshold and enough time has passed since the last dream,
        reads recent vault stream entries, sends them to the LLM for
        distillation, and appends insights to crystallized.md.
        """
        check_interval = 60
        while True:
            try:
                await asyncio.sleep(check_interval)
                if not self._identity or not self._vault:
                    continue

                # Read config thresholds
                idle_threshold = 300
                dream_interval = 3600
                stream_depth = 100
                if self.config:
                    idle_threshold = self.config.get(
                        "plugins.hub.dreaming_idle_threshold", 300
                    )
                    dream_interval = self.config.get(
                        "plugins.hub.dreaming_interval", 3600
                    )
                    stream_depth = self.config.get(
                        "plugins.hub.dreaming_stream_depth", 100
                    )

                now = time.time()
                idle_duration = now - self._last_activity_at
                since_last_dream = now - self._last_dream_at

                # Not idle long enough
                if idle_duration < idle_threshold:
                    continue

                # Too soon since last dream
                if since_last_dream < dream_interval:
                    continue

                # Don't dream if agent is actively working
                if self._identity.state not in (AgentState.IDLE.value, "ready"):
                    continue

                # Check there's actually stream data to review
                stream_entries = self._vault.get_recent_stream(stream_depth)
                if len(stream_entries) < 5:
                    continue

                await self._dream(stream_entries)

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"Dreaming loop error: {e}")

    async def _dream(self, stream_entries: List[Dict]) -> None:
        """Execute a single dreaming cycle.

        Reads stream, builds prompt, calls LLM, writes crystallized insights.
        Uses the existing provider directly for a background API call that
        does NOT interfere with the user's active conversation.
        """
        assert self._identity is not None
        assert self._vault is not None

        designation = self._identity.designation
        prev_state = self._identity.state
        self._identity.state = AgentLifecycle.DREAMING.value

        logger.info(
            f"dreaming: extracting insights from "
            f"{len(stream_entries)} stream entries"
        )

        # Log to vault
        self._vault.append_stream(
            "dreaming_start",
            f"dreaming cycle started ({len(stream_entries)} entries)",
            from_agent=designation,
        )

        try:
            # Build the dreaming prompt
            prompt = self._build_dreaming_prompt(stream_entries)

            # Try to make the LLM call
            insights = await self._dreaming_llm_call(prompt)

            if insights:
                # Append insights to crystallized.md
                self._vault.append_crystallized(insights)

                # Update working memory from recent stream
                peers = (
                    [a.designation for a in self._presence.discover_agents()]
                    if self._presence
                    else []
                )
                self._vault.update_working_memory(
                    stream_entries,
                    self._identity.current_task,
                    peers,
                )

                self._vault.append_stream(
                    "dreaming_complete",
                    "dreaming cycle complete, insights written",
                    from_agent=designation,
                )
                logger.info("dreaming: insights written to crystallized.md")
            else:
                self._vault.append_stream(
                    "dreaming_skipped",
                    "dreaming cycle produced no insights (LLM unavailable)",
                    from_agent=designation,
                )
                logger.info("dreaming: no insights produced (LLM unavailable)")

        except Exception as e:
            logger.warning(f"dreaming: error during cycle: {e}")
            self._vault.append_stream(
                "dreaming_error",
                f"dreaming cycle error: {e}",
                from_agent=designation,
            )
        finally:
            self._last_dream_at = time.time()
            # Restore previous state (unless something else changed it)
            if self._identity.state == AgentLifecycle.DREAMING.value:
                self._identity.state = prev_state


    async def _vault_autosave_loop(self) -> None:
        """Periodically save working memory so crash != total loss.

        Runs every N seconds (default 300 = 5 min). Rebuilds working
        memory from recent stream entries, mirroring the same logic used
        in _dream() and shutdown(). No-op if vault is missing.
        """
        while True:
            try:
                # Read interval from config (allows runtime tuning)
                interval = 300
                if self.config:
                    interval = self.config.get(
                        "plugins.hub.vault_autosave_interval", 60
                    )
                await asyncio.sleep(interval)

                if not self._vault or not self._identity:
                    continue

                recent = self._vault.get_recent_stream(50)
                peers = (
                    [a.designation for a in self._presence.discover_agents()]
                    if self._presence
                    else []
                )
                self._vault.update_working_memory(
                    recent,
                    self._identity.current_task,
                    peers,
                )
                self._last_autosave_at = time.time()
                logger.debug(
                    f"vault autosave: working_memory updated "
                    f"({len(recent)} stream entries)"
                )

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"Vault autosave error: {e}")


    async def _messaging_bridge_loop(self) -> None:
        """Background loop: connect to messaging bridge, poll for incoming.

        Incoming messages from the platform (e.g. Telegram) are routed
        as hub messages to the target agent (or self). Outgoing messages
        are handled separately -- the bridge.send() is called from
        _on_message_received when a message targets the bridge, or from
        the notifier when idle alerts fire.
        """
        if not self.config:
            return

        platform = self.config.get("plugins.hub.bridge_platform", "telegram")
        token = self.config.get("plugins.hub.bridge_token", "") or os.environ.get("KOLLABOR_HUB_BRIDGE_TOKEN", "")
        chat_id = self.config.get("plugins.hub.bridge_chat_id", "") or os.environ.get("KOLLABOR_HUB_BRIDGE_CHAT_ID", "")
        user_id = self.config.get("plugins.hub.bridge_user_id", "")
        poll_interval = self.config.get("plugins.hub.bridge_poll_interval", 2)

        if not token or not chat_id:
            logger.warning(
                "bridge_enabled=True but token/chat_id not configured"
            )
            return

        # Build bridge via factory
        kwargs: Dict[str, Any] = {"token": token, "chat_id": chat_id}
        if user_id:
            kwargs["user_id"] = user_id
        bridge = BridgeManager.create(platform, **kwargs)
        if not bridge:
            return

        # Connect
        connected = await bridge.connect()
        if not connected:
            logger.error(f"messaging bridge ({platform}) failed to connect")
            return

        self._bridge = bridge
        logger.info(
            f"messaging bridge loop started ({platform}, "
            f"poll every {poll_interval}s)"
        )

        try:
            while True:
                try:
                    messages = await bridge.poll()
                    for msg in messages:
                        await self._handle_bridge_incoming(msg)
                    # poll() already blocks via long-polling (30s for telegram).
                    # Only add extra sleep if no messages came back (backoff)
                    # or if the platform doesn't do long-polling.
                    if not messages:
                        await asyncio.sleep(poll_interval)
                except asyncio.CancelledError:
                    raise
                except Exception as e:
                    logger.debug(f"bridge poll error: {e}")
                    await asyncio.sleep(poll_interval)

        except asyncio.CancelledError:
            pass
        finally:
            await bridge.disconnect()
            self._bridge = None

    async def _handle_bridge_incoming(self, msg: IncomingMessage) -> None:
        """Route an incoming bridge message as a hub message.

        The message arrives from an external platform (e.g. Telegram)
        and gets injected into the hub as if a human sent it.
        """
        if not self._identity:
            return

        target = ""
        if self.config:
            target = self.config.get("plugins.hub.bridge_target_agent", "")
        if not target:
            target = self._identity.designation

        # Build a hub message from the external platform message
        hub_msg = HubMessage(
            action="message",
            from_agent=f"bridge:{msg.platform}",
            from_designation=msg.sender_name or msg.platform,
            to=target,
            content=msg.text,
            scope=MessageScope.DIRECT.value,
            metadata={
                "bridge_platform": msg.platform,
                "bridge_sender_id": msg.sender_id,
                "bridge_sender_name": msg.sender_name,
            },
        )

        logger.info(
            f"bridge incoming ({msg.platform}): "
            f"{msg.sender_name} -> {target}: {msg.text[:80]}"
        )

        # Route through the standard message handler
        await self._on_message_received(hub_msg)

    async def bridge_send(self, text: str) -> bool:
        """Send a message through the active bridge (if connected).

        Convenience method for other hub components (notifier, commands)
        to push outgoing messages to the external platform.

        Returns True if sent successfully, False otherwise.
        """
        if not self._bridge:
            return False
        try:
            return await self._bridge.send(text)
        except Exception as e:
            logger.warning(f"bridge_send failed: {e}")
            return False

    async def _bridge_forward(self, text: str) -> None:
        """Forward a message to the bridge if connected. Truncates to 500 chars.

        Silently no-ops if bridge is not active. Never raises.
        """
        if not self._bridge:
            return
        try:
            truncated = text[:500] if len(text) > 500 else text
            await self._bridge.send(truncated)
        except Exception:
            pass

    async def _cron_loop(self) -> None:
        """Check and fire hub cron jobs + task reminders every 10 seconds."""
        while True:
            try:
                await asyncio.sleep(10)
                if not self._identity:
                    continue

                has_cron_jobs = bool(self._hub_cron_jobs)
                has_task_ledger = bool(self._task_ledger)

                if not has_cron_jobs and not has_task_ledger:
                    continue

                now = time.time()

                # --- Hub cron jobs ---
                if has_cron_jobs:
                    fired: List[str] = []
                    remove_ids: List[str] = []

                    for job in self._hub_cron_jobs:
                        if now >= job.next_fire:
                            target = job.target
                            scope = self._resolve_scope(target)
                            if target in ("all", "*"):
                                to = "*"
                                scope = MessageScope.BROADCAST.value
                            else:
                                to = target

                            msg = HubMessage(
                                action="message",
                                from_agent=self._identity.agent_id,
                                from_designation="hub-cron",
                                to=to,
                                content=f"[cron {job.id}] {job.message}",
                                scope=scope,
                            )
                            await self._route_message(msg)
                            logger.info(
                                f"hub cron fired: {job.id}"
                                f" -> {job.target}"
                            )
                            fired.append(job.id)

                            if job.recurring:
                                job.next_fire = now + job.interval_seconds
                            else:
                                remove_ids.append(job.id)

                    if remove_ids:
                        self._hub_cron_jobs = [
                            j
                            for j in self._hub_cron_jobs
                            if j.id not in remove_ids
                        ]

                # --- Task auto-cron: remind agents of due tasks ---
                if has_task_ledger:
                    due_tasks = self._task_ledger.get_cron_due()
                    for task in due_tasks:
                        if task.assignee == self._identity.designation:
                            # Self-reminder: task is already in system
                            # prompt via roster injection, just touch
                            # updated_at to reset the cron timer
                            task.updated_at = time.time()
                            self._task_ledger._save(task)
                        elif self._presence:
                            # Remote: send reminder to assignee
                            reminder_msg = HubMessage(
                                action="message",
                                from_agent=self._identity.agent_id,
                                from_designation="task-cron",
                                to=task.assignee,
                                content=(
                                    f"[task reminder: {task.id}]"
                                    f" {task.directive}\n"
                                    f"report to: {task.report_to}\n"
                                    "use <task_checkpoint> to save"
                                    " progress or <task_complete>"
                                    " when done."
                                ),
                                scope=MessageScope.DIRECT.value,
                            )
                            agents = (
                                self._presence.discover_agents()
                            )
                            for a in agents:
                                if a.designation == task.assignee:
                                    await self._deliver_to_agent(
                                        a, reminder_msg
                                    )
                            task.updated_at = time.time()
                            self._task_ledger._save(task)

            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.debug(f"Cron loop error: {e}")

    def _build_dreaming_prompt(self, stream_entries: List[Dict]) -> str:
        """Build the prompt for the dreaming LLM call."""
        assert self._vault is not None

        # Format stream entries as readable text
        entry_lines = []
        for entry in stream_entries:
            ts = entry.get("ts", 0)
            ts_str = time.strftime("%H:%M:%S", time.localtime(ts)) if ts else "?"
            etype = entry.get("type", "?")
            content = entry.get("content", "")[:300]
            frm = entry.get("from", "")
            to = entry.get("to", "")
            prefix = ""
            if frm:
                prefix = f"[{frm}"
                if to:
                    prefix += f" -> {to}"
                prefix += "] "
            entry_lines.append(f"  {ts_str} {etype}: {prefix}{content}")

        stream_text = "\n".join(entry_lines)

        # Get existing crystallized knowledge
        crystallized = self._vault.get_crystallized()
        crystal_section = crystallized if crystallized else "(none yet)"

        return (
            "You are reviewing your recent activity to extract durable insights.\n"
            "\n"
            "Recent activity (from vault stream):\n"
            f"{stream_text}\n"
            "\n"
            "Existing crystallized knowledge:\n"
            f"{crystal_section}\n"
            "\n"
            "Extract 3-5 concise insights from your recent activity that would be "
            "valuable to remember across sessions. Focus on:\n"
            "- Patterns you noticed in the codebase\n"
            "- Debugging techniques that worked\n"
            "- User preferences or communication style\n"
            "- Architectural decisions and their rationale\n"
            "- Mistakes to avoid\n"
            "\n"
            "Format each insight as a single paragraph. Do not repeat insights "
            "already in your crystallized knowledge."
        )

    async def _dreaming_llm_call(self, prompt: str) -> Optional[str]:
        """Make a background LLM call for dreaming.

        Uses the existing provider instance directly (stateless call)
        so it does NOT interfere with the user's active conversation.
        Returns the LLM response text, or None if unavailable.
        """
        try:
            llm_service = (
                self.event_bus.get_service("llm_service") if self.event_bus else None
            )
            if not llm_service:
                return None

            api_service = getattr(llm_service, "api_service", None)
            if not api_service:
                return None

            provider = getattr(api_service, "_provider", None)
            if not provider:
                return None

            # Make a simple one-shot call using the provider directly.
            # This bypasses APICommunicationService state (cancel flags,
            # token tracking, etc.) so it's safe to run concurrently.
            messages = [
                {"role": "user", "content": prompt},
            ]

            response = await provider.call(messages=messages)
            content = response.get_text_content()
            return content.strip() if content else None

        except Exception as e:
            logger.debug(f"dreaming: LLM call failed: {e}")
            return None

    async def _coordinator_cleanup(self) -> None:
        """Coordinator duty: clean up dead agents, reassign work."""
        assert self._presence is not None
        agents = self._presence.discover_agents(include_self=True)
        # discover_agents already cleans dead presence files
        # Check if any assigned work needs reassignment
        if self._work_queue:
            for slot in self._work_queue.get_all():
                if slot.status == "assigned" and slot.assigned_to:
                    # Check if assigned agent still exists
                    alive = any(a.designation == slot.assigned_to for a in agents)
                    if not alive:
                        dead_agent = slot.assigned_to
                        slot.status = "pending"
                        slot.assigned_to = None
                        logger.info(
                            f"Reassigning work {slot.id}: "
                            f"agent {dead_agent} is dead"
                        )

    async def _try_assign_work(self) -> None:
        """Try to assign pending work to idle agents using capability matching."""
        if not self._work_queue:
            return
        pending = self._work_queue.get_pending()
        if not pending:
            return

        assert self._presence is not None
        agents = self._presence.discover_agents()

        for slot in sorted(pending, key=lambda s: -s.priority):
            best = self._work_queue.find_best_agent(agents, slot)
            if not best:
                continue
            # Find the agent identity for socket path
            target = next(
                (a for a in agents if getattr(a, "designation", "") == best),
                None,
            )
            if not target:
                continue
            claimed = self._work_queue.claim_by_id(slot.id, best)
            if not claimed:
                continue
            # Send work assignment via socket
            msg = HubMessage(
                action="message",
                from_designation="hub",
                to=best,
                content=(
                    f"[work assignment from hub]\n"
                    f"task: {slot.task}\n"
                    f"priority: {slot.priority}\n"
                    f"context: {slot.context}"
                ),
                scope=MessageScope.DIRECT.value,
            )
            await AgentMessenger.send_to_agent(target.socket_path, msg)

    async def _announce_to_peers(self, peers: List[AgentRuntime]) -> None:
        """Announce this agent to all existing peers, triggering their LLM.

        This is what makes agents social - when a new agent joins,
        every existing agent gets a message injected into their
        conversation telling them who joined and what they're working on.
        The LLM naturally responds, often offering help.
        """
        if not self._identity:
            return

        # Build a summary of everyone on the hub for the announcement
        roster_lines = []
        for peer in peers:
            task_info = f" working on: {peer.current_task}" if peer.current_task else ""
            coord = " (coordinator)" if peer.is_coordinator else ""
            roster_lines.append(
                f"  - {peer.designation}{coord}: {peer.state}{task_info}"
            )

        roster_summary = "\n".join(roster_lines) if roster_lines else "  (none)"

        for peer in peers:
            intro = HubMessage(
                action="message",
                from_agent=self._identity.agent_id,
                from_designation=self._identity.designation,
                to=peer.designation,
                content=(
                    f"agent '{self._identity.designation}' just came online "
                    f"in project {self._identity.project}.\n"
                    f"current hub roster:\n{roster_summary}\n"
                    f"if you need help with anything, let them know.\n"
                    f"respond back using: "
                    f'<hub_msg to="{self._identity.designation}">your message</hub_msg>'
                ),
                scope=MessageScope.DIRECT.value,
            )
            await self._deliver_to_agent(peer, intro)
            logger.info(f"Announced to {peer.designation}")

    async def _on_message_received(self, message: HubMessage) -> None:
        """Handle an incoming message from another agent."""
        # Dedup check
        msg_id = getattr(message, "id", "") or ""
        if msg_id and msg_id in self._seen_message_ids:
            logger.debug(f"Dedup: skipping already-seen message {msg_id}")
            return
        if msg_id:
            self._seen_message_ids.add(msg_id)
            self._seen_ids_order.append(msg_id)
            # Evict oldest if over capacity
            while len(self._seen_message_ids) > 1000:
                old = self._seen_ids_order.popleft()
                self._seen_message_ids.discard(old)

        if message.action == "roster_update":
            try:
                self._roster = json.loads(message.content)
            except Exception:
                pass
            return

        # Log to vault
        if self._vault:
            self._vault.append_stream(
                "received",
                message.content,
                from_agent=message.from_designation,
                to_agent=self._identity.designation if self._identity else "",
            )

        # Auto-create TaskCard for incoming task assignments.
        # Messages with task metadata or work-assignment patterns
        # get persisted to disk so they survive compaction.
        if (
            self._task_ledger
            and self._identity
            and message.to == self._identity.designation
            and message.from_designation not in ("task-cron", "hub-cron")
        ):
            content_lower = message.content.lower()
            is_task = (
                message.metadata.get("task_assignment")
                or "[work assignment" in content_lower
                or "task:" in content_lower
                or "directive:" in content_lower
            )
            if is_task:
                # Extract directive from the message content
                directive = message.content[:500]
                card = self._task_ledger.create(
                    assigner=message.from_designation,
                    assignee=self._identity.designation,
                    directive=directive,
                    report_to=message.from_designation,
                )
                logger.info(
                    f"Auto-created task {card.id} from"
                    f" {message.from_designation}"
                )

        # Display the message visually in the UI
        self._display_hub_message(message)

        # Forward to bridge (skip system/cron noise)
        if (
            self._bridge
            and message.from_designation not in ("hub-cron", "task-cron")
            and message.action != "roster_update"
        ):
            my_name = self._identity.designation if self._identity else "?"
            from_name = message.from_designation
            is_arrival = "just came online" in message.content
            is_departure = "is going offline" in message.content
            if is_arrival:
                # Extract just the agent name from "agent 'X' just came online ..."
                await self._bridge_forward(f"[hub] {from_name} came online")
            elif is_departure:
                await self._bridge_forward(f"[hub] {from_name} is going offline")
            elif message.to == my_name or message.to in (
                "*", "all", "everyone", "team", "project",
            ):
                await self._bridge_forward(
                    f"[{from_name} -> {my_name}] {message.content}"
                )
            else:
                # Observed message (not directed at us)
                await self._bridge_forward(
                    f"[{from_name} -> {message.to}] (observed) {message.content}"
                )

        # Inject into llm_service.conversation_history and trigger LLM
        llm_service = (
            self.event_bus.get_service("llm_service") if self.event_bus else None
        )
        if llm_service and hasattr(llm_service, "conversation_history"):
            my_name = self._identity.designation if self._identity else ""
            is_intended = message.to == my_name or message.to in (
                "*",
                "all",
                "everyone",
                "team",
                "project",
            )

            # Build the injected content
            formatted = (
                f"[hub channel: {message.from_designation} -> {message.to}]\n"
                f"{message.content}"
            )
            if not is_intended:
                formatted += (
                    f"\n(this message was sent to {message.to}. "
                    f"you do not need to respond unless this is relevant "
                    f"to your current task or you can add value to the discussion.)"
                )

            try:
                from kollabor_events.data_models import ConversationMessage

                # Build metadata, include bridge info if present
                msg_metadata = {
                    "hub_message": True,
                    "hub_from": message.from_designation,
                    "hub_to": message.to,
                    "hub_is_intended": is_intended,
                    "hub_scope": getattr(message, "scope", "direct"),
                }
                # Pass through bridge metadata for relay
                if hasattr(message, "metadata") and message.metadata:
                    if message.metadata.get("bridge_platform"):
                        msg_metadata["bridge_platform"] = message.metadata["bridge_platform"]

                llm_service.conversation_history.append(
                    ConversationMessage(
                        role="user",
                        content=formatted,
                        metadata=msg_metadata,
                    )
                )
                # Only trigger LLM processing if this agent is the intended target
                # or it's a broadcast. Non-targets see it but don't auto-respond.
                # Skip LLM trigger for departure/system messages to avoid feedback loops.
                is_departure = "is going offline" in message.content
                is_system = message.from_designation in ("hub-cron", "task-cron", "hub")
                if is_intended and not is_departure and not is_system:
                    await self.event_bus.emit_with_hooks(
                        EventType.TRIGGER_LLM_CONTINUE,
                        {
                            "source": f"hub:{message.from_designation}",
                            "content": message.content,
                        },
                        "hub_plugin",
                    )
            except Exception as e:
                logger.error(f"Hub message trigger failed: {e}")

    # Fallback colors when designation isn't in the gem pool
    _FALLBACK_COLORS = [
        (120, 200, 255),
        (255, 180, 100),
        (150, 255, 150),
        (255, 140, 180),
        (200, 170, 255),
        (100, 240, 220),
    ]

    def _get_agent_color(self, designation: str) -> tuple:
        """Get the gem color for a designation, or hash-based fallback."""
        gem = GEM_BY_NAME.get(designation)
        if gem:
            return gem.color_rgb
        # Numbered variants (peridot-2) -- strip the suffix
        base = designation.rsplit("-", 1)[0] if "-" in designation else designation
        gem = GEM_BY_NAME.get(base)
        if gem:
            return gem.color_rgb
        # Fallback for non-gem designations
        idx = hash(designation) % len(self._FALLBACK_COLORS)
        return self._FALLBACK_COLORS[idx]

    def _render_hub_box(
        self, from_name: str, to_name: str, content: str, observing: bool = False
    ) -> None:
        """Render a hub message through the theme system's agent message type.

        Routes through display_message_sequence with type "agent" so the
        renderer's agent_message() method handles all TagBox rendering.

        Direct messages: bright, > tag
        Observed messages: dimmed tag color, ~ tag
        """
        renderer = self.event_bus.get_service("renderer") if self.event_bus else None
        if not renderer or not hasattr(renderer, "message_coordinator"):
            return

        color = self._get_agent_color(from_name)
        display_content = f"{from_name} -> {to_name}\n{content}"
        tag_char = " ~ " if observing else " > "

        try:
            renderer.message_coordinator.display_message_sequence(
                [
                    (
                        "agent",
                        display_content,
                        {
                            "agent_color": color,
                            "tag_char": tag_char,
                            "observing": observing,
                        },
                    )
                ]
            )
        except Exception as e:
            logger.warning(f"Hub message display failed: {e}")

    def _display_hub_message(self, message: HubMessage) -> None:
        """Display an incoming hub message with agent-colored TagBox.

        Direct: bright box with > tag in sender's color
        Observed: dim box with ~ tag, shows actual target
        """
        my_name = self._identity.designation if self._identity else "?"
        is_intended = message.to == my_name or message.to in (
            "*",
            "all",
            "everyone",
            "team",
            "project",
        )

        if is_intended:
            self._render_hub_box(message.from_designation, my_name, message.content)
        else:
            self._render_hub_box(
                message.from_designation,
                message.to,
                message.content,
                observing=True,
            )

    def _display_outgoing_message(self, to_name: str, content: str) -> None:
        """Display an outgoing hub message with agent-colored TagBox."""
        my_name = self._identity.designation if self._identity else "?"
        self._render_hub_box(my_name, to_name, content)

    async def _inject_roster_context(self, context, event_context=None):
        """Inject hub roster into conversation history before LLM calls.

        This is the SOCIAL LAYER. The LLM sees who else is working,
        what they're doing, and can proactively offer help.

        Injects roster as the first system message in conversation_history
        (the actual list the API call uses), updating it each turn.
        """
        if not self._identity or not self._roster:
            return context

        # Build roster block
        lines = []
        lines.append("--- hub context ---")
        lines.append(f'you are "{self._identity.designation}" on the kollabor hub.')
        if self._identity.is_coordinator:
            lines.append("you are the coordinator.")
        lines.append("")

        if self._roster:
            lines.append("active agents:")
            for agent in self._roster:
                status = agent.get("state", "unknown")
                task = agent.get("current_task", "")
                desg = agent.get("designation", "?")
                coord = " (coordinator)" if agent.get("is_coordinator") else ""

                if task:
                    lines.append(f"  {desg}{coord} - {status}: {task}")
                else:
                    lines.append(f"  {desg}{coord} - {status}")
        else:
            lines.append("no other agents online.")

        lines.append("")

        auto_help = True
        if self.config:
            auto_help = self.config.get("plugins.hub.auto_help", True)

        lines.append(
            "to message an agent, ALWAYS use this exact format:"
        )
        lines.append(
            '<hub_msg to="designation">your message</hub_msg>'
        )
        lines.append("")
        lines.append(
            "IMPORTANT: when asked to delegate, coordinate, or assign tasks "
            "to other agents, you MUST use <hub_msg> tags. without them, "
            "your message will NOT reach the other agent. never describe "
            "what you would say -- actually say it with the tag."
        )

        if auto_help and self._roster:
            lines.append(
                "if you can assist another agent with their current task, "
                "proactively offer help via hub_msg."
            )

        lines.append("--- end hub context ---")

        roster_block = "\n".join(lines)

        # Inject active tasks (compaction-proof -- lives on disk, injected
        # into system prompt every LLM turn, survives any compaction)
        if self._identity and self._task_ledger:
            my_tasks = self._task_ledger.get_active_for(
                self._identity.designation
            )
            if my_tasks:
                task_lines = ["", "--- active tasks (DO NOT FORGET) ---"]
                for task in my_tasks:
                    task_lines.append(
                        f"task {task.id} from {task.assigner}"
                        f" (priority {task.priority}):"
                    )
                    task_lines.append(f"  directive: {task.directive}")
                    if task.deliverable:
                        task_lines.append(
                            f"  deliverable: {task.deliverable}"
                        )
                    task_lines.append(f"  report to: {task.report_to}")
                    if task.last_checkpoint_note():
                        task_lines.append(
                            f"  last checkpoint:"
                            f" {task.last_checkpoint_note()}"
                        )
                    task_lines.append(f"  elapsed: {task.elapsed_str()}")
                    if task.is_timed_out():
                        task_lines.append(
                            "  WARNING: exceeded timeout, complete NOW"
                        )
                    if task.status == "qa_review":
                        task_lines.append(
                            "  STATUS: awaiting QA review"
                        )
                    task_lines.append("")
                task_lines.append(
                    "when done, use:"
                    ' <task_complete id="TASK_ID">result</task_complete>'
                )
                task_lines.append(
                    "to checkpoint progress:"
                    ' <task_checkpoint id="TASK_ID">'
                    "progress note</task_checkpoint>"
                )
                task_lines.append(
                    "to request QA:"
                    ' <task_qa id="TASK_ID">'
                    "result for review</task_qa>"
                )
                task_lines.append("--- end tasks ---")
                roster_block += "\n" + "\n".join(task_lines)

        # Inject into actual conversation history that gets sent to the API.
        # Find the llm_service's conversation_history and prepend/update
        # a hub context system message.
        try:
            llm_service = (
                self.event_bus.get_service("llm_service") if self.event_bus else None
            )
            if llm_service and hasattr(llm_service, "conversation_history"):
                history = llm_service.conversation_history
                if history:
                    # Check if first message is system prompt - append to it
                    first = history[0]
                    if getattr(first, "role", None) == "system":
                        content = getattr(first, "content", "") or ""
                        # Remove old hub context if present
                        if "--- hub context ---" in content:
                            idx = content.index("--- hub context ---")
                            content = content[:idx].rstrip()
                        first.content = content + "\n\n" + roster_block
                    else:
                        # No system message - inject one
                        from kollabor_events.data_models import ConversationMessage

                        history.insert(
                            0,
                            ConversationMessage(role="system", content=roster_block),
                        )
        except Exception as e:
            logger.debug(f"Roster injection error: {e}")

        return context

    async def _set_working(self, context, event_context=None):
        """Mark agent as working when LLM request starts."""
        if self._identity:
            self._identity.state = AgentState.WORKING.value
            self._last_activity_at = time.time()
            messages = context.get("messages", [])
            for msg in reversed(messages):
                if msg.get("role") == "user":
                    content = msg.get("content", "")
                    if not content.startswith("<sys_msg>") and not content.startswith(
                        "[message from"
                    ):
                        self._identity.current_task = content[:200]
                        if self._vault:
                            self._vault.append_stream("user_input", content[:500])
                        break
        return context

    async def _set_idle(self, context, event_context=None):
        """Mark agent as idle when LLM response completes."""
        if self._identity:
            self._identity.state = AgentState.IDLE.value
            self._last_activity_at = time.time()
        return context

    async def _broadcast_user_input(self, data, event=None):
        """Broadcast user input to all peers so they see what the human said."""
        if not self._started or not self._identity or not self._presence:
            return data

        user_content = (data.get("message") or "").strip()
        if not user_content:
            return data

        # Skip slash commands - those are local
        if user_content.startswith("/"):
            return data

        # Skip system/hub messages that were injected
        if user_content.startswith("<sys_msg>") or user_content.startswith(
            "[message from"
        ):
            return data

        # Truncate for sanity
        user_content = user_content[:500]

        # Resolve display name for the human
        user_name = "marco"
        if self.config:
            user_name = self.config.get("plugins.hub.user_name", "marco")

        peers = self._presence.discover_agents()
        if not peers:
            return data

        my_designation = self._identity.designation or "?"

        for peer in peers:
            try:
                msg = HubMessage(
                    action="message",
                    from_agent="human",
                    from_designation=user_name,
                    to=my_designation,
                    content=user_content,
                    scope=MessageScope.BROADCAST.value,
                )
                await self._deliver_to_agent(peer, msg)
            except Exception:
                pass

        return data

    async def _parse_hub_messages(self, data, event=None):
        """Parse <hub_msg> and task tags from LLM response."""
        response = data.get("response_text", "") or data.get("clean_response", "")
        if not response:
            return data

        # Log every response to vault (truncated)
        if self._vault and response.strip():
            self._vault.append_stream("response", response[:1000])

        has_hub_msg = "<hub_msg" in response
        has_task_tags = any(
            tag in response
            for tag in (
                "<task_checkpoint",
                "<task_complete",
                "<task_approve",
                "<task_reject",
            )
        )

        if not has_hub_msg and not has_task_tags:
            return data

        cleaned = response

        # --- Hub message routing ---
        if has_hub_msg:
            hub_pattern = r'<hub_msg\s+to="([^"]+)">(.*?)</hub_msg>'
            matches = re.findall(hub_pattern, response, re.DOTALL)

            for target, content in matches:
                content = content.strip()
                if not content:
                    continue

                msg = HubMessage(
                    action="message",
                    from_agent=(
                        self._identity.agent_id if self._identity else ""
                    ),
                    from_designation=(
                        self._identity.designation if self._identity else ""
                    ),
                    to=target,
                    content=content,
                    scope=self._resolve_scope(target),
                )

                await self._route_message(msg)
                self._display_outgoing_message(target, content)

                # Forward outgoing hub messages to bridge
                my_name = (
                    self._identity.designation if self._identity else "?"
                )
                await self._bridge_forward(
                    f"[{my_name} -> {target}] {content}"
                )

            cleaned = re.sub(
                hub_pattern, "", cleaned, flags=re.DOTALL
            ).strip()

        # --- Task tag parsing ---
        if has_task_tags and self._task_ledger and self._identity:
            # <task_checkpoint id="xxx">note</task_checkpoint>
            cp_pattern = (
                r'<task_checkpoint\s+id="([^"]+)">'
                r"(.*?)</task_checkpoint>"
            )
            for task_id, note in re.findall(
                cp_pattern, response, re.DOTALL
            ):
                self._task_ledger.checkpoint(
                    task_id.strip(), note.strip()
                )
                logger.info(
                    f"Task {task_id.strip()} checkpoint:"
                    f" {note.strip()[:60]}"
                )

            # <task_complete id="xxx">result</task_complete>
            tc_pattern = (
                r'<task_complete\s+id="([^"]+)">'
                r"(.*?)</task_complete>"
            )
            for task_id, result in re.findall(
                tc_pattern, response, re.DOTALL
            ):
                card = self._task_ledger.request_qa(
                    task_id.strip(), result.strip()
                )
                if card and self._presence:
                    qa_msg = HubMessage(
                        action="message",
                        from_agent=(
                            self._identity.agent_id
                            if self._identity
                            else ""
                        ),
                        from_designation=self._identity.designation,
                        to=card.report_to,
                        content=(
                            f"[task {card.id} complete - QA needed]\n"
                            f"directive: {card.directive}\n"
                            f"result: {result.strip()}\n\n"
                            "review and approve with:"
                            f' <task_approve id="{card.id}">'
                            "notes</task_approve>\n"
                            "or reject:"
                            f' <task_reject id="{card.id}">'
                            "reason</task_reject>"
                        ),
                        scope=MessageScope.DIRECT.value,
                    )
                    agents = self._presence.discover_agents()
                    for a in agents:
                        if a.designation == card.report_to:
                            await self._deliver_to_agent(a, qa_msg)

            # <task_approve id="xxx">notes</task_approve>
            ta_pattern = (
                r'<task_approve\s+id="([^"]+)">'
                r"(.*?)</task_approve>"
            )
            for task_id, notes in re.findall(
                ta_pattern, response, re.DOTALL
            ):
                card = self._task_ledger.qa_approve(
                    task_id.strip(),
                    self._identity.designation,
                    notes.strip(),
                )
                if card and self._presence:
                    approve_msg = HubMessage(
                        action="message",
                        from_agent=(
                            self._identity.agent_id
                            if self._identity
                            else ""
                        ),
                        from_designation=self._identity.designation,
                        to=card.assignee,
                        content=(
                            f"[task {card.id} QA PASSED]\n"
                            f"reviewer: {self._identity.designation}\n"
                            f"notes: {notes.strip()}"
                        ),
                        scope=MessageScope.DIRECT.value,
                    )
                    agents = self._presence.discover_agents()
                    for a in agents:
                        if a.designation == card.assignee:
                            await self._deliver_to_agent(
                                a, approve_msg
                            )

            # <task_reject id="xxx">reason</task_reject>
            tr_pattern = (
                r'<task_reject\s+id="([^"]+)">'
                r"(.*?)</task_reject>"
            )
            for task_id, reason in re.findall(
                tr_pattern, response, re.DOTALL
            ):
                card = self._task_ledger.qa_reject(
                    task_id.strip(),
                    self._identity.designation,
                    reason.strip(),
                )
                if card and self._presence:
                    reject_msg = HubMessage(
                        action="message",
                        from_agent=(
                            self._identity.agent_id
                            if self._identity
                            else ""
                        ),
                        from_designation=self._identity.designation,
                        to=card.assignee,
                        content=(
                            f"[task {card.id} QA REJECTED - rework"
                            " needed]\n"
                            f"reviewer:"
                            f" {self._identity.designation}\n"
                            f"reason: {reason.strip()}"
                        ),
                        scope=MessageScope.DIRECT.value,
                    )
                    agents = self._presence.discover_agents()
                    for a in agents:
                        if a.designation == card.assignee:
                            await self._deliver_to_agent(
                                a, reject_msg
                            )

            # Strip all task tags from display
            for pat in [cp_pattern, tc_pattern, ta_pattern, tr_pattern]:
                cleaned = re.sub(
                    pat, "", cleaned, flags=re.DOTALL
                ).strip()

        # Apply cleaned response
        if "response_text" in data:
            data["response_text"] = cleaned
        if "clean_response" in data:
            data["clean_response"] = cleaned

        # If the entire response was tags (nothing left after stripping),
        # suppress the assistant message render entirely
        if not cleaned:
            data["suppress_display"] = True

        # Bridge relay: forward ALL LLM responses to the bridge.
        # Hub messages from this agent are already forwarded at send time,
        # so only forward the cleaned text that remains after tag stripping
        # (the "natural language" part of the response).
        if cleaned:
            await self._bridge_forward(cleaned)

        return data

    async def _route_message(self, message: HubMessage) -> None:
        """Route a message to all agents (open channel).

        Every agent sees every message - like a Slack channel.
        The intended recipient is marked so others know they don't
        have to respond unless the topic is relevant to them.
        """
        assert self._presence is not None
        # Log outgoing message to vault
        if self._vault:
            self._vault.append_stream(
                "sent",
                message.content,
                from_agent=self._identity.designation if self._identity else "",
                to_agent=message.to,
            )

        # Broadcast to ALL agents except self (open channel model)
        # Self already sees the message via _display_outgoing_message
        agents = self._presence.discover_agents()
        my_id = self._identity.agent_id if self._identity else ""
        for agent in agents:
            if agent.agent_id != my_id:
                await self._deliver_to_agent(agent, message)

    async def _deliver_to_agent(self, agent: AgentRuntime, message: HubMessage) -> None:
        """Deliver message to agent via socket, fall back to filesystem."""
        success = await AgentMessenger.send_to_agent(agent.socket_path, message)
        if not success:
            await AgentMessenger.send_to_file(agent.agent_id, message)

    def _resolve_scope(self, target: str) -> str:
        """Determine message scope from target string."""
        if target in ("*", "all", "everyone"):
            return MessageScope.BROADCAST.value
        elif target in ("team", "project"):
            return MessageScope.PROJECT.value
        return MessageScope.DIRECT.value

    def _register_commands(self) -> None:
        """Register /hub slash command."""
        cmd = CommandDefinition(
            name="hub",
            description="Agent mesh hub",
            category=CommandCategory.CUSTOM,
            plugin_name=self.name,
            aliases=["mesh"],
            handler=self._handle_hub_command,
            mode=CommandMode.INSTANT,
            subcommands=[
                SubcommandInfo("status", "", "Show hub status and agents"),
                SubcommandInfo("msg", "<agent> <message>", "Send message to agent"),
                SubcommandInfo("broadcast", "<message>", "Broadcast to all agents"),
                SubcommandInfo("work", "", "List pending work"),
                SubcommandInfo("queue", "<task>", "Queue work for next agent"),
                SubcommandInfo("claim", "[id]", "Claim a work slot"),
                SubcommandInfo("whoami", "", "Show your designation"),
                SubcommandInfo("vault", "[name]", "Show vault info for an agent"),
                SubcommandInfo("vaults", "", "List all agent vaults"),
                SubcommandInfo("org", "<name> [mission]", "Launch an organization"),
                SubcommandInfo("orgs", "", "List available organizations"),
                SubcommandInfo("feed", "", "Live dashboard of agent activity"),
                SubcommandInfo(
                    "console", "", "Agent management console (sidebar + feed)"
                ),
                SubcommandInfo("spawn", "<name> <task>", "Spawn a new agent"),
                SubcommandInfo("capture", "<name|all> [lines]", "Capture agent output"),
                SubcommandInfo("stop", "<name|all>", "Stop agent(s)"),
                SubcommandInfo("kill", "<designation>", "Kill any agent on the mesh"),
                SubcommandInfo("agents", "", "List all active agents"),
                SubcommandInfo(
                    "cron",
                    "add|list|delete|clear",
                    "Schedule recurring hub messages",
                ),
                SubcommandInfo(
                    "notify",
                    "enable|disable|channel|url|threshold|test|status",
                    "Manage notification settings",
                ),
                SubcommandInfo(
                    "tasks",
                    "list|mine|assign|cancel|status",
                    "Task management",
                ),
                SubcommandInfo(
                    "bridge",
                    "status|send|enable|disable",
                    "Messaging bridge (Telegram, etc)",
                ),
            ],
        )
        assert self.command_registry is not None
        self.command_registry.register_command(cmd)

    async def _handle_hub_command(self, command_or_args=None, **kwargs) -> str:
        """Handle /hub slash command.

        Accepts either a SlashCommand object (from executor) or a string (direct call).
        """
        # Extract args string from SlashCommand or use directly
        if command_or_args is None:
            args_str = ""
        elif isinstance(command_or_args, str):
            args_str = command_or_args
        elif hasattr(command_or_args, "args"):
            # SlashCommand object -- args is List[str]
            args_str = " ".join(command_or_args.args) if command_or_args.args else ""
        else:
            args_str = str(command_or_args)

        parts = args_str.strip().split(maxsplit=1)
        subcmd = parts[0] if parts else "status"
        rest = parts[1] if len(parts) > 1 else ""

        if subcmd == "status":
            return self._format_status()
        elif subcmd == "whoami":
            return self._format_whoami()
        elif subcmd == "msg":
            return await self._handle_msg_command(rest)
        elif subcmd == "broadcast":
            return await self._handle_broadcast_command(rest)
        elif subcmd == "work":
            return self._format_work()
        elif subcmd == "queue":
            return self._handle_queue_command(rest)
        elif subcmd == "claim":
            return await self._handle_claim_command(rest)
        elif subcmd == "vault":
            return self._format_vault(rest)
        elif subcmd == "vaults":
            return self._format_all_vaults()
        elif subcmd == "org":
            return await self._handle_org_command(rest)
        elif subcmd == "orgs":
            return self._format_orgs()
        elif subcmd == "feed":
            return await self._handle_feed_command()
        elif subcmd == "console":
            return await self._handle_console_command()
        elif subcmd == "spawn":
            return await self._handle_spawn_command(rest)
        elif subcmd == "capture":
            return await self._handle_capture_command(rest)
        elif subcmd == "stop":
            return await self._handle_stop_command(rest)
        elif subcmd == "kill":
            return await self._handle_kill_command(rest)
        elif subcmd == "agents":
            return await self._handle_agents_command()
        elif subcmd == "cron":
            return self._handle_cron_command(rest)
        elif subcmd == "notify":
            return await self._handle_notify_command(rest)
        elif subcmd == "tasks":
            return await self._handle_tasks_command(rest)
        elif subcmd == "bridge":
            return await self._handle_bridge_command(rest)
        else:
            return self._format_status()

    async def _handle_kill_command(self, rest: str) -> str:
        """Kill any agent on the mesh by designation via shutdown socket action."""
        designation = rest.strip()
        if not designation:
            return "usage: /hub kill <designation>"

        if not self._presence:
            return "hub not active"

        # Don't kill yourself
        if self._identity and designation == self._identity.designation:
            return "can't kill yourself. use ctrl+c."

        agent = self._presence.get_agent_by_designation(designation)
        if not agent:
            available = [a.designation for a in self._presence.discover_agents()]
            return f"agent '{designation}' not found. online: {', '.join(available) or 'none'}"

        if not agent.socket_path:
            return f"agent '{designation}' has no socket path"

        try:
            success = await AgentMessenger.signal_shutdown(
                agent.socket_path, reason=f"killed by {self._identity.designation}", timeout=5.0
            )
            if success:
                return f"shutdown signal sent to '{designation}'"
            else:
                return f"failed to signal '{designation}' (socket may be dead)"
        except Exception as e:
            return f"kill error: {e}"

    def _get_orchestrator(self):
        """Get the agent orchestrator plugin via service registry."""
        if not self.event_bus:
            return None
        return self.event_bus.get_service("agent_orchestrator")

    async def _handle_spawn_command(self, rest: str) -> str:
        """Handle /hub spawn <name> <task>."""
        orch = self._get_orchestrator()
        if not orch:
            return "error: agent orchestrator not available"
        args = rest.split() if rest.strip() else []
        if len(args) < 2:
            return "usage: /hub spawn <name> <task>"
        result = await orch._cmd_create(args)
        return result.message

    async def _handle_capture_command(self, rest: str) -> str:
        """Handle /hub capture <name|all> [lines]."""
        orch = self._get_orchestrator()
        if not orch:
            return "error: agent orchestrator not available"
        args = rest.split() if rest.strip() else []
        if not args:
            return "usage: /hub capture <name|all> [lines]"
        result = await orch._cmd_capture(args)
        return result.message

    async def _handle_stop_command(self, rest: str) -> str:
        """Handle /hub stop <name|all>."""
        orch = self._get_orchestrator()
        if not orch:
            return "error: agent orchestrator not available"
        args = rest.split() if rest.strip() else []
        if not args:
            return "usage: /hub stop <name|all>"
        result = await orch._cmd_stop(args)
        return result.message

    async def _handle_agents_command(self) -> str:
        """Handle /hub agents -- list all active agents."""
        orch = self._get_orchestrator()
        if not orch:
            return "error: agent orchestrator not available"
        result = await orch._cmd_list([])
        return result.message

    # -- Cron commands --

    def _handle_cron_command(self, args: str) -> str:
        """Handle /hub cron subcommands."""
        parts = args.strip().split(maxsplit=1)
        action = parts[0] if parts else "list"
        rest = parts[1] if len(parts) > 1 else ""

        if action == "add":
            return self._cron_add(rest)
        elif action == "list":
            return self._cron_list()
        elif action == "delete":
            return self._cron_delete(rest.strip())
        elif action == "clear":
            return self._cron_clear()
        else:
            return (
                "usage: /hub cron add|list|delete|clear\n"
                "  add <target> <interval> <message>\n"
                "  list\n"
                "  delete <id>\n"
                "  clear"
            )

    def _cron_add(self, args: str) -> str:
        """Add a new cron job: /hub cron add <target> <interval> <message>."""
        parts = args.strip().split(maxsplit=2)
        if len(parts) < 3:
            return "usage: /hub cron add <target> <interval> <message>"

        target, interval_str, message = parts

        try:
            interval_seconds = _parse_interval(interval_str)
        except ValueError as e:
            return f"bad interval: {e}"

        job_id = uuid.uuid4().hex[:8]
        job = HubCronJob(
            id=job_id,
            target=target,
            message=message,
            interval_seconds=interval_seconds,
            next_fire=time.time() + interval_seconds,
        )
        self._hub_cron_jobs.append(job)
        logger.info(
            f"hub cron job created: {job_id} every {interval_str} -> {target}"
        )
        return f"cron job {job_id} created: every {interval_str} -> {target}"

    def _cron_list(self) -> str:
        """List all active cron jobs."""
        if not self._hub_cron_jobs:
            return "hub cron: no jobs"

        lines = [f"hub cron: {len(self._hub_cron_jobs)} job(s)"]
        now = time.time()
        for job in self._hub_cron_jobs:
            remaining = max(0, job.next_fire - now)
            mode = "recurring" if job.recurring else "one-shot"
            lines.append(
                f"  [{job.id}] -> {job.target} ({mode})"
                f" every {self._format_seconds(job.interval_seconds)}"
                f" | next in {self._format_seconds(remaining)}"
            )
            lines.append(f"    msg: {job.message[:80]}")
        return "\n".join(lines)

    def _cron_delete(self, job_id: str) -> str:
        """Delete a cron job by id."""
        if not job_id:
            return "usage: /hub cron delete <id>"

        before = len(self._hub_cron_jobs)
        self._hub_cron_jobs = [
            j for j in self._hub_cron_jobs if j.id != job_id
        ]
        if len(self._hub_cron_jobs) < before:
            logger.info(f"hub cron job deleted: {job_id}")
            return f"cron job {job_id} deleted"
        return f"cron job {job_id} not found"

    def _cron_clear(self) -> str:
        """Remove all cron jobs."""
        count = len(self._hub_cron_jobs)
        self._hub_cron_jobs.clear()
        logger.info(f"hub cron cleared: {count} job(s) removed")
        return f"hub cron cleared: {count} job(s) removed"

    # -- Task commands --

    async def _handle_tasks_command(self, args: str) -> str:
        """Handle /hub tasks subcommands."""
        if not self._task_ledger:
            return "task ledger not initialized"

        parts = args.strip().split(maxsplit=1)
        action = parts[0] if parts else "list"
        rest = parts[1] if len(parts) > 1 else ""

        if action == "list":
            return self._tasks_list()
        elif action == "mine":
            return self._tasks_mine()
        elif action == "assign":
            return self._tasks_assign(rest)
        elif action == "cancel":
            return self._tasks_cancel(rest.strip())
        elif action == "status":
            return self._tasks_status(rest.strip())
        else:
            return (
                "usage: /hub tasks list|mine|assign|cancel|status\n"
                "  list                        all tasks\n"
                "  mine                        my active tasks\n"
                "  assign <agent> <directive>  assign task\n"
                "  cancel <id>                 cancel a task\n"
                "  status <id>                 task details"
            )

    def _tasks_list(self) -> str:
        """List all tasks."""
        all_tasks = self._task_ledger.get_all()
        if not all_tasks:
            return "no tasks"

        lines = [f"tasks: {len(all_tasks)} total"]
        for card in sorted(all_tasks, key=lambda c: -c.priority):
            status_icon = {
                "active": ">>",
                "paused": "||",
                "done": "ok",
                "failed": "!!",
                "qa_review": "QA",
                "closed": "--",
            }.get(card.status, "??")
            lines.append(
                f"  [{status_icon}] {card.id}"
                f" {card.assigner}->{card.assignee}"
                f" p{card.priority}"
                f" {card.elapsed_str()}"
            )
            lines.append(f"       {card.directive[:70]}")
        return "\n".join(lines)

    def _tasks_mine(self) -> str:
        """Show my active tasks."""
        if not self._identity:
            return "not connected to hub"
        my_tasks = self._task_ledger.get_active_for(
            self._identity.designation
        )
        if not my_tasks:
            return "no active tasks assigned to you"

        lines = [f"your tasks: {len(my_tasks)}"]
        for card in my_tasks:
            lines.append(
                f"  [{card.id}] p{card.priority}"
                f" from {card.assigner}"
                f" ({card.elapsed_str()})"
            )
            lines.append(f"    directive: {card.directive[:70]}")
            if card.deliverable:
                lines.append(
                    f"    deliverable: {card.deliverable[:70]}"
                )
            if card.last_checkpoint_note():
                lines.append(
                    f"    last checkpoint:"
                    f" {card.last_checkpoint_note()[:60]}"
                )
            if card.status == "qa_review":
                lines.append("    status: awaiting QA review")
        return "\n".join(lines)

    def _tasks_assign(self, args: str) -> str:
        """Assign a task: /hub tasks assign <agent> <directive>."""
        parts = args.strip().split(maxsplit=1)
        if len(parts) < 2:
            return "usage: /hub tasks assign <agent> <directive>"

        assignee = parts[0]
        directive = parts[1]

        if not self._identity:
            return "not connected to hub"

        card = self._task_ledger.create(
            assigner=self._identity.designation,
            assignee=assignee,
            directive=directive,
        )
        return (
            f"task {card.id} assigned to {assignee}:"
            f" {directive[:60]}"
        )

    def _tasks_cancel(self, task_id: str) -> str:
        """Cancel a task by id."""
        if not task_id:
            return "usage: /hub tasks cancel <id>"

        if self._task_ledger.cancel(task_id):
            return f"task {task_id} cancelled"
        return f"task {task_id} not found"

    def _tasks_status(self, task_id: str) -> str:
        """Show detailed task status."""
        if not task_id:
            return "usage: /hub tasks status <id>"

        card = self._task_ledger.get(task_id)
        if not card:
            return f"task {task_id} not found"

        lines = [
            f"task {card.id}:",
            f"  status:      {card.status}",
            f"  assigner:    {card.assigner}",
            f"  assignee:    {card.assignee}",
            f"  directive:   {card.directive}",
            f"  deliverable: {card.deliverable or '(none)'}",
            f"  report to:   {card.report_to}",
            f"  priority:    {card.priority}",
            f"  elapsed:     {card.elapsed_str()}",
            f"  cron:        {'active' if card.cron_active else 'off'}"
            f" ({int(card.cron_interval)}s interval)",
        ]
        if card.timeout_seconds:
            lines.append(
                f"  timeout:     {int(card.timeout_seconds)}s"
                f" ({'EXPIRED' if card.is_timed_out() else 'ok'})"
            )
        if card.checkpoints:
            lines.append(
                f"  checkpoints: {len(card.checkpoints)}"
            )
            for cp in card.checkpoints[-3:]:
                lines.append(f"    - {cp.get('note', '')[:60]}")
        if card.result:
            lines.append(f"  result:      {card.result[:100]}")
        if card.error:
            lines.append(f"  error:       {card.error[:100]}")
        if card.qa_reviewer:
            passed = "PASSED" if card.qa_passed else "REJECTED"
            lines.append(
                f"  qa:          {passed} by {card.qa_reviewer}"
            )
            if card.qa_notes:
                lines.append(
                    f"  qa notes:    {card.qa_notes[:60]}"
                )
        return "\n".join(lines)

    # -- Notify commands --

    async def _handle_notify_command(self, args: str) -> str:
        """Handle /hub notify subcommands."""
        parts = args.strip().split(maxsplit=1)
        action = parts[0] if parts else "status"
        rest = parts[1] if len(parts) > 1 else ""

        if action == "enable":
            return self._notify_set_enabled(True)
        elif action == "disable":
            return self._notify_set_enabled(False)
        elif action == "channel":
            return self._notify_set_channel(rest.strip())
        elif action == "url":
            return self._notify_set_url(rest.strip())
        elif action == "threshold":
            return self._notify_set_threshold(rest.strip())
        elif action == "test":
            return await self._notify_test()
        elif action == "status":
            return self._notify_status()
        else:
            return (
                "usage: /hub notify enable|disable|channel|url|threshold|test|status\n"
                "  enable          enable notifications\n"
                "  disable         disable notifications\n"
                "  channel <name>  set channel (webhook, telegram)\n"
                "  url <url>       set webhook url\n"
                "  threshold <sec> set idle threshold in seconds\n"
                "  test            send a test notification\n"
                "  status          show current config"
            )

    def _notify_set_enabled(self, enabled: bool) -> str:
        """Enable or disable the notification loop at runtime."""
        if not self.config:
            return "config not available"

        self.config.setdefault("plugins", {}).setdefault("hub", {})
        self.config["plugins"]["hub"]["notify_enabled"] = enabled

        if enabled and not self._notify_task:
            # Start the loop
            if not self._notifier:
                self._notifier = HubNotifier(
                    config=self.config,
                    get_designation=lambda: (
                        self._identity.designation if self._identity else ""
                    ),
                    get_last_activity=lambda: self._last_activity_at,
                    get_state=lambda: (
                        self._identity.state if self._identity else "unknown"
                    ),
                )
            self._notify_task = asyncio.create_task(self._notifier.run())
            return "notifications enabled (loop started)"
        elif not enabled and self._notify_task:
            # Stop the loop
            self._notify_task.cancel()
            self._notify_task = None
            self._notifier = None
            return "notifications disabled (loop stopped)"

        return f"notifications {'enabled' if enabled else 'disabled'}"

    def _notify_set_channel(self, channel: str) -> str:
        """Set the notification channel."""
        if not channel:
            return "usage: /hub notify channel <webhook|telegram>"
        if channel not in ("webhook", "telegram"):
            return f"unknown channel '{channel}'. use: webhook, telegram"

        if not self.config:
            return "config not available"
        self.config.setdefault("plugins", {}).setdefault("hub", {})
        self.config["plugins"]["hub"]["notify_channel"] = channel

        # Rebuild backend if notifier exists
        if self._notifier:
            self._notifier._backend = self._notifier._build_backend()

        return f"notification channel set to {channel}"

    def _notify_set_url(self, url: str) -> str:
        """Set the webhook url."""
        if not url:
            return "usage: /hub notify url <https://...>"

        if not self.config:
            return "config not available"
        self.config.setdefault("plugins", {}).setdefault("hub", {})
        self.config["plugins"]["hub"]["notify_url"] = url

        # Rebuild backend if notifier exists
        if self._notifier:
            self._notifier._backend = self._notifier._build_backend()

        return "webhook url set"

    def _notify_set_threshold(self, value: str) -> str:
        """Set the idle notification threshold in seconds."""
        if not value:
            return "usage: /hub notify threshold <seconds>"
        try:
            seconds = int(value)
            if seconds < 30:
                return "threshold must be at least 30 seconds"
        except ValueError:
            return f"invalid threshold: {value}"

        if not self.config:
            return "config not available"
        self.config.setdefault("plugins", {}).setdefault("hub", {})
        self.config["plugins"]["hub"]["notify_idle_threshold"] = seconds

        return f"notify idle threshold set to {self._format_seconds(seconds)}"

    async def _notify_test(self) -> str:
        """Send a test notification through the configured backend."""
        if not self.config:
            return "config not available"

        hub_cfg = self.config.get("plugins", {}).get("hub", {})
        channel = hub_cfg.get("notify_channel", "webhook")

        # Build a temporary backend for testing
        backend = None
        if channel == "webhook":
            url = hub_cfg.get("notify_url", "")
            if not url:
                return "no webhook url configured. use: /hub notify url <url>"
            from .notifier import WebhookNotifier
            backend = WebhookNotifier(url)
        elif channel == "telegram":
            token = hub_cfg.get("notify_telegram_token", "")
            chat_id = hub_cfg.get("notify_telegram_chat_id", "")
            if not token or not chat_id:
                return "telegram token/chat_id not configured"
            from .notifier import TelegramNotifier
            backend = TelegramNotifier(token, chat_id)
        else:
            return f"unknown channel: {channel}"

        designation = self._identity.designation if self._identity else "unknown"
        try:
            await backend.send(
                f"[test] agent '{designation}' notification test",
                {"test": True, "designation": designation},
            )
            return f"test notification sent via {channel}"
        except Exception as e:
            return f"test notification failed: {e}"

    def _notify_status(self) -> str:
        """Show current notification configuration."""
        if not self.config:
            return "config not available"

        hub_cfg = self.config.get("plugins", {}).get("hub", {})
        enabled = hub_cfg.get("notify_enabled", False)
        channel = hub_cfg.get("notify_channel", "webhook")
        url = hub_cfg.get("notify_url", "")
        threshold = hub_cfg.get("notify_idle_threshold", 300)
        cooldown = hub_cfg.get("notify_cooldown", 1800)

        loop_status = "running" if self._notify_task else "stopped"
        url_display = url[:50] + "..." if len(url) > 50 else (url or "(not set)")

        return (
            f"notifications: {'enabled' if enabled else 'disabled'}\n"
            f"  loop: {loop_status}\n"
            f"  channel: {channel}\n"
            f"  url: {url_display}\n"
            f"  idle threshold: {self._format_seconds(threshold)}\n"
            f"  cooldown: {self._format_seconds(cooldown)}"
        )

    @staticmethod
    def _format_seconds(s: float) -> str:
        """Format seconds as human-readable string."""
        s = int(s)
        if s < 60:
            return f"{s}s"
        elif s < 3600:
            m, sec = divmod(s, 60)
            return f"{m}m{sec}s" if sec else f"{m}m"
        else:
            h, remainder = divmod(s, 3600)
            m = remainder // 60
            return f"{h}h{m}m" if m else f"{h}h"

    # -- Bridge commands --

    async def _handle_bridge_command(self, args: str) -> str:
        """Handle /hub bridge subcommands."""
        parts = args.strip().split(maxsplit=1)
        action = parts[0] if parts else "status"
        rest = parts[1] if len(parts) > 1 else ""

        if action == "status":
            return self._bridge_status()
        elif action == "send":
            if not rest.strip():
                return "usage: /hub bridge send <message>"
            return await self._bridge_send_command(rest.strip())
        elif action == "enable":
            return await self._bridge_set_enabled(True)
        elif action == "disable":
            return await self._bridge_set_enabled(False)
        elif action == "setup":
            return await self._bridge_setup()
        else:
            return (
                "usage: /hub bridge status|send|enable|disable|setup\n"
                "  status          show bridge connection state\n"
                "  send <message>  send message through bridge\n"
                "  enable          start the bridge loop\n"
                "  disable         stop the bridge loop\n"
                "  setup           auto-detect config and send test message"
            )

    def _bridge_status(self) -> str:
        """Show current bridge configuration and state."""
        if not self.config:
            return "config not available"

        enabled = self.config.get("plugins.hub.bridge_enabled", False)
        platform = self.config.get("plugins.hub.bridge_platform", "telegram")
        has_token = bool(self.config.get("plugins.hub.bridge_token", ""))
        has_chat = bool(self.config.get("plugins.hub.bridge_chat_id", ""))
        target = self.config.get("plugins.hub.bridge_target_agent", "") or "self"
        poll_interval = self.config.get("plugins.hub.bridge_poll_interval", 2)
        loop_status = "running" if self._bridge_task else "stopped"
        connected = "yes" if self._bridge else "no"

        return (
            f"messaging bridge: {'enabled' if enabled else 'disabled'}\n"
            f"  loop: {loop_status}\n"
            f"  connected: {connected}\n"
            f"  platform: {platform}\n"
            f"  token: {'set' if has_token else '(not set)'}\n"
            f"  chat_id: {'set' if has_chat else '(not set)'}\n"
            f"  target: {target}\n"
            f"  poll interval: {poll_interval}s"
        )

    async def _bridge_setup(self) -> str:
        """Auto-detect bridge config from env vars and send test message."""
        import os as _os

        lines = ["bridge setup:"]

        # Check env vars
        token = _os.environ.get("KOLLABOR_HUB_BRIDGE_TOKEN", "")
        chat_id = _os.environ.get("KOLLABOR_HUB_BRIDGE_CHAT_ID", "")

        if not token:
            token = (self.config.get("plugins.hub.bridge_token", "") if self.config else "")
        if not chat_id:
            chat_id = (self.config.get("plugins.hub.bridge_chat_id", "") if self.config else "")

        if not token:
            lines.append("  no bot token found.")
            lines.append("")
            lines.append("  quick start:")
            lines.append("  1. open telegram, talk to @BotFather")
            lines.append("  2. /newbot -> pick a name -> get the token")
            lines.append("  3. export KOLLABOR_HUB_BRIDGE_TOKEN=your-token")
            lines.append("  4. talk to @userinfobot to get your chat ID")
            lines.append("  5. export KOLLABOR_HUB_BRIDGE_CHAT_ID=your-chat-id")
            lines.append("  6. run /hub bridge setup again")
            return "\n".join(lines)

        if not chat_id:
            lines.append(f"  token: found ({token[:8]}...)")
            lines.append("  chat_id: missing")
            lines.append("  talk to @userinfobot on telegram to get your chat ID")
            lines.append("  then: export KOLLABOR_HUB_BRIDGE_CHAT_ID=your-chat-id")
            return "\n".join(lines)

        lines.append(f"  token: {token[:8]}...")
        lines.append(f"  chat_id: {chat_id}")
        lines.append("  platform: telegram")

        # Save to config
        if self.config:
            self.config.set("plugins.hub.bridge_token", token)
            self.config.set("plugins.hub.bridge_chat_id", chat_id)
            self.config.set("plugins.hub.bridge_platform", "telegram")
            self.config.set("plugins.hub.bridge_enabled", True)

        # Try to connect and send test message
        try:
            from .messaging_bridge import TelegramBridge

            bridge = TelegramBridge(token=token, chat_id=chat_id)
            connected = await bridge.connect()
            if connected:
                designation = self._identity.designation if self._identity else "kollabor"
                ok = await bridge.send(
                    f"kollabor bridge setup complete. agent '{designation}' can now reach you here.",
                )
                await bridge.disconnect()
                if ok:
                    lines.append("  test message: sent (check your telegram)")
                    lines.append("")
                    lines.append("  bridge is configured. run /hub bridge enable to start.")
                else:
                    lines.append("  test message: failed to send")
            else:
                lines.append("  connection failed. check your bot token.")
        except Exception as e:
            lines.append(f"  error: {e}")

        return "\n".join(lines)

    async def _bridge_send_command(self, text: str) -> str:
        """Send a message through the active bridge."""
        if not self._bridge:
            return "bridge not connected. enable it first: /hub bridge enable"
        success = await self.bridge_send(text)
        if success:
            return f"sent via {self._bridge.name}"
        else:
            return "bridge send failed (check logs)"

    async def _bridge_set_enabled(self, enabled: bool) -> str:
        """Enable or disable the messaging bridge at runtime."""
        if enabled and not self._bridge_task:
            # Validate config before starting
            if not self.config:
                return "config not available"
            token = self.config.get("plugins.hub.bridge_token", "")
            chat_id = self.config.get("plugins.hub.bridge_chat_id", "")
            if not token or not chat_id:
                return "cannot enable: bridge_token and bridge_chat_id required"

            if self.config:
                self.config.set("plugins.hub.bridge_enabled", True)
            self._bridge_task = asyncio.create_task(
                self._messaging_bridge_loop()
            )
            return "messaging bridge enabled and starting"

        elif not enabled and self._bridge_task:
            if self.config:
                self.config.set("plugins.hub.bridge_enabled", False)
            self._bridge_task.cancel()
            try:
                await self._bridge_task
            except asyncio.CancelledError:
                pass
            self._bridge_task = None
            return "messaging bridge disabled"

        elif enabled:
            return "messaging bridge is already running"
        else:
            return "messaging bridge is already stopped"

    def _format_status(self) -> str:
        """Format hub status display."""
        if not self._identity:
            return "hub: not connected"

        assert self._presence is not None
        agents = self._presence.discover_agents(include_self=True)
        lines = [f"hub: {len(agents)} agent(s) online"]
        lines.append("")
        for a in agents:
            role = " (coordinator)" if a.is_coordinator else ""
            me = " (you)" if a.agent_id == self._identity.agent_id else ""
            task = f" - {a.current_task[:50]}" if a.current_task else ""
            lines.append(f"  {a.designation}{role}{me}: {a.state}{task}")

        pending = self._work_queue.get_pending() if self._work_queue else []
        if pending:
            lines.append(f"\nwork queue: {len(pending)} pending")
            for slot in pending[:5]:
                lines.append(f"  [{slot.id}] {slot.task[:60]}")

        return "\n".join(lines)

    def _format_whoami(self) -> str:
        if not self._identity:
            return "hub: not connected"
        role = "coordinator" if self._identity.is_coordinator else "agent"
        return (
            f"designation: {self._identity.designation}\n"
            f"role: {role}\n"
            f"agent_id: {self._identity.agent_id}\n"
            f"pid: {self._identity.pid}\n"
            f"project: {self._identity.project}"
        )

    async def _handle_msg_command(self, args: str) -> str:
        parts = args.split(maxsplit=1)
        if len(parts) < 2:
            return "usage: /hub msg <agent> <message>"
        target, content = parts
        msg = HubMessage(
            action="message",
            from_agent=self._identity.agent_id if self._identity else "",
            from_designation=self._identity.designation if self._identity else "",
            to=target,
            content=content,
            scope=self._resolve_scope(target),
        )
        await self._route_message(msg)
        return f"sent to {target}"

    async def _handle_broadcast_command(self, content: str) -> str:
        if not content:
            return "usage: /hub broadcast <message>"
        msg = HubMessage(
            action="message",
            from_agent=self._identity.agent_id if self._identity else "",
            from_designation=self._identity.designation if self._identity else "",
            to="*",
            content=content,
            scope=MessageScope.BROADCAST.value,
        )
        await self._route_message(msg)
        assert self._presence is not None
        agents = self._presence.discover_agents()
        return f"broadcast to {len(agents)} agent(s)"

    def _format_work(self) -> str:
        if not self._work_queue:
            return "work queue: unavailable"
        slots = self._work_queue.get_all()
        if not slots:
            return "work queue: empty"
        lines = [f"work queue: {len(slots)} slot(s)"]
        for s in slots:
            assigned = f" -> {s.assigned_to}" if s.assigned_to else ""
            lines.append(f"  [{s.id}] {s.status}{assigned}: {s.task[:60]}")
        return "\n".join(lines)

    def _handle_queue_command(self, task: str) -> str:
        if not task:
            return "usage: /hub queue <task description>"
        if not self._work_queue:
            return "work queue: unavailable"
        slot = self._work_queue.add(
            task=task,
            project=self._identity.project if self._identity else "",
            queued_by=self._identity.designation if self._identity else "",
        )
        return f"queued: [{slot.id}] {task[:60]}"

    async def _handle_claim_command(self, slot_id: str) -> str:
        if not self._work_queue or not self._identity:
            return "work queue: unavailable"
        rest = slot_id.strip()
        if rest:
            slot = self._work_queue.claim_by_id(
                rest,
                self._identity.designation,
            )
            if slot:
                return f"claimed: [{slot.id}] {slot.task[:60]}"
            return f"slot '{rest}' not found or not pending"
        slot = self._work_queue.claim_next(
            self._identity.designation,
            self._identity.project,
        )
        if slot:
            return f"claimed: [{slot.id}] {slot.task[:60]}"
        return "no available work to claim"

    def _format_vault(self, name: str = "") -> str:
        """Show vault info for an agent."""
        name = name.strip() or (self._identity.designation if self._identity else "")
        if not name:
            return "usage: /hub vault <designation>"
        vault = AgentVault(name)
        if not vault.exists():
            return f"no vault for '{name}'"
        info = vault.get_summary()
        return (
            f"vault: {info['designation']}\n"
            f"  sessions: {info['session_count']}\n"
            f"  stream entries: {info['stream_entries']}\n"
            f"  last active: {info['last_active']}\n"
            f"  has working memory: {info['has_working_memory']}\n"
            f"  has crystallized: {info['has_crystallized']}"
        )

    def _format_all_vaults(self) -> str:
        """List all agent vaults."""
        from .vault import get_vault_summaries

        summaries = get_vault_summaries()
        if not summaries:
            return "no vaults yet"
        lines = [f"vaults: {len(summaries)} agent(s)"]
        for s in summaries:
            lines.append(
                f"  {s['designation']:12} sessions={s['session_count']} "
                f"entries={s['stream_entries']} last={s['last_active']}"
            )
        return "\n".join(lines)

    async def _handle_org_command(self, args: str) -> str:
        """Launch an organization."""
        from .org_launcher import OrgLauncher, get_org_agents, load_organization

        parts = args.strip().split(maxsplit=1)
        if not parts:
            return "usage: /hub org <name> [mission]"

        org_name = parts[0]
        mission = parts[1] if len(parts) > 1 else ""

        org = load_organization(org_name)
        if not org:
            return f"organization '{org_name}' not found. try /hub orgs"

        agents = get_org_agents(org)

        launcher = OrgLauncher(self._identity.project if self._identity else "")
        count, launched = launcher.launch_org(org_name, mission)

        lines = [f"launched org '{org_name}': {count} agents"]
        for a in agents:
            level = a.get("level", "member")
            role = a.get("role", "")
            team = a.get("team", "")
            indent = (
                "  "
                if level == "director"
                else "    " if level == "manager" else "      "
            )
            team_str = f" [{team}]" if team else ""
            lines.append(f"{indent}{a['designation']}: {role}{team_str}")

        if mission:
            lines.append(f"\nmission: {mission}")

        return "\n".join(lines)

    def _format_orgs(self) -> str:
        """List available organizations."""
        from .org_launcher import list_organizations

        orgs = list_organizations()
        if not orgs:
            return "no organizations found"

        lines = [f"organizations: {len(orgs)} available"]
        for org in orgs:
            source = f" ({org['source']})" if org.get("source") == "user" else ""
            lines.append(f"  {org['name']}{source}: {org['description']}")
        lines.append("\nusage: /hub org <name> [mission]")
        lines.append("custom: create JSON in ~/.kollabor-cli/hub/organizations/")
        return "\n".join(lines)

    async def _handle_feed_command(self) -> str:
        """Open live hub feed dashboard via AltView stack."""
        if not self.event_bus:
            return "event bus not available"

        try:
            from plugins.altview.hub_feed_altview import HubFeedAltView

            # Get or create the altview stack manager
            stack_mgr = None
            try:
                stack_mgr = self.event_bus.get_service("altview_stack_manager")
            except Exception:
                pass

            if not stack_mgr:
                from kollabor_tui.altview.stack_manager import AltViewStackManager

                renderer = self.event_bus.get_service("renderer")
                stack_mgr = AltViewStackManager(self.event_bus, renderer)
                self.event_bus.register_service("altview_stack_manager", stack_mgr)

            altview = HubFeedAltView()
            await stack_mgr.push(altview, "hub-feed")

            return ""

        except Exception as e:
            return f"feed error: {e}"

    async def _handle_console_command(self) -> str:
        """Open hub console (sidebar + feed) via AltView stack."""
        if not self.event_bus:
            return "event bus not available"

        try:
            from plugins.altview.hub_console_altview import HubConsoleAltView

            stack_mgr = None
            try:
                stack_mgr = self.event_bus.get_service("altview_stack_manager")
            except Exception:
                pass

            if not stack_mgr:
                from kollabor_tui.altview.stack_manager import AltViewStackManager

                renderer = self.event_bus.get_service("renderer")
                stack_mgr = AltViewStackManager(self.event_bus, renderer)
                self.event_bus.register_service("altview_stack_manager", stack_mgr)

            altview = HubConsoleAltView()
            altview.set_event_bus(self.event_bus)
            await stack_mgr.push(altview, "hub-console")

            return ""

        except Exception as e:
            return f"console error: {e}"

    def _is_enabled(self) -> bool:
        if self.config:
            return bool(self.config.get("plugins.hub.enabled", True))
        return True

    def get_vault(self):
        """Public accessor for vault (used by trender tags and compaction plugin)."""
        return self._vault

    def get_status_line(self) -> Optional[str]:
        """Status bar widget showing hub state."""
        if not self._identity or not self._started:
            return None

        peers = len(self._roster)
        role = "hub" if self._identity.is_coordinator else "mesh"
        return f"{role}:{self._identity.designation} +{peers}"

    async def _on_remote_shutdown(self, reason: str = "") -> None:
        """Handle shutdown signal received via hub socket.

        Triggers the app's graceful shutdown so the terminal
        gets properly restored from raw mode.
        """
        logger.info(f"Remote shutdown received: {reason}")
        try:
            # Signal the app to exit gracefully via the same mechanism as ctrl+c
            import signal

            os.kill(os.getpid(), signal.SIGINT)
        except Exception as e:
            logger.error(f"Failed to trigger shutdown: {e}")
            # Fallback: try to set running flag directly
            try:
                app = self.event_bus.get_service("app") if self.event_bus else None
                if app and hasattr(app, "running"):
                    app.running = False
            except Exception:
                pass

    async def shutdown(self) -> None:
        """Clean shutdown - save vault, remove presence, close socket, release lock."""
        # Guard against re-entry (SIGINT can fire multiple times during shutdown)
        if getattr(self, "_shutdown_in_progress", False):
            return
        self._shutdown_in_progress = True

        # Save working memory before dying
        if self._vault and self._identity:
            try:
                recent = self._vault.get_recent_stream(50)
                peers = (
                    [a.designation for a in self._presence.discover_agents()]
                    if self._presence
                    else []
                )
                self._vault.update_working_memory(
                    recent, self._identity.current_task, peers
                )
                self._vault.append_stream(
                    "session_end",
                    f"agent {self._identity.designation} shutting down",
                    from_agent=self._identity.designation,
                )
                self._vault.touch()
            except Exception as e:
                logger.error(f"Vault save on shutdown failed: {e}")
        if self._heartbeat_task:
            self._heartbeat_task.cancel()
            try:
                await self._heartbeat_task
            except asyncio.CancelledError:
                pass

        if self._mailbox_task:
            self._mailbox_task.cancel()
            try:
                await self._mailbox_task
            except asyncio.CancelledError:
                pass

        if self._dreaming_task:
            self._dreaming_task.cancel()
            try:
                await self._dreaming_task
            except asyncio.CancelledError:
                pass

        if self._notify_task:
            self._notify_task.cancel()
            try:
                await self._notify_task
            except asyncio.CancelledError:
                pass

        if self._cron_task:
            self._cron_task.cancel()
            try:
                await self._cron_task
            except asyncio.CancelledError:
                pass

        if self._autosave_task:
            self._autosave_task.cancel()
            try:
                await self._autosave_task
            except asyncio.CancelledError:
                pass

        if self._bridge_task:
            self._bridge_task.cancel()
            try:
                await self._bridge_task
            except asyncio.CancelledError:
                pass

        # Announce departure to peers before removing presence.
        # Each peer gets a unique HubMessage (unique id) so that
        # the dedup in _on_message_received can catch socket+file
        # double-delivery without id collisions across peers.
        # Only announce departure if the hub fully started (has designation + presence)
        if self._started and self._presence and self._identity and self._identity.designation:
            # Forward own departure to bridge
            await self._bridge_forward(
                f"[hub] {self._identity.designation} is going offline"
            )
            try:
                peers = self._presence.discover_agents()
                # Deduplicate by designation (stale presence files can cause duplicates)
                seen_designations = set()
                for peer in peers:
                    desig = peer.designation
                    if desig in seen_designations:
                        continue
                    seen_designations.add(desig)
                    try:
                        departure = HubMessage(
                            action="message",
                            from_agent=self._identity.agent_id,
                            from_designation=self._identity.designation,
                            to=desig,
                            content=f"agent '{self._identity.designation}' is going offline.",
                            scope=MessageScope.DIRECT.value,
                        )
                        await self._deliver_to_agent(peer, departure)
                    except Exception:
                        pass
            except Exception:
                pass

        if self._presence:
            self._presence.remove()

        if self._socket_server:
            await self._socket_server.stop()

        if self._election:
            self._election.release()

        # Clean up message directory
        if self._identity:
            msg_dir = get_messages_dir() / self._identity.agent_id
            if msg_dir.exists():
                try:
                    for f in msg_dir.glob("*"):
                        f.unlink()
                    msg_dir.rmdir()
                except Exception:
                    pass

        logger.info("Hub plugin shut down")
