"""FastAPI app factory for Beaver."""

from __future__ import annotations

import json
import asyncio
import io
import mimetypes
import os
import re
import secrets
import shutil
import time
import zipfile
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from types import SimpleNamespace
from typing import Any

from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config
from beaver.foundation.events import ChannelIdentity, InboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.interfaces.channels.connections import (
    ChannelConnectionStore,
    ChannelConnectorRegistry,
    ConnectorSidecarClient,
    CredentialStore,
    FeishuConnector,
    MessageDedupeStore,
    TelegramConnector,
    WeixinConnector,
)
from beaver.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
from beaver.services.cron_service import CronService, schedule_from_api
from beaver.services.skillhub_service import SkillHubService
from beaver.services.user_files import (
    USER_FILE_ROOTS,
    UserFileError,
    UserFileNotFoundError,
    UserFilePathError,
    UserFileSizeError,
    UserFileService,
)
from beaver.services.user_file_resolver import (
    UserFileConfigurationError,
    UserFileStorageResolver,
    build_file_auth_context,
)
from beaver.skills.authoring import canonical_skill_format_instructions, ensure_canonical_skill_body, normalize_skill_frontmatter
from beaver.skills.authoring.format import parse_skill_rewrite_json
from beaver.skills.learning import SkillLearningService, SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.learning.replay import ReplayRunner
from beaver.skills.catalog.utils import extract_required_tool_names, parse_frontmatter

from .deps import get_agent_service
from .files import (
    browse_workspace,
    content_disposition,
    create_workspace_dir,
    delete_file,
    delete_workspace_path,
    generate_file_id,
    get_file_metadata,
    get_file_path,
    list_files,
    save_file,
    save_to_workspace,
    workspace_file_preview,
    workspace_file_path,
)
from .schemas import (
    WebChatAcceptanceRequest,
    WebChatAcceptanceResponse,
    WebChatFeedbackRequest,
    WebChatFeedbackResponse,
    WebChatRequest,
    WebChatResponse,
    WebErrorResponse,
    WebAgentConfigRequest,
    WebAgentConfigResponse,
    WebChannelConfigRequest,
    WebChannelConfigResponse,
    WebChannelConnectionCreateRequest,
    WebChannelConnectionResponse,
    WebChannelConnectionUpdateRequest,
    WebChannelValidationResponse,
    WebConnectorBridgeEventRequest,
    WebConnectorBridgeEventResponse,
    WebConnectorSessionCreateRequest,
    WebConnectorSessionResponse,
    WebProviderConfigRequest,
    WebProviderConfigResponse,
    WebStatusResponse,
)

try:
    from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
    from fastapi.middleware.cors import CORSMiddleware
    from fastapi.responses import JSONResponse, Response
except ModuleNotFoundError:  # pragma: no cover - fallback for skeleton-only environments
    CORSMiddleware = None  # type: ignore[assignment]

    def File(default: Any = None) -> Any:  # type: ignore[override]
        return default

    def Form(default: Any = None) -> Any:  # type: ignore[override]
        return default

    def Header(default: Any = None) -> Any:  # type: ignore[override]
        return default

    class HTTPException(Exception):
        """Minimal fallback exception matching FastAPI's constructor shape."""

        def __init__(self, status_code: int, detail: str) -> None:
            super().__init__(detail)
            self.status_code = status_code
            self.detail = detail

    class Request:  # type: ignore[override]
        """Fallback request shim used only for import-time compatibility."""

        def __init__(self, app: Any) -> None:
            self.app = app

    class UploadFile:  # type: ignore[override]
        filename: str | None

    class Response:  # type: ignore[override]
        def __init__(self, content: bytes, media_type: str | None = None, headers: dict[str, str] | None = None) -> None:
            self.content = content
            self.media_type = media_type
            self.headers = headers or {}

    class JSONResponse(Response):  # type: ignore[override]
        def __init__(self, content: Any, status_code: int = 200) -> None:
            super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json")
            self.status_code = status_code

    class WebSocketDisconnect(Exception):
        """Fallback websocket disconnect exception."""

    class WebSocket:  # type: ignore[override]
        """Fallback websocket shim used only so annotations import."""

        app: Any

    class FastAPI:  # type: ignore[override]
        """Small fallback shim so the package can import before dependencies are installed."""

        def __init__(self, *, title: str, lifespan: Callable[..., Any] | None = None) -> None:
            self.title = title
            self.lifespan = lifespan
            self.state = SimpleNamespace()

        def get(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def post(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def patch(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator

        def websocket(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
            def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
                return func

            return decorator


RAW_TOOL_CALL_DISPLAY_FALLBACK = (
    "The run reached the configured tool-call limit before producing a reliable final answer. "
    "The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
    "Please request a revision to continue the task."
)


@asynccontextmanager
async def _app_lifespan(
    app: FastAPI,
    *,
    workspace: str | Path | None,
    config_path: str | Path | None,
    service: AgentService | None,
    manage_service_lifecycle: bool | None,
    shutdown_timeout_seconds: float | None,
    shutdown_force: bool,
) -> AsyncIterator[None]:
    """把 Web app 接到 AgentService lifecycle 上。"""

    attached_service = service or AgentService(workspace=workspace, config_path=config_path)
    owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
    app.state.agent_service = attached_service
    app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
    app.state.channel_runtime = None
    started = False
    channel_runtime: ChannelRuntime | None = None
    if owns_service:
        try:
            await attached_service.start()
            await app.state.cron_service.start()
            started = True
        except BaseException:
            with suppress(BaseException):
                app.state.cron_service.stop()
                if attached_service.is_running:
                    await attached_service.shutdown(
                        timeout_seconds=shutdown_timeout_seconds,
                        force=shutdown_force,
                    )
                else:
                    attached_service.close()
            raise
    try:
        loaded = attached_service.create_loop().boot()
        app.state.channel_connection_workspace = loaded.workspace
        connector_registry = _build_channel_connector_registry(loaded.workspace)
        app.state.channel_connector_registry = connector_registry
        connection_channels = await connector_registry.materialize_channel_configs()
        runtime_channels = dict(loaded.config.channels)
        runtime_channels.update(connection_channels)
        channel_runtime = ChannelRuntime(
            service=attached_service,
            workspace=loaded.workspace,
            channels=runtime_channels,
        )
        app.state.channel_runtime = channel_runtime
        await channel_runtime.start()
    except BaseException:
        if owns_service and started:
            with suppress(BaseException):
                await attached_service.shutdown(
                    timeout_seconds=shutdown_timeout_seconds,
                    force=shutdown_force,
                )
        raise
    worker: SkillLearningWorker | None = None
    worker_task = None
    worker_config = SkillLearningWorkerConfig.from_env()
    if owns_service and worker_config.enabled:
        loaded = attached_service.create_loop().boot()
        worker = SkillLearningWorker(
            pipeline=loaded.skill_learning_pipeline,  # type: ignore[arg-type]
            provider_bundle_factory=lambda: attached_service._make_provider_bundle_for_task(loaded, {}),  # noqa: SLF001
            replay_runner_factory=lambda: ReplayRunner(agent_loop=attached_service.create_loop()),
            config=worker_config,
        )
        worker_task = asyncio.create_task(worker.run_forever())
        app.state.skill_learning_worker = worker
        app.state.skill_learning_worker_task = worker_task
    try:
        yield
    finally:
        runtime = getattr(app.state, "channel_runtime", None)
        if isinstance(runtime, ChannelRuntime):
            with suppress(BaseException):
                await runtime.stop()
        cron_service = getattr(app.state, "cron_service", None)
        if isinstance(cron_service, CronService):
            cron_service.stop()
        if worker is not None:
            worker.stop()
        if worker_task is not None:
            worker_task.cancel()
            with suppress(BaseException):
                await worker_task
        if owns_service and started:
            await attached_service.shutdown(
                timeout_seconds=shutdown_timeout_seconds,
                force=shutdown_force,
            )


def _build_cron_service(agent_service: AgentService) -> CronService:
    loaded = agent_service.create_loop().boot()

    async def on_job(job: Any, run_record: CronRunRecord) -> CronExecutionResult:
        if getattr(job.payload, "mode", "notification") == "notification":
            result = await agent_service.run_scheduled_notification(
                job.payload.message,
                session_id=NOTIFICATION_SESSION_ID,
                cron_job_id=job.id,
                cron_job_name=job.name,
                scheduled_run_id=run_record.scheduled_run_id,
            )
            return CronExecutionResult(
                response=result.output_text,
                run_id=result.run_id,
                notification_session_id=result.session_id,
                mode="notification",
            )

        session_id = job.payload.session_key or f"cron:{job.id}"
        result = await agent_service.run_scheduled_task(
            job.payload.message,
            session_id=session_id,
            cron_job_id=job.id,
            cron_job_name=job.name,
            scheduled_run_id=run_record.scheduled_run_id,
            requires_followup=bool(getattr(job.payload, "requires_followup", False)),
        )
        return CronExecutionResult(
            response=result.output_text,
            task_id=result.task_id,
            run_id=result.run_id,
            notification_session_id=session_id,
            mode="task",
        )

    service = CronService(loaded.workspace / "cron" / "jobs.json", on_job=on_job)
    agent_service.register_runtime_service("cron_service", service)
    return service


def get_cron_service(request: Request) -> CronService:
    service = getattr(request.app.state, "cron_service", None)
    if isinstance(service, CronService):
        return service
    agent_service = get_agent_service(request)
    service = _build_cron_service(agent_service)
    request.app.state.cron_service = service
    return service


def get_channel_runtime(request: Request) -> ChannelRuntime:
    runtime = getattr(request.app.state, "channel_runtime", None)
    if not isinstance(runtime, ChannelRuntime):
        raise HTTPException(status_code=503, detail="Channel runtime is not running")
    return runtime


def _connection_state_dir(workspace: Path) -> Path:
    return Path(workspace) / "state" / "channel_connections"


def _channel_connection_workspace(request: Request) -> Path:
    workspace = getattr(request.app.state, "channel_connection_workspace", None)
    if workspace is not None:
        return Path(workspace)
    return Path(get_agent_service(request).loader.workspace)


def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
    return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")


def _bridge_token() -> str:
    return os.getenv("BEAVER_BRIDGE_TOKEN", "")


def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
    state_dir = _connection_state_dir(workspace)
    connection_store = ChannelConnectionStore(state_dir / "connections.json")
    credential_store = CredentialStore(state_dir / "credentials.json")
    registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
    registry.register(
        TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
        )
    )
    sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
    sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
    sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
    registry.register(
        WeixinConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            sidecar_client=sidecar_client,
            sidecar_base_url=sidecar_base_url,
        )
    )
    registry.register(
        FeishuConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            sidecar_client=sidecar_client,
            sidecar_base_url=sidecar_base_url,
        )
    )
    return registry


def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
    registry = getattr(request.app.state, "channel_connector_registry", None)
    if isinstance(registry, ChannelConnectorRegistry):
        return registry
    workspace = getattr(request.app.state, "channel_connection_workspace", None)
    if workspace is None:
        raise RuntimeError("Channel connector registry unavailable before service boot")
    registry = _build_channel_connector_registry(workspace)
    request.app.state.channel_connector_registry = registry
    return registry


def _connection_response_view(connection: Any) -> dict[str, Any]:
    view = connection.to_dict()
    view.pop("credentials_ref", None)
    view.pop("connector_ref", None)
    view.pop("pairing_session_id", None)
    return view


def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]:
    result = dict(view)
    metadata = result.get("metadata")
    if isinstance(metadata, dict):
        result["metadata"] = {
            str(key): value
            for key, value in metadata.items()
            if not _is_sensitive_metadata_key(str(key))
        }
    return result


def _is_sensitive_metadata_key(key: str) -> bool:
    lowered = key.lower()
    return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential"))


async def _activate_connected_channel(
    request: Request,
    registry: ChannelConnectorRegistry,
    connection: Any,
) -> Any:
    if connection.status != "connected":
        return connection
    runtime = get_channel_runtime(request)
    config = (await registry.materialize_channel_configs()).get(connection.channel_id)
    if config is not None:
        await runtime.add_channel(connection.channel_id, config)
    return registry.connection_store.get(connection.connection_id)


def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
    if not isinstance(config, dict):
        return {}
    return {
        _camel_to_snake_text(str(key)): value
        for key, value in config.items()
        if str(key).strip()
    }


def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None:
    if connection.status == "revoked":
        raise HTTPException(status_code=404, detail="Channel connection not found")
    if connection.status not in {"connected", "running"}:
        raise HTTPException(status_code=409, detail="Channel connection is not connected")
    mismatches: list[str] = []
    if payload.channel_id != connection.channel_id:
        mismatches.append("channelId")
    if payload.kind != connection.kind:
        mismatches.append("kind")
    if payload.account_id != connection.account_id:
        mismatches.append("accountId")
    if mismatches:
        raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}")
    content = payload.content.strip()
    if not content:
        raise HTTPException(status_code=400, detail="Bridge event content is required")
    max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000)
    if len(content) > max_chars:
        raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})")


def _positive_int(value: Any, *, default: int) -> int:
    try:
        number = int(value)
    except (TypeError, ValueError):
        return default
    return number if number > 0 else default


def _camel_to_snake_text(value: str) -> str:
    result: list[str] = []
    for char in value.strip():
        if char.isupper() and result:
            result.append("_")
        result.append(char.lower())
    return "".join(result)


def _self_restart_enabled() -> bool:
    return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"}


def _cors_allow_origins() -> list[str]:
    raw = os.getenv("BEAVER_CORS_ALLOW_ORIGINS", "").strip()
    if raw:
        return [origin.strip().rstrip("/") for origin in raw.split(",") if origin.strip()]
    return [
        "http://127.0.0.1:3000",
        "http://localhost:3000",
        "http://127.0.0.1:3080",
        "http://localhost:3080",
        "http://127.0.0.1:3081",
        "http://localhost:3081",
    ]


def _schedule_self_restart(delay_seconds: float = 0.75) -> None:
    import threading

    def _exit_later() -> None:
        time.sleep(delay_seconds)
        os._exit(0)

    threading.Thread(target=_exit_later, daemon=True).start()


def create_app(
    *,
    workspace: str | Path | None = None,
    config_path: str | Path | None = None,
    service: AgentService | None = None,
    manage_service_lifecycle: bool | None = None,
    shutdown_timeout_seconds: float | None = 5.0,
    shutdown_force: bool = True,
) -> FastAPI:
    """Create a Beaver web app hosted by AgentService running mode.

    默认 ownership 语义：
    - 未传 `service`：app 自己创建并接管其 lifecycle
    - 传入外部 `service`：默认只挂载，不自动 start/shutdown

    如果确实需要覆盖默认行为，可以显式传 `manage_service_lifecycle=True/False`。
    """

    app = FastAPI(
        title="Beaver Backend",
        lifespan=lambda fastapi_app: _app_lifespan(
            fastapi_app,
            workspace=workspace,
            config_path=config_path,
            service=service,
            manage_service_lifecycle=manage_service_lifecycle,
            shutdown_timeout_seconds=shutdown_timeout_seconds,
            shutdown_force=shutdown_force,
        ),
    )
    if CORSMiddleware is not None:
        app.add_middleware(
            CORSMiddleware,
            allow_origins=_cors_allow_origins(),
            allow_credentials=True,
            allow_methods=["*"],
            allow_headers=["*"],
        )
    app.state.auth_tokens = {}
    app.state.handoff_codes = {}
    app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
    max_file_size = 50 * 1024 * 1024
    max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
    user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024)

    def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver:
        username = _require_web_user(app, authorization)
        loaded = get_agent_service(request).create_loop().boot()
        auth_context = build_file_auth_context(username=username, config=loaded.config)
        return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context)

    async def _user_file_service(request: Request, authorization: str | None) -> UserFileService:
        return await _user_file_resolver(request, authorization).service()

    def _user_file_http_error(exc: UserFileError) -> HTTPException:
        if isinstance(exc, UserFileNotFoundError):
            return HTTPException(status_code=404, detail=str(exc) or "File not found")
        if isinstance(exc, UserFilePathError):
            return HTTPException(status_code=400, detail=str(exc) or "Invalid path")
        if isinstance(exc, UserFileSizeError):
            return HTTPException(status_code=413, detail=str(exc) or "File too large")
        if isinstance(exc, UserFileConfigurationError):
            return HTTPException(status_code=503, detail=str(exc) or "User file storage is not configured")
        return HTTPException(status_code=400, detail=str(exc) or "User file operation failed")

    @app.get("/api/ping", response_model=WebStatusResponse)
    async def ping(request: Request) -> WebStatusResponse:
        agent_service = get_agent_service(request)
        running = agent_service.is_running
        return WebStatusResponse(
            status="ok",
            running=running,
            mode="running" if running else ("direct" if agent_service.has_loop else "idle"),
        )

    @app.get("/api/status")
    async def status(request: Request) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        loaded = agent_service.create_loop().boot()
        config = loaded.config
        config_path = config.config_path or default_config_path(workspace=loaded.workspace)
        cron_service = get_cron_service(request)

        providers_status = []
        default_provider = config.resolve_provider_target().get("provider_name")
        for spec in PROVIDERS:
            if spec.name == "custom":
                continue
            provider_cfg = config.providers.get(spec.name)
            api_key = provider_cfg.api_key if provider_cfg is not None else None
            api_base = provider_cfg.api_base if provider_cfg is not None else None
            enabled = _provider_enabled(spec.name, provider_cfg)
            if spec.is_oauth:
                has_key = enabled
            elif spec.is_local or spec.is_direct:
                has_key = bool(api_base)
            else:
                has_key = bool(api_key)
            providers_status.append(
                {
                    "id": spec.name,
                    "name": spec.label,
                    "label": spec.label,
                    "enabled": enabled,
                    "active": default_provider == spec.name,
                    "has_key": has_key,
                    "api_key_masked": _mask_secret(api_key),
                    "api_base": api_base or "",
                    "default_api_base": spec.default_api_base,
                    "detail": api_base or spec.default_api_base or "",
                    "requires_api_key": not (spec.is_oauth or spec.is_local or spec.is_direct),
                    "is_oauth": spec.is_oauth,
                    "is_local": spec.is_local,
                }
            )

        return {
            "config_path": str(config_path),
            "config_exists": config_path.exists(),
            "workspace": str(loaded.workspace),
            "workspace_exists": loaded.workspace.exists(),
            "model": config.default_model or agent_service.profile.default_model,
            "max_tokens": agent_service.profile.max_tokens,
            "max_context_messages": agent_service.profile.max_context_messages,
            "temperature": agent_service.profile.temperature,
            "max_tool_iterations": agent_service.profile.max_tool_iterations,
            "providers": providers_status,
            "channels": get_channel_runtime(request).statuses(),
            "runtime_controls": {"self_restart": _self_restart_enabled()},
            "cron": cron_service.status(),
        }

    @app.get("/api/channels")
    async def list_channels(request: Request) -> list[dict[str, Any]]:
        return get_channel_runtime(request).statuses()

    @app.get("/api/channel-connectors")
    async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
        return get_channel_connector_registry(request).connectors()

    @app.get("/api/channel-connections")
    async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
        registry = get_channel_connector_registry(request)
        return [_connection_response_view(connection) for connection in registry.connection_store.list()]

    @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
    async def create_channel_connection(
        request: Request,
        payload: WebChannelConnectionCreateRequest,
    ) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        kind = _clean_text(payload.kind)
        mode = _clean_text(payload.mode)
        if not kind:
            raise HTTPException(status_code=400, detail="Connection kind is required")
        if not mode:
            raise HTTPException(status_code=400, detail="Connection mode is required")
        secrets_payload = payload.secrets or {}
        secrets = {key: value for key, value in secrets_payload.items() if value}
        credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
        connection = registry.connection_store.create(
            kind=kind,
            mode=mode,
            display_name=_clean_text(payload.display_name) or kind,
            account_id=_clean_text(payload.account_id) or "",
            owner_user_id=_clean_text(payload.owner_user_id) or None,
            auth_type=_clean_text(payload.auth_type) or "token",
            credentials_ref=credentials_ref,
            runtime_config=_normalize_connection_config(payload.config),
        )
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(credentials_ref),
        )

    @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
    async def update_channel_connection(
        connection_id: str,
        request: Request,
        payload: WebChannelConnectionUpdateRequest,
    ) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        if payload.display_name is not None:
            connection.display_name = _clean_text(payload.display_name) or connection.display_name
        if payload.account_id is not None:
            connection.account_id = _clean_text(payload.account_id) or connection.account_id
        if payload.config is not None:
            connection.runtime_config = _normalize_connection_config(payload.config)
        if payload.secrets:
            secrets = {key: value for key, value in payload.secrets.items() if value}
            if secrets:
                # TODO: add credential GC when connection updates credentials.
                connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
        connection = registry.connection_store.update(connection)
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(connection.credentials_ref),
        )

    @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
    async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(connection.credentials_ref),
        )

    @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
    async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
        registry = get_channel_connector_registry(request)
        try:
            result = await registry.validate(connection_id)
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelValidationResponse(
            ok=result.ok,
            status=result.status,
            account_id=result.account_id,
            display_name=result.display_name,
            error=result.error,
            metadata=result.metadata,
            connection=_connection_response_view(connection),
        )

    @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
    async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            await registry.revoke(connection_id)
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})

    @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
    async def start_channel_connector_session(
        request: Request,
        payload: WebConnectorSessionCreateRequest,
    ) -> WebConnectorSessionResponse:
        registry = get_channel_connector_registry(request)
        kind = _clean_text(payload.kind)
        try:
            connector = registry.connector_for_kind(kind)
        except KeyError:
            raise HTTPException(status_code=404, detail="Connector not found")
        start_session = getattr(connector, "start_session", None)
        if start_session is None:
            raise HTTPException(status_code=400, detail="Connector does not support sessions")
        view = await start_session(
            display_name=_clean_text(payload.display_name) or kind,
            owner_user_id=_clean_text(payload.owner_user_id) or None,
            options=payload.options,
        )
        connection_id = _clean_text(view.get("connectionId"))
        connection_view = None
        if connection_id:
            connection = registry.connection_store.get(connection_id)
            connection = await _activate_connected_channel(request, registry, connection)
            connection_view = _connection_response_view(connection)
        return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view)

    @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
    async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
        registry = get_channel_connector_registry(request)
        connection = next(
            (item for item in registry.connection_store.list() if item.pairing_session_id == session_id),
            None,
        )
        if connection is None:
            raise HTTPException(status_code=404, detail="Connector session not found")
        connector = registry.connector_for_kind(connection.kind)
        poll_session = getattr(connector, "poll_session", None)
        if poll_session is None:
            raise HTTPException(status_code=400, detail="Connector does not support sessions")
        view = await poll_session(session_id)
        connection = registry.connection_store.get(connection.connection_id)
        connection = await _activate_connected_channel(request, registry, connection)
        return WebConnectorSessionResponse(
            session=_connector_session_response_view(view),
            connection=_connection_response_view(connection),
        )

    @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
    async def accept_connector_bridge_event(
        request: Request,
        payload: WebConnectorBridgeEventRequest,
        authorization: str | None = Header(default=None),
    ) -> Any:
        expected = _bridge_token()
        if not expected or authorization != f"Bearer {expected}":
            raise HTTPException(status_code=401, detail="Invalid connector bridge token")

        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(payload.connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        _connector_bridge_guard(connection, payload)

        store = _message_dedupe_store(_channel_connection_workspace(request))
        begin = store.begin(
            connection_id=payload.connection_id,
            event_id=payload.event_id,
            delivery_attempt=payload.delivery_attempt,
        )
        if not begin.should_process:
            body = WebConnectorBridgeEventResponse(
                accepted=begin.http_status == 200,
                duplicate=True,
                pending=begin.http_status == 409,
                retryAfterSeconds=begin.retry_after_seconds,
            ).model_dump(by_alias=True)
            return JSONResponse(status_code=begin.http_status, content=body)

        runtime = get_channel_runtime(request)
        identity = ChannelIdentity(
            channel_id=payload.channel_id,
            kind=payload.kind,
            account_id=payload.account_id,
            peer_id=payload.peer_id,
            thread_id=payload.thread_id,
            peer_type=payload.peer_type,
            user_id=payload.user_id,
            message_id=payload.message_id,
        )
        inbound = InboundMessage(
            channel=payload.channel_id,
            content=payload.content,
            content_type=payload.message_type,
            channel_identity=identity,
            user_id=payload.user_id,
            message_id=payload.message_id,
            metadata=dict(payload.metadata),
        )
        result = await runtime.accept_inbound(inbound)
        if result.accepted or result.duplicate:
            store.complete(begin.dedupe_key, message_id=payload.message_id)
        else:
            store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
        return WebConnectorBridgeEventResponse(
            accepted=result.accepted,
            duplicate=result.duplicate,
            pending=result.pending,
        )

    @app.get("/api/channels/{channel_id}/config")
    async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
        raw = _read_config_json(config_path)
        channel = _ensure_dict(raw, "channels").get(channel_id)
        if not isinstance(channel, dict):
            raise HTTPException(status_code=404, detail="Channel not found")
        return _channel_config_view(channel_id, channel)

    @app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse)
    async def update_channel_config(
        channel_id: str,
        request: Request,
        payload: WebChannelConfigRequest,
    ) -> WebChannelConfigResponse:
        if not _clean_text(channel_id):
            raise HTTPException(status_code=400, detail="Channel id is required")
        kind = _clean_text(payload.kind)
        mode = _clean_text(payload.mode)
        if not kind:
            raise HTTPException(status_code=400, detail="Channel kind is required")
        if not mode:
            raise HTTPException(status_code=400, detail="Channel mode is required")

        agent_service = get_agent_service(request)
        config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
        raw = _read_config_json(config_path)
        channels = _ensure_dict(raw, "channels")
        current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {}
        current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {}
        next_secrets = dict(current_secrets)
        for key, value in (payload.secrets or {}).items():
            cleaned_key = _clean_text(key)
            cleaned_value = _clean_text(value)
            if not cleaned_key or not cleaned_value:
                continue
            next_secrets[cleaned_key] = cleaned_value

        channel_payload: dict[str, Any] = {
            "enabled": bool(payload.enabled),
            "kind": kind,
            "mode": mode,
            "accountId": _clean_text(payload.account_id) or "",
            "displayName": _clean_text(payload.display_name) or channel_id,
            "config": payload.config or {},
            "secrets": next_secrets,
        }
        channels[channel_id] = channel_payload
        _write_config_json(config_path, raw)
        _reload_agent_config(agent_service, config_path)
        return WebChannelConfigResponse(
            ok=True,
            channel_id=channel_id,
            restart_required=True,
            channel=_channel_config_view(channel_id, channel_payload),
        )

    @app.get("/api/channels/{channel_id}/events")
    async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]:
        return get_channel_runtime(request).recent_events(channel_id, limit=limit)

    @app.post("/api/channels/{channel_id}/webhook")
    async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse:
        runtime = get_channel_runtime(request)
        adapter = runtime.adapters.get(channel_id)
        if adapter is None or not hasattr(adapter, "handle_webhook_payload"):
            raise HTTPException(status_code=404, detail="Webhook channel not found")
        payload = await request.json()
        if not isinstance(payload, dict):
            raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object")
        result = await adapter.handle_webhook_payload(payload)  # type: ignore[attr-defined]
        status_code = 202 if result.get("pending") else 200
        return JSONResponse(result, status_code=status_code)

    @app.websocket("/api/channels/{channel_id}/ws")
    async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
        runtime = getattr(websocket.app.state, "channel_runtime", None)
        if not isinstance(runtime, ChannelRuntime):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
            await websocket.close(code=1011)
            return
        adapter = runtime.adapters.get(channel_id)
        if adapter is None or not hasattr(adapter, "handle_websocket"):
            await websocket.accept()
            await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
            await websocket.close(code=1008)
            return
        await adapter.handle_websocket(websocket)  # type: ignore[attr-defined]

    @app.post("/api/runtime/restart")
    async def restart_runtime() -> JSONResponse:
        if not _self_restart_enabled():
            raise HTTPException(status_code=403, detail="Self restart is disabled")
        _schedule_self_restart()
        return JSONResponse({"ok": True, "restarting": True}, status_code=202)

    @app.post("/api/auth/login")
    async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        username = _clean_text(payload.get("username"))
        password = str(payload.get("password") or "")
        if not username or not password:
            raise HTTPException(status_code=400, detail="Username and password are required")

        users = _load_auth_users(_auth_file_path())
        expected = users.get(username)
        if expected is None or not secrets.compare_digest(expected, password):
            raise HTTPException(status_code=401, detail="Invalid username or password")

        token = _issue_web_token(app, username)
        handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
        return {
            "access_token": token,
            "refresh_token": "",
            "token_type": "bearer",
            "user_id": username,
            "username": username,
            "role": "owner",
            "handoff_code": handoff_code,
            "handoff_expires_at": handoff_expires_at,
            "backend_connection": _backend_connection_view(request),
            "local_backend": _local_backend_view(),
        }

    @app.post("/api/auth/register")
    async def auth_register(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        username = _clean_text(payload.get("username"))
        password = str(payload.get("password") or "")
        email = _clean_text(payload.get("email")) or ""
        if not username or not password:
            raise HTTPException(status_code=400, detail="Username and password are required")

        auth_file = _auth_file_path()
        users = _load_auth_users_if_present(auth_file)
        user_exists = username in users
        if user_exists and not secrets.compare_digest(users[username], password):
            raise HTTPException(
                status_code=409,
                detail="Username already exists. Use the existing password to finish setup or log in.",
            )

        agent_service = get_agent_service(request)
        loaded = agent_service.create_loop().boot()
        config = loaded.config
        authz_base_url = _clean_text(payload.get("authz_base_url")) or (config.authz.base_url if config.authz.enabled else "")
        backend_name = _clean_text(payload.get("backend_name")) or config.backend_identity.name or username
        requested_backend_id = _clean_text(payload.get("backend_id")) or config.backend_identity.backend_id or None
        public_base_url = (
            _clean_text(payload.get("base_url"))
            or config.backend_identity.public_base_url
            or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
            or str(request.base_url).rstrip("/")
        )
        frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url

        authz_user_registered = False
        authz_backend_registered = False
        local_backend: dict[str, Any] | None = None

        if authz_base_url:
            from beaver.integrations.authz import AuthzClient

            try:
                authz_payload = await AuthzClient(
                    authz_base_url,
                    timeout_seconds=config.authz.request_timeout_seconds,
                ).register_user(
                    username=username,
                    password=password,
                    email=email or None,
                    backend_name=backend_name,
                    backend_id=requested_backend_id,
                    base_url=public_base_url,
                    frontend_base_url=frontend_base_url,
                )
            except Exception as exc:  # noqa: BLE001 - expose upstream setup failures to portal
                raise HTTPException(status_code=502, detail=f"AuthZ registration failed: {exc}") from exc

            backend = authz_payload.get("backend") if isinstance(authz_payload, dict) else {}
            if isinstance(backend, dict):
                backend_id = _clean_text(backend.get("backend_id")) or requested_backend_id
                client_id = _clean_text(backend.get("client_id")) or backend_id
                client_secret = _clean_text(backend.get("client_secret")) or config.backend_identity.client_secret
                if backend_id and client_id and client_secret:
                    local_backend = _save_backend_identity(
                        agent_service,
                        config_path=config.config_path or default_config_path(workspace=loaded.workspace),
                        backend_id=backend_id,
                        client_id=client_id,
                        client_secret=client_secret,
                        name=_clean_text(backend.get("name")) or backend_name,
                        public_base_url=public_base_url,
                        authz_base_url=authz_base_url,
                    )
                    authz_backend_registered = True
            authz_user_registered = bool(authz_payload)

        if not user_exists:
            users[username] = password
            _save_auth_users(auth_file, users)

        token = _issue_web_token(app, username)
        handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
        backend_connection = {
            **_backend_connection_view(request),
            "public_base_url": public_base_url,
            "api_base_url": public_base_url,
            "frontend_base_url": frontend_base_url,
            "registered": bool(local_backend),
        }
        if local_backend is not None:
            backend_connection.update(
                {
                    "backend_id": local_backend.get("backend_id"),
                    "client_id": local_backend.get("client_id"),
                    "name": local_backend.get("name"),
                }
            )
        return {
            "access_token": token,
            "refresh_token": "",
            "token_type": "bearer",
            "user_id": username,
            "username": username,
            "email": email,
            "role": "owner",
            "handoff_code": handoff_code,
            "handoff_expires_at": handoff_expires_at,
            "existing_user": user_exists,
            "authz": {
                "enabled": bool(authz_base_url),
                "base_url": authz_base_url or None,
                "user_registered": authz_user_registered,
                "backend_registered": authz_backend_registered,
            },
            "backend_connection": backend_connection,
            "local_backend": local_backend or _local_backend_view(),
        }

    @app.post("/api/auth/handoff/consume")
    async def auth_handoff_consume(payload: dict[str, Any]) -> dict[str, Any]:
        return _consume_handoff_code(app, str(payload.get("code") or ""))

    @app.get("/api/auth/me")
    async def auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]:
        username = _require_web_user(app, authorization)
        return {
            "id": username,
            "username": username,
            "email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL", ""),
            "role": "owner",
            "quota_tier": "single-user",
        }

    @app.post("/api/auth/logout")
    async def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
        if authorization and authorization.lower().startswith("bearer "):
            token = authorization[7:].strip()
            app.state.auth_tokens.pop(token, None)
        return {"ok": True}

    @app.post("/api/providers/{provider_name}/config", response_model=WebProviderConfigResponse)
    async def update_provider_config(
        provider_name: str,
        request: Request,
        payload: WebProviderConfigRequest,
    ) -> WebProviderConfigResponse:
        spec = find_by_name(provider_name)
        if spec is None or spec.name == "custom":
            raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_name}")

        agent_service = get_agent_service(request)
        config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
        raw = _read_config_json(config_path)
        providers = _ensure_dict(raw, "providers")
        agents = _ensure_dict(raw, "agents")
        defaults = _ensure_dict(agents, "defaults")

        if not payload.enabled:
            providers.pop(spec.name, None)
            if _clean_text(defaults.get("provider")) == spec.name:
                defaults.pop("provider", None)
        else:
            current = providers.get(spec.name) if isinstance(providers.get(spec.name), dict) else {}
            provider_payload = dict(current)
            api_key = _clean_text(payload.api_key)
            api_base = _clean_text(payload.api_base)
            if api_key:
                provider_payload["apiKey"] = api_key
            elif "apiKey" not in provider_payload and "api_key" not in provider_payload:
                provider_payload.pop("apiKey", None)
            if api_base:
                provider_payload["apiBase"] = api_base
            elif spec.default_api_base and not provider_payload.get("apiBase") and not provider_payload.get("api_base"):
                provider_payload["apiBase"] = spec.default_api_base
            elif not api_base and not spec.default_api_base:
                provider_payload.pop("apiBase", None)
            if payload.request_timeout_seconds is not None:
                provider_payload["requestTimeoutSeconds"] = payload.request_timeout_seconds
            providers.clear()
            providers[spec.name] = provider_payload
            defaults["provider"] = spec.name
            model = _clean_text(payload.model)
            if model:
                defaults["model"] = model

        _write_config_json(config_path, raw)
        _reload_agent_config(agent_service, config_path)
        return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)

    @app.post("/api/agent-config", response_model=WebAgentConfigResponse)
    async def update_agent_config(
        request: Request,
        payload: WebAgentConfigRequest,
    ) -> WebAgentConfigResponse:
        if payload.max_tokens is not None and payload.max_tokens <= 0:
            raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
        if payload.temperature < 0 or payload.temperature > 2:
            raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
        if payload.max_tool_iterations < 0:
            raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")

        agent_service = get_agent_service(request)
        config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
        raw = _read_config_json(config_path)
        agents = _ensure_dict(raw, "agents")
        defaults = _ensure_dict(agents, "defaults")

        if payload.max_tokens is None:
            defaults.pop("maxTokens", None)
            defaults.pop("max_tokens", None)
        else:
            defaults["maxTokens"] = payload.max_tokens
            defaults.pop("max_tokens", None)
        defaults["temperature"] = payload.temperature
        defaults["maxToolIterations"] = payload.max_tool_iterations
        defaults.pop("max_tool_iterations", None)

        _write_config_json(config_path, raw)
        _reload_agent_config(agent_service, config_path)
        return WebAgentConfigResponse(ok=True)

    @app.get("/api/sessions")
    async def list_sessions(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        session_manager = loaded.session_manager
        rows = session_manager.list_sessions_rich(
            limit=100,
            exclude_sources=["subagent", "notification"],
            exclude_end_reasons=["archived", "deleted"],
        )  # type: ignore[union-attr]
        return [
            {
                "key": str(row.get("id")),
                "created_at": _iso_from_timestamp(row.get("started_at")),
                "updated_at": _iso_from_timestamp(row.get("last_active")),
                "path": str(row.get("id")),
            }
            for row in rows
        ]

    @app.get("/api/debug/chat-logs")
    async def get_chat_logs(request: Request, limit: int = 50) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        session_manager = loaded.session_manager
        bounded_limit = max(1, min(int(limit or 50), 200))
        rows = session_manager.list_sessions_rich(
            limit=bounded_limit,
            exclude_end_reasons=["archived", "deleted"],
        )  # type: ignore[union-attr]
        sessions = []
        for row in rows:
            session_id = str(row.get("id"))
            runs = _debug_runs_for_session(session_manager, session_id)
            if not runs:
                continue
            sessions.append(
                {
                    "session_id": session_id,
                    "source": row.get("source"),
                    "title": row.get("title"),
                    "created_at": _iso_from_timestamp(row.get("started_at")),
                    "updated_at": _iso_from_timestamp(row.get("last_active")),
                    "runs": runs,
                }
            )
        return {"sessions": sessions}

    @app.post("/api/sessions/{session_id:path}/archive")
    async def archive_session(session_id: str, request: Request) -> dict[str, Any]:
        if session_id.startswith("notify:"):
            raise HTTPException(status_code=400, detail="Notification sessions cannot be archived")
        loaded = get_agent_service(request).create_loop().boot()
        loaded.session_manager.end_session(session_id, "archived")  # type: ignore[union-attr]
        return {"ok": True, "archived": True}

    @app.post("/api/sessions/{session_id:path}")
    async def create_session(session_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        session_manager = loaded.session_manager
        session = session_manager.get_or_create(session_id, source="web")  # type: ignore[union-attr]
        return _session_detail(session_manager, session_id, session)  # type: ignore[arg-type]

    @app.get("/api/sessions/{session_id:path}/process")
    async def get_session_process(session_id: str, request: Request) -> dict[str, Any]:
        from beaver.services.process_service import SessionProcessProjector

        loaded = get_agent_service(request).create_loop().boot()
        projector = SessionProcessProjector(
            loaded.session_manager,
            loaded.run_memory_store,
        )
        return projector.project(session_id)

    @app.get("/api/sessions/{session_id:path}/active-task")
    async def get_session_active_task(session_id: str, request: Request) -> dict[str, Any] | None:
        loaded = get_agent_service(request).create_loop().boot()
        task_service = loaded.task_service
        if task_service is None:
            return None
        view = task_service.active_task_view(session_id)
        if view is None:
            return None
        return {
            "task_id": view["task_id"],
            "status": view["status"],
            "short_title": view["short_title"],
            "description": view["description"],
            "updated_at": view["updated_at"],
        }

    @app.get("/api/sessions/{session_id:path}")
    async def get_session(session_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        session_manager = loaded.session_manager
        session = session_manager.get_or_create(session_id, source="web")  # type: ignore[union-attr]
        return _session_detail(session_manager, session_id, session)  # type: ignore[arg-type]

    @app.delete("/api/sessions/{session_id:path}")
    async def delete_session(session_id: str, request: Request) -> dict[str, Any]:
        if session_id.startswith("notify:"):
            raise HTTPException(status_code=400, detail="Notification sessions cannot be archived")
        loaded = get_agent_service(request).create_loop().boot()
        loaded.session_manager.end_session(session_id, "archived")  # type: ignore[union-attr]
        return {"ok": True, "archived": True}

    @app.post("/api/files/upload")
    async def upload_file(
        request: Request,
        file: UploadFile = File(...),
        session_id: str = Form("web:default"),
    ) -> dict[str, Any]:
        if not file.filename:
            raise HTTPException(status_code=400, detail="No filename provided")

        content = await file.read()
        if len(content) > max_file_size:
            raise HTTPException(status_code=413, detail="File too large (max 50MB)")

        loaded = get_agent_service(request).create_loop().boot()
        file_id = generate_file_id()
        metadata = save_file(
            workspace=loaded.workspace,
            file_id=file_id,
            filename=file.filename,
            content=content,
            content_type=file.content_type or "application/octet-stream",
            session_id=session_id,
        )
        metadata["url"] = f"/api/files/{file_id}"
        return metadata

    @app.get("/api/files")
    async def list_uploaded_files(request: Request, session_id: str | None = None) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        return list_files(loaded.workspace, session_id=session_id)

    @app.get("/api/files/{file_id}")
    async def download_file(file_id: str, request: Request) -> Response:
        loaded = get_agent_service(request).create_loop().boot()
        metadata = get_file_metadata(loaded.workspace, file_id)
        if metadata is None:
            raise HTTPException(status_code=404, detail="File not found")

        file_path = get_file_path(loaded.workspace, file_id)
        if file_path is None:
            raise HTTPException(status_code=404, detail="File data missing")

        content_type = str(metadata.get("content_type") or "application/octet-stream")
        disposition = "inline" if content_type.startswith("image/") else "attachment"
        filename = str(metadata.get("name") or file_path.name)
        return Response(
            content=file_path.read_bytes(),
            media_type=content_type,
            headers={"Content-Disposition": content_disposition(disposition, filename)},
        )

    @app.delete("/api/files/{file_id}")
    async def remove_file(file_id: str, request: Request) -> dict[str, bool]:
        loaded = get_agent_service(request).create_loop().boot()
        if delete_file(loaded.workspace, file_id):
            return {"ok": True}
        raise HTTPException(status_code=404, detail="File not found")

    @app.get("/api/user-files/status")
    async def user_files_status(
        request: Request,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        return (await _user_file_resolver(request, authorization).status()).to_dict()

    @app.get("/api/user-files/browse")
    async def browse_user_files(
        request: Request,
        path: str = "",
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        try:
            return await (await _user_file_service(request, authorization)).browse(path)
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc

    @app.get("/api/user-files/download")
    async def download_user_file(
        path: str,
        request: Request,
        authorization: str | None = Header(default=None),
    ) -> Response:
        try:
            content = await (await _user_file_service(request, authorization)).download(path)
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc
        disposition = "inline" if content.content_type.startswith("image/") else "attachment"
        return Response(
            content=content.content,
            media_type=content.content_type,
            headers={"Content-Disposition": content_disposition(disposition, content.name)},
        )

    @app.get("/api/user-files/preview")
    async def preview_user_file(
        path: str,
        request: Request,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        try:
            return await (await _user_file_service(request, authorization)).preview(path)
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc

    @app.post("/api/user-files/upload")
    async def upload_user_file(
        request: Request,
        file: UploadFile = File(...),
        path: str = Form("uploads"),
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        if not file.filename:
            raise HTTPException(status_code=400, detail="No filename provided")
        file_size = getattr(file, "size", None)
        if isinstance(file_size, int) and file_size > max_user_file_upload_size:
            raise HTTPException(status_code=413, detail=f"File too large (max {_human_upload_size(max_user_file_upload_size)})")
        try:
            return await (await _user_file_service(request, authorization)).upload_stream(
                path,
                file.filename,
                file.file,
                content_type=file.content_type or "application/octet-stream",
                max_bytes=max_user_file_upload_size,
                part_size=user_file_upload_part_size,
            )
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc

    @app.delete("/api/user-files/delete")
    async def delete_user_file(
        path: str,
        request: Request,
        authorization: str | None = Header(default=None),
    ) -> dict[str, bool]:
        try:
            removed = await (await _user_file_service(request, authorization)).delete(path)
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc
        if removed:
            return {"ok": True}
        raise HTTPException(status_code=404, detail="Path not found")

    @app.post("/api/user-files/mkdir")
    async def create_user_file_directory(
        path: str,
        request: Request,
        authorization: str | None = Header(default=None),
    ) -> dict[str, Any]:
        try:
            return await (await _user_file_service(request, authorization)).mkdir(path)
        except UserFileError as exc:
            raise _user_file_http_error(exc) from exc

    @app.get("/api/workspace/browse")
    async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return browse_workspace(loaded.workspace, path)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/workspace/download")
    async def download_workspace_file(path: str, request: Request) -> Response:
        loaded = get_agent_service(request).create_loop().boot()
        file_path = workspace_file_path(loaded.workspace, path)
        if file_path is None:
            raise HTTPException(status_code=404, detail="File not found")

        content_type, _ = mimetypes.guess_type(file_path.name)
        content_type = content_type or "application/octet-stream"
        disposition = "inline" if content_type.startswith("image/") else "attachment"
        return Response(
            content=file_path.read_bytes(),
            media_type=content_type,
            headers={"Content-Disposition": content_disposition(disposition, file_path.name)},
        )

    @app.get("/api/workspace/file")
    async def preview_workspace_file(path: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return workspace_file_preview(loaded.workspace, path)
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc

    @app.post("/api/workspace/upload")
    async def upload_to_workspace(
        request: Request,
        file: UploadFile = File(...),
        path: str = Form(""),
    ) -> dict[str, Any]:
        if not file.filename:
            raise HTTPException(status_code=400, detail="No filename provided")

        content = await file.read()
        if len(content) > max_file_size:
            raise HTTPException(status_code=413, detail="File too large (max 50MB)")

        loaded = get_agent_service(request).create_loop().boot()
        try:
            return save_to_workspace(loaded.workspace, path, file.filename, content)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.delete("/api/workspace/delete")
    async def delete_workspace_item(path: str, request: Request) -> dict[str, bool]:
        loaded = get_agent_service(request).create_loop().boot()
        if delete_workspace_path(loaded.workspace, path):
            return {"ok": True}
        raise HTTPException(status_code=404, detail="Path not found")

    @app.post("/api/workspace/mkdir")
    async def create_workspace_directory(path: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return create_workspace_dir(loaded.workspace, path)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/agents")
    async def list_agents(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        return [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()]  # type: ignore[union-attr]

    @app.post("/api/agents")
    async def upsert_agent(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        agent = loaded.agent_registry.upsert_agent(_agent_payload_from_ui(payload))  # type: ignore[union-attr]
        return _registered_agent_to_ui(agent)

    @app.patch("/api/agents/{agent_id}")
    async def patch_agent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        registry = loaded.agent_registry
        current = registry.get_agent(agent_id)  # type: ignore[union-attr]
        if current is None:
            raise HTTPException(status_code=404, detail=f"Unknown agent: {agent_id}")
        merged = current.to_dict()
        merged.update(_agent_payload_from_ui(payload))
        merged["agent_id"] = agent_id
        agent = registry.upsert_agent(merged)  # type: ignore[union-attr]
        return _registered_agent_to_ui(agent)

    @app.post("/api/agents/{agent_id}/disable")
    async def disable_agent(agent_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            agent = loaded.agent_registry.disable_agent(agent_id)  # type: ignore[union-attr]
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        return _registered_agent_to_ui(agent)

    @app.delete("/api/agents/{agent_id}")
    async def delete_agent(agent_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        deleted = loaded.agent_registry.delete_agent(agent_id)  # type: ignore[union-attr]
        if not deleted:
            raise HTTPException(status_code=404, detail="Agent not found")
        return {"ok": True, "id": agent_id}

    @app.post("/api/agents/refresh")
    async def refresh_agents(request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        return {"agents": [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()]}  # type: ignore[union-attr]

    @app.get("/api/subagents")
    async def list_subagents(request: Request) -> list[dict[str, Any]]:
        from beaver.coordinator.subagents import LocalSubagentStore

        loaded = get_agent_service(request).create_loop().boot()
        store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
        return [store.serialize(spec) for spec in store.list_subagents()]

    @app.get("/api/subagents/{agent_id}")
    async def get_subagent(agent_id: str, request: Request) -> dict[str, Any]:
        from beaver.coordinator.subagents import LocalSubagentStore

        loaded = get_agent_service(request).create_loop().boot()
        store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
        spec = store.get_subagent(agent_id)
        if spec is None:
            raise HTTPException(status_code=404, detail="Sub-agent not found")
        return store.serialize(spec)

    @app.post("/api/subagents")
    async def create_subagent(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        from beaver.coordinator.subagents import LocalSubagentStore

        loaded = get_agent_service(request).create_loop().boot()
        store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
        try:
            spec = store.upsert_subagent(payload)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return store.serialize(spec)

    @app.put("/api/subagents/{agent_id}")
    async def update_subagent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        if _clean_text(payload.get("id")) != agent_id:
            raise HTTPException(status_code=400, detail="Path id must match body id")
        return await create_subagent(request, payload)

    @app.delete("/api/subagents/{agent_id}")
    async def delete_subagent(agent_id: str, request: Request) -> dict[str, Any]:
        from beaver.coordinator.subagents import LocalSubagentStore

        loaded = get_agent_service(request).create_loop().boot()
        store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
        if not store.delete_subagent(agent_id):
            raise HTTPException(status_code=404, detail="Sub-agent not found")
        return {"ok": True, "id": agent_id}

    @app.get("/api/authz/status")
    async def get_authz_status(request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        config = loaded.config
        registered = bool(config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret)
        permissions: dict[str, Any] = {}
        error = None
        if config.authz.enabled and config.authz.base_url and config.backend_identity.backend_id:
            try:
                from beaver.integrations.authz import AuthzClient

                permissions = await AuthzClient(
                    config.authz.base_url,
                    timeout_seconds=config.authz.request_timeout_seconds,
                ).get_permissions(config.backend_identity.backend_id)
            except Exception as exc:  # noqa: BLE001 - status endpoint reports dependency errors
                error = str(exc)
        return {
            "enabled": config.authz.enabled,
            "base_url": config.authz.base_url,
            "outlook_mcp_url": config.authz.outlook_mcp_url,
            "local_backend": {
                "backend_id": config.backend_identity.backend_id or None,
                "client_id": config.backend_identity.client_id or None,
                "name": config.backend_identity.name or None,
                "public_base_url": config.backend_identity.public_base_url or None,
                "registered": registered,
            },
            "permissions": permissions,
            "error": error,
        }

    @app.get("/api/mcp/servers")
    async def list_mcp_servers(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        return [_mcp_server_view(server_id, cfg, loaded.mcp_report.get(server_id, {})) for server_id, cfg in loaded.config.tools.mcp_servers.items()]

    @app.post("/api/mcp/servers")
    async def add_mcp_server(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        server_id = _clean_text(payload.get("id"))
        if not server_id:
            raise HTTPException(status_code=400, detail="Server id is required")
        config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
        raw = _read_config_json(config_path)
        tools = _ensure_dict(raw, "tools")
        servers = _ensure_dict(tools, "mcpServers")
        servers[server_id] = _mcp_config_payload(payload, server_id)
        _write_config_json(config_path, raw)
        _reload_agent_config(get_agent_service(request), config_path)
        loaded = get_agent_service(request).create_loop().boot()
        cfg = loaded.config.tools.mcp_servers[server_id]
        return _mcp_server_view(server_id, cfg, {})

    @app.put("/api/mcp/servers/{server_id}")
    async def update_mcp_server(server_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        if _clean_text(payload.get("id")) and _clean_text(payload.get("id")) != server_id:
            raise HTTPException(status_code=400, detail="Path id must match body id")
        payload = {**payload, "id": server_id}
        return await add_mcp_server(request, payload)

    @app.delete("/api/mcp/servers/{server_id}")
    async def delete_mcp_server(server_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
        raw = _read_config_json(config_path)
        servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers")
        if server_id not in servers:
            raise HTTPException(status_code=404, detail="MCP server not found")
        servers.pop(server_id, None)
        _write_config_json(config_path, raw)
        _reload_agent_config(get_agent_service(request), config_path)
        return {"ok": True, "id": server_id}

    @app.post("/api/mcp/servers/{server_id}/test")
    async def test_mcp(server_id: str, request: Request) -> dict[str, Any]:
        from beaver.integrations.mcp import test_mcp_server

        loaded = get_agent_service(request).create_loop().boot()
        cfg = loaded.config.tools.mcp_servers.get(server_id)
        if cfg is None:
            raise HTTPException(status_code=404, detail="MCP server not found")
        return await test_mcp_server(
            server_id,
            cfg,
            authz_config=loaded.config.authz,
            backend_identity=loaded.config.backend_identity,
        )

    @app.get("/api/integrations/outlook/status")
    async def get_outlook_status(request: Request) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, outlook_status

        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await outlook_status(loaded.config, loaded.workspace)
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=500, detail=str(exc)) from exc

    @app.post("/api/integrations/outlook/test-connection")
    async def test_outlook_connection(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookConnectionInput, OutlookIntegrationError, test_connection

        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await test_connection(OutlookConnectionInput(**payload), loaded.config)
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        except TypeError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.post("/api/integrations/outlook/connect")
    async def connect_outlook(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        from beaver.integrations.outlook import (
            OUTLOOK_SERVER_ID,
            OutlookConnectionInput,
            OutlookIntegrationError,
            connect_workspace,
            outlook_mcp_config_payload,
        )

        agent_service = get_agent_service(request)
        loaded = agent_service.create_loop().boot()
        try:
            result = await connect_workspace(loaded.config, loaded.workspace, OutlookConnectionInput(**payload))
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        except TypeError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

        config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
        raw = _read_config_json(config_path)
        servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers")
        servers[OUTLOOK_SERVER_ID] = outlook_mcp_config_payload(loaded.config)
        _write_config_json(config_path, raw)
        _reload_agent_config(agent_service, config_path)
        return result

    @app.post("/api/integrations/outlook/disconnect")
    async def disconnect_outlook(request: Request) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, disconnect_workspace

        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await disconnect_workspace(loaded.config)
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/integrations/outlook/overview")
    async def get_outlook_overview(request: Request) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, get_overview

        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await get_overview(loaded.config, loaded.workspace)
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/integrations/outlook/messages")
    async def get_outlook_messages(
        request: Request,
        folder: str = "inbox",
        top: int = 20,
        skip: int = 0,
        unread_only: bool = False,
    ) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, list_messages

        if not folder.strip():
            raise HTTPException(status_code=400, detail="folder is required")
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await list_messages(
                loaded.config,
                folder=folder.strip(),
                top=top,
                skip=skip,
                unread_only=unread_only,
            )
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/integrations/outlook/events")
    async def get_outlook_events(
        request: Request,
        start_time: str,
        end_time: str,
        top: int = 20,
        skip: int = 0,
    ) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, list_events

        if not start_time.strip() or not end_time.strip():
            raise HTTPException(status_code=400, detail="start_time and end_time are required")
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await list_events(
                loaded.config,
                start_time=start_time.strip(),
                end_time=end_time.strip(),
                top=top,
                skip=skip,
            )
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/integrations/outlook/message-detail")
    async def get_outlook_message_detail(
        request: Request,
        message_id: str,
        changekey: str | None = None,
    ) -> dict[str, Any]:
        from beaver.integrations.outlook import OutlookIntegrationError, get_message_detail

        if not message_id.strip():
            raise HTTPException(status_code=400, detail="message_id is required")
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return await get_message_detail(
                loaded.config,
                message_id.strip(),
                changekey=changekey.strip() if changekey else None,
            )
        except OutlookIntegrationError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc

    @app.get("/api/mcp/tools")
    async def list_mcp_tools(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        registry = loaded.tool_registry
        report: dict[str, Any] = {}
        if getattr(loaded, "mcp_manager", None) is not None:
            loaded.mcp_report = await loaded.mcp_manager.connect_all(registry)
            report = dict(loaded.mcp_report or {})
        groups: dict[str, list[dict[str, Any]]] = {}
        for spec in registry.list_specs():
            if not spec.name.startswith("mcp_"):
                continue
            metadata = dict(getattr(spec, "metadata", {}) or {})
            server_id = str(metadata.get("server_id") or "")
            if not server_id:
                remainder = spec.name[len("mcp_"):]
                server_id, _, _public_name = remainder.partition("_")
            public_name = str(metadata.get("original_tool_name") or spec.name)
            groups.setdefault(server_id, []).append(
                {
                    "server_id": server_id,
                    "tool_name": public_name,
                    "name": spec.name,
                    "description": spec.description,
                    "parameters": spec.input_schema,
                    "kind": metadata.get("kind") or "online",
                    "category": metadata.get("category") or "online",
                }
            )
        result: list[dict[str, Any]] = []
        for key, value in sorted(groups.items()):
            cfg = loaded.config.tools.mcp_servers.get(key)
            server_report = report.get(key, {})
            kind = cfg.kind if cfg is not None else (value[0].get("kind") if value else "online")
            category = cfg.category if cfg is not None else (value[0].get("category") if value else kind)
            result.append(
                {
                    "server_id": key,
                    "server_name": cfg.display_name if cfg and cfg.display_name else key,
                    "transport": cfg.transport if cfg is not None else "mcp",
                    "kind": kind,
                    "category": category,
                    "status": server_report.get("status"),
                    "last_error": server_report.get("last_error"),
                    "tool_count": len(value),
                    "tools": sorted(value, key=lambda item: item["tool_name"]),
                }
            )
        return result

    @app.get("/api/skills")
    async def list_skills(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        skills = loaded.skills_loader.list_skills(filter_unavailable=False)  # type: ignore[union-attr]
        return [
            {
                "name": record.name,
                "description": record.description,
                "source": "builtin" if record.source == "builtin" else "workspace",
                "available": loaded.skills_loader._record_available(record),  # type: ignore[union-attr]
                "path": str(record.path),
                "version": record.version,
                "status": record.status,
                "source_kind": record.source_kind,
                "tool_hints": list(record.tool_hints),
                "provenance": (
                    loaded.skill_spec_store.read_published_skill(record.name).version.provenance  # type: ignore[union-attr]
                    if loaded.skill_spec_store.read_published_skill(record.name) is not None  # type: ignore[union-attr]
                    else {}
                ),
                "agent_cards": [],
            }
            for record in skills
        ]

    @app.get("/api/skills/{name}/detail")
    async def get_skill_detail(name: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        record = loaded.skills_loader.get_skill_record(name)  # type: ignore[union-attr]
        if record is None:
            raise HTTPException(status_code=404, detail="Skill not found")
        return _skill_detail_payload(loaded, name, record.version)

    @app.get("/api/skills/{name}/versions/{version}")
    async def get_skill_version(name: str, version: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        record = loaded.skills_loader.get_skill_record(name)  # type: ignore[union-attr]
        if record is None:
            raise HTTPException(status_code=404, detail="Skill not found")
        return _skill_detail_payload(loaded, name, version)

    @app.get("/api/skills/{name}/versions/{version}/file")
    async def get_skill_file(name: str, version: str, request: Request, path: str) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        record = loaded.skills_loader.get_skill_record(name)  # type: ignore[union-attr]
        if record is None:
            raise HTTPException(status_code=404, detail="Skill not found")
        base_dir = _skill_version_base_dir(loaded, record, version)
        file_path = _safe_child_path(base_dir, path)
        if not file_path.exists() or not file_path.is_file():
            raise HTTPException(status_code=404, detail="Skill file not found")
        return _skill_file_content_payload(base_dir, file_path)

    @app.get("/api/skills/{name}/download")
    async def download_skill(name: str, request: Request) -> Response:
        loaded = get_agent_service(request).create_loop().boot()
        record = loaded.skills_loader.get_skill_record(name)  # type: ignore[union-attr]
        if record is None:
            raise HTTPException(status_code=404, detail="Skill not found")
        skill_dir = record.path.parent
        buffer = io.BytesIO()
        with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
            for file_path in sorted(skill_dir.rglob("*")):
                if file_path.is_file() and not file_path.is_symlink():
                    archive.write(file_path, f"{name}/{file_path.relative_to(skill_dir)}")
        return Response(
            content=buffer.getvalue(),
            media_type="application/zip",
            headers={"Content-Disposition": f'attachment; filename="{name}.zip"'},
        )

    @app.delete("/api/skills/{name}")
    async def delete_skill(name: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        target = loaded.workspace / "skills" / name
        if not target.exists() or not target.is_dir():
            raise HTTPException(status_code=404, detail="Skill not found")
        shutil.rmtree(target)
        return {"ok": True, "name": name}

    @app.post("/api/skills/upload")
    async def upload_skill(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
        filename = file.filename or ""
        if not filename.endswith(".zip"):
            raise HTTPException(status_code=400, detail="File must be a .zip archive")
        agent_service = get_agent_service(request)
        loaded = agent_service.create_loop().boot()
        try:
            content = await file.read()
            draft_payload = _create_skill_upload_draft(loaded, filename, content)
            draft = loaded.draft_service.get_draft(draft_payload["skill_name"], draft_payload["draft_id"])
            if draft is not None:
                await _rewrite_uploaded_skill_draft_with_llm(agent_service, loaded, draft, filename=filename)
                draft = loaded.draft_service.get_draft(draft.skill_name, draft.draft_id) or draft
                draft_payload = draft.to_dict()
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return draft_payload

    @app.get("/api/marketplaces/skills/search")
    async def search_skillhub(
        request: Request,
        q: str = "",
        sort: str = "relevance",
        page: int = 0,
        size: int = 12,
        namespace: str | None = None,
    ) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.search(q=q, sort=sort, page=page, size=size, namespace=namespace)
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.get("/api/marketplaces/skills/{namespace}/{slug}")
    async def get_skillhub_detail(namespace: str, slug: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.detail(namespace, slug)
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}")
    async def get_skillhub_version(namespace: str, slug: str, version: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.version(namespace, slug, version)
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions")
    async def list_skillhub_versions(namespace: str, slug: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.versions(namespace, slug)
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}/file")
    async def get_skillhub_file(namespace: str, slug: str, version: str, request: Request, path: str) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.file_content(namespace, slug, version, path)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.post("/api/marketplaces/skills/{namespace}/{slug}/install")
    async def install_skillhub_skill(namespace: str, slug: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        service = SkillHubService(loaded.skill_spec_store)  # type: ignore[arg-type]
        try:
            return await service.install(namespace, slug, version=_clean_text((payload or {}).get("version")))
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        except Exception as exc:
            raise HTTPException(status_code=502, detail=str(exc)) from exc

    @app.get("/api/skills/candidates")
    async def list_skill_candidates(request: Request, status: str | None = None) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        return [
            _skill_learning_candidate_payload(loaded, item)
            for item in loaded.skill_learning_pipeline.list_candidates(status=status)  # type: ignore[union-attr]
        ]

    @app.get("/api/skills/candidates/{candidate_id}")
    async def get_skill_candidate(candidate_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id)  # type: ignore[union-attr]
            return _skill_learning_candidate_payload(loaded, candidate)
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc

    @app.post("/api/skills/candidates/{candidate_id}/draft")
    async def synthesize_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        loop = agent_service.create_loop()
        loaded = loop.boot()
        try:
            candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id)  # type: ignore[union-attr]
            if candidate.draft_skill_name and candidate.draft_id:
                try:
                    loaded.skill_learning_pipeline.get_draft(candidate.draft_skill_name, candidate.draft_id)  # type: ignore[union-attr]
                except ValueError:
                    pass
                else:
                    return _skill_draft_payload(loaded, candidate.draft_skill_name, candidate.draft_id)
            provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {})  # noqa: SLF001
            draft = await loaded.skill_learning_pipeline.synthesize_draft(  # type: ignore[union-attr]
                candidate_id,
                provider_bundle=provider_bundle,
            )
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        return _skill_draft_payload(loaded, draft.skill_name, draft.draft_id)

    @app.post("/api/skills/candidates/{candidate_id}/regenerate")
    async def regenerate_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        loop = agent_service.create_loop()
        loaded = loop.boot()
        provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {})  # noqa: SLF001
        try:
            draft = await loaded.skill_learning_pipeline.regenerate_draft(  # type: ignore[union-attr]
                candidate_id,
                provider_bundle=provider_bundle,
            )
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        return _skill_draft_payload(loaded, draft.skill_name, draft.draft_id)

    @app.post("/api/skills/learning/run-once")
    async def run_skill_learning_once(request: Request) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        loaded = agent_service.create_loop().boot()
        worker = SkillLearningWorker(
            pipeline=loaded.skill_learning_pipeline,  # type: ignore[arg-type]
            provider_bundle_factory=lambda: agent_service._make_provider_bundle_for_task(loaded, {}),  # noqa: SLF001
            config=SkillLearningWorkerConfig.from_env(),
        )
        result = await worker.run_once()
        return result.to_dict()

    @app.get("/api/skills/drafts")
    async def list_skill_drafts(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        return [
            _skill_draft_payload(loaded, item.skill_name, item.draft_id)
            for item in loaded.skill_learning_pipeline.list_drafts()  # type: ignore[union-attr]
        ]

    @app.get("/api/skills/{skill_name}/drafts/{draft_id}")
    async def get_skill_draft(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            return _skill_draft_payload(loaded, skill_name, draft_id, include_reviews=True)
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc

    @app.get("/api/skills/{skill_name}/drafts/{draft_id}/safety")
    async def get_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        report = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id)  # type: ignore[union-attr]
        if report is None:
            raise HTTPException(status_code=404, detail="Safety report not found")
        return report.to_dict()

    @app.post("/api/skills/{skill_name}/drafts/{draft_id}/safety")
    async def recheck_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            report = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id)  # type: ignore[union-attr]
        except ValueError as exc:
            raise _skill_draft_http_error(exc) from exc
        return report.to_dict()

    @app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
    async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id)  # type: ignore[union-attr]
        if report is None:
            raise HTTPException(status_code=404, detail="Eval report not found")
        return report.to_dict()

    @app.post("/api/skills/{skill_name}/drafts/{draft_id}/submit")
    async def submit_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        agent_service = get_agent_service(request)
        loop = agent_service.create_loop()
        loaded = loop.boot()
        try:
            safety = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id)  # type: ignore[union-attr]
            if safety.passed and safety.risk_level != "critical":
                loaded.skill_learning_pipeline.submit_review(  # type: ignore[union-attr]
                    skill_name,
                    draft_id,
                    requested_by=str((payload or {}).get("requested_by") or "web"),
                    notes=str((payload or {}).get("notes") or ""),
                )
                candidate_id = _skill_learning_candidate_id_for_draft(loaded, skill_name, draft_id)
                if candidate_id is not None:
                    provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {})  # noqa: SLF001
                    await loaded.skill_learning_pipeline.evaluate_draft(  # type: ignore[union-attr]
                        candidate_id,
                        skill_name,
                        draft_id,
                        provider_bundle=provider_bundle,
                        replay_runner=ReplayRunner(agent_loop=loop),
                    )
        except ValueError as exc:
            raise _skill_draft_http_error(exc) from exc
        return _skill_draft_payload(loaded, skill_name, draft_id)

    @app.post("/api/skills/{skill_name}/drafts/{draft_id}/approve")
    async def approve_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            review = loaded.skill_learning_pipeline.approve(  # type: ignore[union-attr]
                skill_name,
                draft_id,
                reviewer=str((payload or {}).get("reviewer") or "web"),
                notes=str((payload or {}).get("notes") or ""),
            )
        except ValueError as exc:
            raise _skill_draft_http_error(exc) from exc
        return review.to_dict()

    @app.post("/api/skills/{skill_name}/drafts/{draft_id}/reject")
    async def reject_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            review = loaded.skill_learning_pipeline.reject(  # type: ignore[union-attr]
                skill_name,
                draft_id,
                reviewer=str((payload or {}).get("reviewer") or "web"),
                notes=str((payload or {}).get("notes") or ""),
            )
        except ValueError as exc:
            raise _skill_draft_http_error(exc) from exc
        return review.to_dict()

    @app.post("/api/skills/{skill_name}/drafts/{draft_id}/publish")
    async def publish_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            result = loaded.skill_learning_pipeline.publish(  # type: ignore[union-attr]
                skill_name,
                draft_id,
                publisher=str((payload or {}).get("publisher") or "web"),
                notes=str((payload or {}).get("notes") or ""),
                confirm_high_risk=bool((payload or {}).get("confirm_high_risk")),
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return result.to_dict()

    @app.post("/api/skills/{skill_name}/disable")
    async def disable_skill(skill_name: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        try:
            spec = loaded.skill_learning_pipeline.disable(  # type: ignore[union-attr]
                skill_name,
                actor=str((payload or {}).get("actor") or "web"),
                reason=str((payload or {}).get("reason") or ""),
            )
        except ValueError as exc:
            raise HTTPException(status_code=404, detail=str(exc)) from exc
        return spec.to_dict()

    @app.post("/api/skills/{skill_name}/rollback")
    async def rollback_skill(skill_name: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        target_version = str(payload.get("target_version") or "").strip()
        if not target_version:
            raise HTTPException(status_code=400, detail="target_version is required")
        loaded = get_agent_service(request).create_loop().boot()
        try:
            spec = loaded.skill_learning_pipeline.rollback(  # type: ignore[union-attr]
                skill_name,
                target_version,
                actor=str(payload.get("actor") or "web"),
                reason=str(payload.get("reason") or ""),
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return spec.to_dict()

    @app.get("/api/notifications")
    async def list_notifications(request: Request) -> list[dict[str, Any]]:
        cron_service = get_cron_service(request)
        return [
            _notification_summary(job, run)
            for job, run in cron_service.list_runs()
            if run.mode == "notification" or run.notification_session_id == NOTIFICATION_SESSION_ID
        ]

    @app.get("/api/notifications/{scheduled_run_id}")
    async def get_notification(scheduled_run_id: str, request: Request) -> dict[str, Any]:
        cron_service = get_cron_service(request)
        found = cron_service.get_run(scheduled_run_id)
        if found is None:
            raise HTTPException(status_code=404, detail="Notification not found")
        job, run = found
        loaded = get_agent_service(request).create_loop().boot()
        session_id = run.notification_session_id or NOTIFICATION_SESSION_ID
        session = loaded.session_manager.get_or_create(session_id, source="notification", title="通知")  # type: ignore[union-attr]
        return {
            **_notification_summary(job, run),
            "detail": _session_detail(loaded.session_manager, session_id, session),  # type: ignore[arg-type]
        }

    @app.post("/api/notifications/{scheduled_run_id}/engage")
    async def engage_notification(scheduled_run_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
        cron_service = get_cron_service(request)
        found = cron_service.get_run(scheduled_run_id)
        if found is None:
            raise HTTPException(status_code=404, detail="Notification not found")
        job, run = found
        intent = _scheduled_reply_intent((payload or {}).get("intent"))
        task = get_agent_service(request).engage_scheduled_run(job=job, run=run, intent=intent)
        cron_service.mark_run_engaged(scheduled_run_id, task_id=task.task_id, intent=intent)
        return {"ok": True, "task_id": task.task_id, "intent": intent}

    @app.get("/api/cron/jobs")
    async def list_cron_jobs(request: Request, include_disabled: bool = True) -> list[dict[str, Any]]:
        cron_service = get_cron_service(request)
        return [job.to_api_dict() for job in cron_service.list_jobs(include_disabled=include_disabled)]

    @app.post("/api/cron/jobs")
    async def add_cron_job(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        cron_service = get_cron_service(request)
        try:
            schedule = schedule_from_api(payload)
            job = cron_service.add_job(
                name=str(payload.get("name") or "").strip(),
                message=str(payload.get("message") or "").strip(),
                schedule=schedule,
                session_key=str(payload.get("session_key") or "").strip() or None,
                payload_kind=str(payload.get("payload_kind") or "agent_turn"),
                mode=str(payload.get("mode") or "notification").strip().lower(),
                requires_followup=bool(payload.get("requires_followup", False)),
                deliver=bool(payload.get("deliver", False)),
                channel=str(payload.get("channel") or "").strip() or None,
                to=str(payload.get("to") or "").strip() or None,
                delete_after_run=bool(payload.get("delete_after_run", False)),
            )
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return job.to_api_dict()

    @app.delete("/api/cron/jobs/{job_id}")
    async def delete_cron_job(job_id: str, request: Request) -> dict[str, Any]:
        if not get_cron_service(request).remove_job(job_id):
            raise HTTPException(status_code=404, detail="Cron job not found")
        return {"ok": True, "id": job_id}

    @app.put("/api/cron/jobs/{job_id}/toggle")
    async def toggle_cron_job(job_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
        job = get_cron_service(request).update_enabled(job_id, bool(payload.get("enabled", True)))
        if job is None:
            raise HTTPException(status_code=404, detail="Cron job not found")
        return job.to_api_dict()

    @app.post("/api/cron/jobs/{job_id}/run")
    async def run_cron_job(job_id: str, request: Request) -> dict[str, Any]:
        cron_service = get_cron_service(request)
        if not await cron_service.run_job(job_id, force=True):
            raise HTTPException(status_code=404, detail="Cron job not found")
        job = cron_service.get_job(job_id)
        return {"ok": True, "id": job_id, "job": job.to_api_dict() if job is not None else None}

    @app.get("/api/tasks")
    async def list_tasks(request: Request) -> list[dict[str, Any]]:
        loaded = get_agent_service(request).create_loop().boot()
        task_service = loaded.task_service
        if task_service is None:
            return []
        return [task_service.to_api_dict(task) for task in task_service.list_tasks()]

    @app.get("/api/tasks/{task_id}")
    async def get_task(task_id: str, request: Request) -> dict[str, Any]:
        from beaver.services.process_service import SessionProcessProjector

        loaded = get_agent_service(request).create_loop().boot()
        task_service = loaded.task_service
        if task_service is None:
            raise HTTPException(status_code=404, detail="Task service is unavailable")
        task = task_service.get_task(task_id)
        if task is None:
            raise HTTPException(status_code=404, detail="Task not found")
        process_projection = SessionProcessProjector(
            loaded.session_manager,
            loaded.run_memory_store,
        ).project(task.session_id)
        filtered_process = _filter_task_process_projection(process_projection, task_id)
        return {
            **task_service.to_api_dict(task),
            "events": [event.to_dict() for event in task_service.list_events(task_id)],
            "runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store),  # type: ignore[arg-type]
            "process_runs": filtered_process["runs"],
            "process_events": filtered_process["events"],
            "process_artifacts": filtered_process["artifacts"],
        }

    @app.delete("/api/tasks/{task_id}")
    async def delete_task(task_id: str, request: Request) -> dict[str, Any]:
        loaded = get_agent_service(request).create_loop().boot()
        task_service = loaded.task_service
        if task_service is None:
            raise HTTPException(status_code=404, detail="Task service is unavailable")
        if not task_service.delete_task(task_id):
            raise HTTPException(status_code=404, detail="Task not found")
        return {"ok": True, "task_id": task_id}

    @app.post(
        "/api/chat",
        response_model=WebChatResponse,
        responses={
            400: {"model": WebErrorResponse},
            409: {"model": WebErrorResponse},
            503: {"model": WebErrorResponse},
        },
    )
    async def chat(request: Request, payload: WebChatRequest) -> WebChatResponse:
        agent_service = get_agent_service(request)
        message = payload.message.strip()
        if not message:
            raise HTTPException(status_code=400, detail="'message' is required")

        reply_to_scheduled_run_id = _clean_text(payload.reply_to_scheduled_run_id)
        if reply_to_scheduled_run_id:
            cron_service = get_cron_service(request)
            found = cron_service.get_run(reply_to_scheduled_run_id)
            if found is None:
                raise HTTPException(status_code=404, detail="Notification not found")
            job, run = found
            intent = _scheduled_reply_intent(payload.scheduled_reply_intent)
            try:
                reply_kwargs = {
                    "job": job,
                    "run": run,
                    "intent": intent,
                }
                if payload.thinking_enabled is not None:
                    reply_kwargs["thinking_enabled"] = payload.thinking_enabled
                result = await agent_service.submit_scheduled_reply(message, **reply_kwargs)
                cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent)
                if intent == "update_future":
                    cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, message))
            except ValueError as exc:
                raise HTTPException(status_code=400, detail=str(exc)) from exc
            except RuntimeError as exc:
                raise HTTPException(status_code=503, detail=str(exc)) from exc

            return WebChatResponse(
                session_id=result.session_id,
                run_id=result.run_id,
                output_text=result.output_text,
                finish_reason=result.finish_reason,
                tool_iterations=result.tool_iterations,
                provider_name=result.provider_name,
                model=result.model,
                usage=result.usage,
                task_id=result.task_id,
                task_status=result.task_status,
                evidence_status="recorded" if result.task_id else None,
                validation_result=None,
            )

        fallback_target = _model_dump(payload.fallback_target)
        auxiliary_target = _model_dump(payload.auxiliary_target)
        embedding_target = _model_dump(payload.embedding_target)

        try:
            direct_kwargs = {
                "session_id": payload.session_id,
                "source": "web",
                "user_id": payload.user_id,
                "title": payload.title,
                "execution_context": payload.execution_context,
                "prompt_locale": payload.prompt_locale,
                "model": payload.model,
                "provider_name": payload.provider_name,
                "embedding_model": payload.embedding_model,
                "temperature": payload.temperature,
                "max_tokens": payload.max_tokens,
                "max_tool_iterations": payload.max_tool_iterations,
                "fallback_target": fallback_target,
                "auxiliary_target": auxiliary_target,
                "embedding_target": embedding_target,
            }
            if payload.thinking_enabled is not None:
                direct_kwargs["thinking_enabled"] = payload.thinking_enabled
            result = await _run_web_direct(agent_service, message, **direct_kwargs)
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        except RuntimeError as exc:
            detail = str(exc)
            if "requires an active run() loop" in detail or "not ready" in detail:
                status_code = 503
            elif "submit_direct" in detail or "running" in detail:
                status_code = 409
            else:
                status_code = 503
            raise HTTPException(status_code=status_code, detail=detail) from exc

        return WebChatResponse(
            session_id=result.session_id,
            run_id=result.run_id,
            output_text=result.output_text,
            finish_reason=result.finish_reason,
            tool_iterations=result.tool_iterations,
            provider_name=result.provider_name,
            model=result.model,
            usage=result.usage,
            task_id=result.task_id,
            task_status=result.task_status,
            evidence_status="recorded" if result.task_id else None,
            validation_result=None,
        )

    @app.websocket("/ws/{session_id:path}")
    async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
        """WebSocket chat adapter.

        This is intentionally a thin Web entrypoint: it delegates to
        AgentService.submit_direct() and returns the same run/task metadata as
        the REST chat endpoint.
        """

        await websocket.accept()
        agent_service = getattr(websocket.app.state, "agent_service", None)
        if not isinstance(agent_service, AgentService):
            await websocket.send_json({"type": "error", "error": "AgentService is not ready"})
            await websocket.close(code=1011)
            return

        while True:
            try:
                payload = await websocket.receive_json()
            except WebSocketDisconnect:
                break
            except ValueError:
                await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
                continue
            if not isinstance(payload, dict):
                await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
                continue

            message_type = (_clean_text(payload.get("type")) or "").lower()
            if message_type == "ping":
                await websocket.send_json({"type": "pong"})
                continue
            if message_type != "message":
                await websocket.send_json(
                    {
                        "type": "error",
                        "error": f"Unsupported websocket message type: {message_type or '<empty>'}",
                    }
                )
                continue

            content = _clean_text(payload.get("content"))
            if not content:
                await websocket.send_json({"type": "error", "error": "'content' is required"})
                continue

            await websocket.send_json({"type": "status", "status": "thinking"})
            try:
                reply_to_scheduled_run_id = _clean_text(payload.get("reply_to_scheduled_run_id"))
                if reply_to_scheduled_run_id:
                    cron_service = get_cron_service(websocket)
                    found = cron_service.get_run(reply_to_scheduled_run_id)
                    if found is None:
                        raise ValueError("Notification not found")
                    job, run = found
                    intent = _scheduled_reply_intent(payload.get("scheduled_reply_intent"))
                    reply_kwargs = {
                        "job": job,
                        "run": run,
                        "intent": intent,
                    }
                    websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
                    if websocket_thinking_enabled is not None:
                        reply_kwargs["thinking_enabled"] = websocket_thinking_enabled
                    result = await agent_service.submit_scheduled_reply(content, **reply_kwargs)
                    cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent)
                    if intent == "update_future":
                        cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, content))
                else:
                    direct_kwargs = {
                        "session_id": session_id,
                        "source": "websocket",
                        "user_id": _clean_text(payload.get("user_id")) or None,
                        "title": _clean_text(payload.get("title")) or None,
                        "execution_context": _clean_text(payload.get("execution_context")) or None,
                        "prompt_locale": _clean_text(payload.get("prompt_locale")) or None,
                        "model": _clean_text(payload.get("model")) or None,
                        "provider_name": _clean_text(payload.get("provider_name")) or None,
                        "embedding_model": _clean_text(payload.get("embedding_model")) or None,
                        "max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")),
                    }
                    websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
                    if websocket_thinking_enabled is not None:
                        direct_kwargs["thinking_enabled"] = websocket_thinking_enabled
                    result = await _run_web_direct(agent_service, content, **direct_kwargs)
            except Exception as exc:
                await websocket.send_json(
                    {
                        "type": "message",
                        "role": "assistant",
                        "content": f"Run failed before completion: {exc}",
                        "session_id": session_id,
                        "finish_reason": "error",
                        "tool_iterations": 0,
                        "metadata": {
                            "error": str(exc),
                            "input_metadata": _websocket_input_metadata(payload),
                        },
                    }
                )
                continue

            await websocket.send_json(_websocket_message_payload(result, input_payload=payload))
            await websocket.send_json(
                {
                    "type": "session_updated",
                    "session_id": result.session_id,
                    "source": "websocket",
                }
            )

    @app.post(
        "/api/chat/acceptance",
        response_model=WebChatAcceptanceResponse,
        responses={
            400: {"model": WebErrorResponse},
            404: {"model": WebErrorResponse},
        },
    )
    async def chat_acceptance(request: Request, payload: WebChatAcceptanceRequest) -> WebChatAcceptanceResponse:
        agent_service = get_agent_service(request)
        try:
            result = await agent_service.submit_acceptance(
                session_id=payload.session_id,
                run_id=payload.run_id,
                acceptance_type=payload.acceptance_type,
                comment=payload.comment,
            )
        except ValueError as exc:
            detail = str(exc)
            status_code = 404 if "No internal task" in detail else 400
            raise HTTPException(status_code=status_code, detail=detail) from exc

        return WebChatAcceptanceResponse(**result)

    @app.post(
        "/api/chat/feedback",
        response_model=WebChatFeedbackResponse,
        responses={
            400: {"model": WebErrorResponse},
            404: {"model": WebErrorResponse},
        },
    )
    async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
        agent_service = get_agent_service(request)
        try:
            result = await agent_service.submit_acceptance(
                session_id=payload.session_id,
                run_id=payload.run_id,
                acceptance_type=payload.feedback_type,
                comment=payload.comment,
            )
        except ValueError as exc:
            detail = str(exc)
            status_code = 404 if "No internal task" in detail else 400
            raise HTTPException(status_code=status_code, detail=detail) from exc

        return WebChatFeedbackResponse(**result)

    return app


def _session_detail(session_manager: Any, session_id: str, session: dict[str, Any]) -> dict[str, Any]:
    messages = []
    for event in session_manager.get_messages_as_conversation(session_id):
        role = event.get("role")
        if role not in {"user", "assistant"}:
            continue
        content = event.get("content") or ""
        comparable_content = str(content).replace("\u200b", "").replace("\u200c", "").replace("\u200d", "").replace("\ufeff", "")
        if role == "assistant" and not comparable_content.strip():
            continue
        content = _sanitize_user_visible_assistant_content(role=role, content=content)
        messages.append(
            {
                "role": role,
                "content": content,
                "timestamp": _iso_from_timestamp(event.get("timestamp")),
                "run_id": event.get("run_id"),
                "task_id": event.get("task_id"),
                "task_status": event.get("task_status"),
                "evidence_status": event.get("evidence_status"),
                "acceptance_state": event.get("acceptance_state"),
                "feedback_state": event.get("feedback_state"),
                "feedback_error": event.get("feedback_error"),
                "message_type": event.get("message_type"),
                "scheduled_job_id": event.get("scheduled_job_id"),
                "scheduled_run_id": event.get("scheduled_run_id"),
                "cron_job_name": event.get("cron_job_name"),
            }
        )
    return {
        "key": session_id,
        "messages": messages,
        "created_at": _iso_from_timestamp(session.get("started_at")),
        "updated_at": _iso_from_timestamp(session.get("last_active")),
    }


async def _run_web_direct(agent_service: AgentService, message: str, **kwargs: Any) -> Any:
    if agent_service.is_running:
        return await agent_service.submit_direct(message, **kwargs)
    return await agent_service.process_direct(message, **kwargs)


def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]:
    try:
        archive = zipfile.ZipFile(io.BytesIO(content), "r")
    except zipfile.BadZipFile as exc:
        raise ValueError("Invalid zip archive") from exc
    with archive:
        file_infos = [info for info in archive.infolist() if not info.is_dir()]
        if not file_infos:
            raise ValueError("Zip archive is empty")
        skill_entries = []
        safe_entries: list[tuple[Any, str, tuple[str, ...]]] = []
        for info in file_infos:
            raw = info.filename.replace("\\", "/")
            parts = Path(raw).parts
            if "__MACOSX" in parts or Path(raw).name == ".DS_Store":
                continue
            if raw.startswith("/") or any(part in {"", ".", ".."} for part in parts):
                raise ValueError(f"Unsafe archive entry: {info.filename}")
            safe_entries.append((info, raw, tuple(parts)))
            if _is_skill_markdown_entry(parts[-1]):
                skill_entries.append(raw)
        if not skill_entries:
            raise ValueError("Zip must contain SKILL.md")
        if len(skill_entries) > 1:
            raise ValueError("Zip must contain exactly one SKILL.md")
        skill_entry = skill_entries[0]
        skill_root = tuple(Path(skill_entry).parts[:-1])
        raw_skill = archive.read(skill_entry).decode("utf-8", errors="replace")
        frontmatter, body = parse_frontmatter(raw_skill)
        skill_name = str(frontmatter.get("name") or (skill_root[-1] if skill_root else "") or Path(filename).stem).strip().replace(" ", "-")
        if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}:
            raise ValueError("Could not determine a safe skill name")
        proposed_frontmatter = normalize_skill_frontmatter(
            {
                **dict(frontmatter),
                "name": skill_name,
                "description": frontmatter.get("description") or skill_name,
            },
            skill_name=skill_name,
        )
        proposed_frontmatter["tools"] = _merge_tool_names(
            proposed_frontmatter.get("tools"),
            extract_required_tool_names(body),
            _infer_uploaded_skill_tools(
                skill_name=skill_name,
                filename=filename,
                frontmatter=proposed_frontmatter,
                content=body,
                loaded=loaded,
            ),
        )
        proposed_content = ensure_canonical_skill_body(
            body,
            title=skill_name,
            description=str(proposed_frontmatter.get("description") or ""),
            tools=list(proposed_frontmatter.get("tools") or []),
        )
        files: list[tuple[str, bytes]] = []
        for info, raw, parts in safe_entries:
            if raw == skill_entry:
                continue
            if skill_root:
                if parts[: len(skill_root)] != skill_root:
                    continue
                rel_parts = parts[len(skill_root):]
            else:
                rel_parts = parts
            if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts):
                raise ValueError(f"Unsafe archive entry: {info.filename}")
            files.append(("/".join(rel_parts), archive.read(info)))
    draft = loaded.draft_service.create_new_skill_draft(
        skill_name=skill_name,
        proposed_content=proposed_content,
        proposed_frontmatter=proposed_frontmatter,
        created_by="web-upload",
        reason=f"Uploaded {filename}",
        evidence_refs=[{"kind": "upload", "filename": filename, "files": sorted(path for path, _ in files)}],
    )
    upload_dir = loaded.skill_spec_store.root / skill_name / "draft_uploads" / draft.draft_id
    upload_dir.mkdir(parents=True, exist_ok=True)
    for rel_path, file_bytes in files:
        if rel_path == "SKILL.md":
            continue
        target = upload_dir / rel_path
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(file_bytes)
    draft.evidence_refs = [
        {
            "kind": "upload",
            "filename": filename,
            "files": sorted(path for path, _ in files),
            "supporting_upload_dir": str(upload_dir),
        }
    ]
    loaded.skill_spec_store.write_draft(draft)
    return draft.to_dict()


def _is_skill_markdown_entry(filename: str) -> bool:
    return filename.strip().lower() in {"skill.md", "skills.md"}


def _merge_tool_names(*groups: Any) -> list[str]:
    result: list[str] = []
    for group in groups:
        if isinstance(group, str):
            raw_items = group.split(",")
        elif isinstance(group, (list, tuple, set)):
            raw_items = list(group)
        else:
            raw_items = []
        for item in raw_items:
            cleaned = str(item).strip()
            if cleaned and cleaned not in result:
                result.append(cleaned)
    return result


def _infer_uploaded_skill_tools(
    *,
    skill_name: str,
    filename: str,
    frontmatter: dict[str, Any],
    content: str,
    loaded: Any,
) -> list[str]:
    available = _available_runtime_tool_names(loaded)
    text = "\n".join(
        [
            skill_name,
            filename,
            json.dumps(frontmatter, ensure_ascii=False, sort_keys=True),
            content,
        ]
    ).lower()
    inferred: list[str] = []

    for tool_name in sorted(available or _COMMON_RUNTIME_TOOL_NAMES):
        if re.search(rf"(?<![a-z0-9_]){re.escape(tool_name.lower())}(?![a-z0-9_])", text):
            inferred.append(tool_name)

    def add_if_available(*tool_names: str) -> None:
        for tool_name in tool_names:
            if available is not None and tool_name not in available:
                continue
            if tool_name not in inferred:
                inferred.append(tool_name)

    if re.search(r"\b(weather|forecast|temperature|precipitation|rain|snow|humidity|wind|air quality|aqi)\b", text):
        add_if_available("web_fetch", "web_search")
    if re.search(r"\b(latest|current|today|tomorrow|news|search|query|lookup|find online|web search)\b", text):
        add_if_available("web_search")
    if re.search(r"\b(url|http|https|website|webpage|page|fetch|crawl|browser|online source)\b", text):
        add_if_available("web_fetch")

    return inferred


def _available_runtime_tool_names(loaded: Any) -> set[str] | None:
    registry = getattr(loaded, "tool_registry", None)
    if registry is None:
        return None
    try:
        return {spec.name for spec in registry.list_specs()}
    except Exception:
        return None


_COMMON_RUNTIME_TOOL_NAMES = {
    "web_fetch",
    "web_search",
    "read_file",
    "write_file",
    "patch_file",
    "search_files",
    "list_directory",
    "memory",
    "terminal",
    "process",
    "execute_code",
    "skill_view",
    "skills_list",
    "skill_manage",
    "cron",
}


async def _rewrite_uploaded_skill_draft_with_llm(agent_service: Any, loaded: Any, draft: Any, *, filename: str) -> None:
    try:
        provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {})  # noqa: SLF001
        provider = getattr(provider_bundle, "auxiliary_provider", None) or getattr(provider_bundle, "main_provider", None)
        runtime = getattr(provider_bundle, "auxiliary_runtime", None) or getattr(provider_bundle, "main_runtime", None)
        if provider is None:
            return
        available_tool_names = sorted(_available_runtime_tool_names(loaded) or _COMMON_RUNTIME_TOOL_NAMES)
        response = await provider.chat(
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You rewrite uploaded Beaver skills into the required house style. "
                        "Return only JSON with keys: frontmatter, content, change_reason. "
                        "Do not include markdown fences."
                    ),
                },
                {
                    "role": "user",
                    "content": (
                        f"Uploaded filename: {filename}\n"
                        f"Skill name: {draft.skill_name}\n"
                        f"Current frontmatter:\n{json.dumps(draft.proposed_frontmatter, ensure_ascii=False, sort_keys=True)}\n\n"
                        f"Current content:\n{draft.proposed_content}\n\n"
                        f"Available runtime tool names:\n{json.dumps(available_tool_names, ensure_ascii=False)}\n\n"
                        f"{canonical_skill_format_instructions()}\n\n"
                        "Rewrite the skill so it is operational, concrete, and ready for review/publish. "
                        "Infer exact required runtime tools from the uploaded content when the workflow depends on tools. "
                        "Keep frontmatter.tools and the Required Tools section consistent."
                    ),
                },
            ],
            tools=None,
            model=getattr(runtime, "model", None),
            max_tokens=4096,
            temperature=0,
        )
        payload = parse_skill_rewrite_json(response.content or "", skill_name=draft.skill_name)
        if payload is None:
            return
        payload["frontmatter"]["tools"] = _merge_tool_names(
            payload["frontmatter"].get("tools"),
            extract_required_tool_names(payload["content"]),
            _infer_uploaded_skill_tools(
                skill_name=draft.skill_name,
                filename=filename,
                frontmatter=payload["frontmatter"],
                content=payload["content"],
                loaded=loaded,
            ),
        )
        payload["content"] = ensure_canonical_skill_body(
            payload["content"],
            title=str(payload["frontmatter"].get("name") or draft.skill_name),
            description=str(payload["frontmatter"].get("description") or ""),
            tools=list(payload["frontmatter"].get("tools") or []),
        )
        draft.proposed_frontmatter = payload["frontmatter"]
        draft.proposed_content = payload["content"]
        if payload.get("change_reason"):
            draft.reason = f"{draft.reason}; LLM rewrite: {payload['change_reason']}"
        loaded.skill_spec_store.write_draft(draft)
    except Exception:
        return


def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[str, Any]]:
    grouped: dict[str, list[Any]] = {}
    run_order: list[str] = []
    for record in session_manager.get_event_records(session_id):
        if not record.run_id:
            continue
        if record.run_id not in grouped:
            grouped[record.run_id] = []
            run_order.append(record.run_id)
        grouped[record.run_id].append(record)

    runs: list[dict[str, Any]] = []
    for run_id in run_order:
        records = grouped[run_id]
        started = next((item for item in records if item.event_type == "run_started"), None)
        completed = next(
            (item for item in reversed(records) if item.event_type in {"run_completed", "run_failed"}),
            None,
        )
        user_event = next((item for item in records if item.event_type == "user_message_added"), None)
        intent_event = next((item for item in records if item.event_type == "intent_agent_decision_snapshotted"), None)
        task_id = None
        attempt_index = None
        task_mode = None
        intent_decision = intent_event.event_payload if intent_event is not None else None
        source = None
        title = None
        if started is not None and isinstance(started.event_payload, dict):
            task_id = started.event_payload.get("task_id")
            attempt_index = started.event_payload.get("attempt_index")
            task_mode = started.event_payload.get("task_mode")
            source = started.event_payload.get("source")
            if intent_decision is None:
                intent_decision = started.event_payload.get("intent_agent_decision")
        if started is not None:
            title = getattr(started, "title", None)
        if title is None:
            title = source or "run"
        runs.append(
            {
                "run_id": run_id,
                "session_id": session_id,
                "title": title,
                "source": source,
                "task_id": task_id,
                "attempt_index": attempt_index,
                "task_mode": task_mode,
                "intent_agent_choice": intent_decision.get("choice") if isinstance(intent_decision, dict) else None,
                "intent_agent_decision": intent_decision if isinstance(intent_decision, dict) else None,
                "user_input": user_event.content if user_event is not None else "",
                "started_at": _iso_from_timestamp(started.timestamp if started is not None else None),
                "ended_at": _iso_from_timestamp(completed.timestamp) if completed is not None else None,
                "finish_reason": completed.finish_reason if completed is not None else None,
                "events": [_debug_event_to_dict(item) for item in records],
            }
        )
    return runs


def _debug_event_to_dict(record: Any) -> dict[str, Any]:
    return {
        "message_id": record.message_id,
        "run_id": record.run_id,
        "role": record.role,
        "event_type": record.event_type,
        "content": record.content,
        "timestamp": _iso_from_timestamp(record.timestamp),
        "context_visible": record.context_visible,
        "tool_name": record.tool_name,
        "tool_call_id": record.tool_call_id,
        "tool_calls": record.tool_calls,
        "finish_reason": record.finish_reason,
        "reasoning": record.reasoning,
        "reasoning_details": record.reasoning_details,
        "codex_reasoning_items": record.codex_reasoning_items,
        "event_payload": record.event_payload,
    }


def _notification_summary(job: Any, run: CronRunRecord) -> dict[str, Any]:
    return {
        "scheduled_run_id": run.scheduled_run_id,
        "job_id": job.id,
        "job_name": job.name,
        "title": job.name,
        "message": job.payload.message,
        "status": run.status,
        "mode": run.mode,
        "started_at_ms": run.started_at_ms,
        "finished_at_ms": run.finished_at_ms,
        "started_at": _iso_from_ms(run.started_at_ms),
        "finished_at": _iso_from_ms(run.finished_at_ms),
        "output": run.output,
        "error": run.error,
        "notification_session_id": run.notification_session_id or NOTIFICATION_SESSION_ID,
        "task_id": run.task_id,
        "run_id": run.run_id,
        "engaged": run.engaged,
        "engaged_at_ms": run.engaged_at_ms,
        "engage_intent": run.engage_intent,
    }


def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memory_store: Any) -> list[dict[str, Any]]:
    run_records = {record.run_id: record for record in run_memory_store.list_runs()}
    labels = _agent_labels_for_task_events(events)
    views: list[dict[str, Any]] = []
    seen: set[str] = set()
    for index, run_id in enumerate(task.run_ids):
        if run_id in seen:
            continue
        seen.add(run_id)
        run_record = run_records.get(run_id)
        session_id = run_record.session_id if run_record is not None else task.session_id
        messages = []
        for record in session_manager.get_run_event_records(session_id, run_id):
            if record.role not in {"user", "assistant", "tool"}:
                continue
            content = (record.content or "").strip()
            if not content:
                continue
            content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
            messages.append(
                {
                    "role": record.role,
                    "content": content,
                    "created_at": _iso_from_timestamp(record.timestamp),
                    "tool_name": record.tool_name,
                }
            )
        views.append(
            {
                "run_id": run_id,
                "title": labels.get(run_id) or ("主 Agent" if index == len(task.run_ids) - 1 else f"Agent {index + 1}"),
                "session_id": session_id,
                "started_at": run_record.started_at if run_record is not None else None,
                "ended_at": run_record.ended_at if run_record is not None else None,
                "success": run_record.success if run_record is not None else None,
                "finish_reason": run_record.finish_reason if run_record is not None else None,
                "attempt_index": run_record.attempt_index if run_record is not None else None,
                "task_text": run_record.task_text if run_record is not None else "",
                "messages": messages,
                "evidence_status": "recorded",
                "validation_result": None,
            }
        )
    return views


def _filter_task_process_projection(projection: dict[str, Any], task_id: str) -> dict[str, list[dict[str, Any]]]:
    def belongs_to_task(item: dict[str, Any]) -> bool:
        metadata = item.get("metadata")
        return isinstance(metadata, dict) and metadata.get("task_id") == task_id

    def with_task_metadata(item: dict[str, Any]) -> dict[str, Any]:
        copied = dict(item)
        metadata = dict(copied.get("metadata") or {})
        metadata.setdefault("task_id", task_id)
        copied["metadata"] = metadata
        return copied

    runs = [with_task_metadata(item) for item in projection.get("runs", []) if isinstance(item, dict) and belongs_to_task(item)]
    run_ids = {str(item.get("run_id")) for item in runs if item.get("run_id")}
    events = [
        with_task_metadata(item)
        for item in projection.get("events", [])
        if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
    ]
    artifacts = [
        with_task_metadata(item)
        for item in projection.get("artifacts", [])
        if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
    ]
    return {"runs": runs, "events": events, "artifacts": artifacts}


def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]:
    labels: dict[str, str] = {}
    for event in events:
        payload = dict(getattr(event, "payload", {}) or {})
        for item in payload.get("node_results") or []:
            if not isinstance(item, dict):
                continue
            run_id = str(item.get("run_id") or "")
            node_id = str(item.get("node_id") or "").strip()
            if run_id and node_id:
                labels[run_id] = node_id
        main_run_id = str(payload.get("main_run_id") or "")
        if main_run_id:
            labels[main_run_id] = "主 Agent"
    return labels


def _scheduled_reply_intent(value: Any) -> str:
    cleaned = str(value or "").strip().lower()
    if cleaned == "update_future":
        return "update_future"
    if cleaned == "continue_task":
        return "continue_task"
    return "revise_once"


def _updated_scheduled_instruction(current: str, request: str) -> str:
    cleaned_current = " ".join((current or "").strip().split())
    cleaned_request = " ".join((request or "").strip().split())
    if not cleaned_request:
        return cleaned_current
    return f"{cleaned_current}\n\n以后执行时请遵循：{cleaned_request}"


def _iso_from_ms(value: Any) -> str | None:
    if value in (None, ""):
        return None
    try:
        return _iso_from_timestamp(float(value) / 1000)
    except (TypeError, ValueError):
        return None


def _iso_from_timestamp(value: Any) -> str:
    from datetime import datetime, timezone

    if value in (None, ""):
        return datetime.now(timezone.utc).isoformat()
    try:
        return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
    except (TypeError, ValueError):
        return str(value)


def _registered_agent_to_ui(agent: Any) -> dict[str, Any]:
    metadata = dict(agent.metadata or {})
    source = agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace"
    aliases = metadata.get("aliases")
    return {
        "id": agent.agent_id,
        "name": agent.display_name or agent.name,
        "description": agent.description,
        "source": source,
        "kind": metadata.get("kind") or ("local_subagent" if metadata.get("local_subagent") else "specialist"),
        "protocol": metadata.get("protocol"),
        "endpoint": metadata.get("endpoint"),
        "base_url": metadata.get("base_url"),
        "card_url": metadata.get("card_url"),
        "auth_env": metadata.get("auth_env"),
        "auth_mode": metadata.get("auth_mode") or "none",
        "auth_audience": metadata.get("auth_audience"),
        "auth_scopes": _coerce_str_list(metadata.get("auth_scopes")),
        "tags": list(agent.tags),
        "aliases": _coerce_str_list(aliases) or [agent.name],
        "metadata": {
            **metadata,
            "role": agent.role,
            "capabilities": list(agent.capabilities),
            "skill_names": list(agent.skill_names),
            "tool_hints": list(agent.tool_hints),
            "priority": agent.priority,
            "status": agent.status,
        },
        "support_streaming": bool(metadata.get("support_streaming", False)),
    }


def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]:
    metadata = dict(payload.get("metadata") or {})
    for key in (
        "protocol",
        "endpoint",
        "base_url",
        "card_url",
        "auth_env",
        "auth_mode",
        "auth_audience",
        "auth_scopes",
        "aliases",
        "support_streaming",
    ):
        if key in payload:
            metadata[key] = payload.get(key)
    if metadata.get("protocol") == "a2a" or metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url"):
        metadata.setdefault("kind", "a2a_remote")
    capabilities = payload.get("capabilities")
    if capabilities is None and isinstance(metadata.get("capabilities"), list):
        capabilities = metadata.get("capabilities")
    role = payload.get("role") or metadata.get("role") or payload.get("kind") or ""
    agent_id = payload.get("agent_id") or payload.get("id") or payload.get("name") or _derive_agent_id_from_metadata(metadata)
    return {
        "agent_id": agent_id,
        "name": payload.get("name") or payload.get("id") or agent_id,
        "display_name": payload.get("display_name") or payload.get("name") or payload.get("id") or agent_id,
        "role": role,
        "description": payload.get("description") or "",
        "system_prompt": payload.get("system_prompt") or metadata.get("system_prompt") or "",
        "capabilities": capabilities or [],
        "skill_names": payload.get("skill_names") or metadata.get("skill_names") or [],
        "tool_hints": payload.get("tool_hints") or metadata.get("tool_hints") or [],
        "model": payload.get("model") or metadata.get("model"),
        "provider_name": payload.get("provider_name") or metadata.get("provider_name"),
        "tags": payload.get("tags") or [],
        "priority": payload.get("priority") or metadata.get("priority") or 0,
        "status": payload.get("status") or ("active" if payload.get("enabled", True) else "disabled"),
        "source": payload.get("source") or "workspace",
        "metadata": metadata,
    }


def _derive_agent_id_from_metadata(metadata: dict[str, Any]) -> str:
    raw = metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url") or "workspace-agent"
    text = str(raw).strip().lower()
    for prefix in ("https://", "http://"):
        if text.startswith(prefix):
            text = text[len(prefix):]
    text = text.split("/", 1)[0] or text
    normalized = "".join(ch if ch.isalnum() else "-" for ch in text).strip("-")
    while "--" in normalized:
        normalized = normalized.replace("--", "-")
    return normalized or "workspace-agent"


def _mcp_server_view(server_id: str, cfg: Any, report: dict[str, Any]) -> dict[str, Any]:
    transport = "stdio" if getattr(cfg, "command", "") else "http"
    tool_names = list(report.get("tool_names") or [])
    return {
        "id": server_id,
        "name": getattr(cfg, "display_name", "") or server_id,
        "transport": transport,
        "kind": getattr(cfg, "kind", "local" if transport == "stdio" else "online"),
        "category": getattr(cfg, "category", "local" if transport == "stdio" else "online"),
        "managed": bool(getattr(cfg, "managed", False)),
        "source": getattr(cfg, "source", "config"),
        "url": getattr(cfg, "url", "") or None,
        "command": getattr(cfg, "command", "") or None,
        "args": list(getattr(cfg, "args", []) or []),
        "env": _redact_mapping(dict(getattr(cfg, "env", {}) or {})),
        "headers": _redact_mapping(dict(getattr(cfg, "headers", {}) or {})),
        "auth_mode": getattr(cfg, "auth_mode", "none") or "none",
        "auth_audience": getattr(cfg, "auth_audience", "") or None,
        "auth_scopes": list(getattr(cfg, "auth_scopes", []) or []),
        "enabled": True,
        "tool_timeout": getattr(cfg, "tool_timeout", 30),
        "tool_count": int(report.get("tool_count") or len(tool_names)),
        "tool_names": tool_names,
        "status": report.get("status") or "disconnected",
        "last_error": report.get("last_error"),
        "sensitive": bool(getattr(cfg, "sensitive", False)),
    }


def _mcp_config_payload(payload: dict[str, Any], server_id: str) -> dict[str, Any]:
    command = _clean_text(payload.get("command")) or ""
    url = _clean_text(payload.get("url")) or ""
    auth_mode = (_clean_text(payload.get("auth_mode") or payload.get("authMode")) or "none").lower()
    auth_audience = _clean_text(payload.get("auth_audience") or payload.get("authAudience")) or ""
    if auth_mode == "oauth_backend_token" and not auth_audience:
        auth_audience = f"mcp:{server_id}"
    return {
        "command": command,
        "args": _coerce_str_list(payload.get("args")),
        "env": _coerce_str_dict(payload.get("env")),
        "url": url,
        "headers": _coerce_str_dict(payload.get("headers")),
        "authMode": auth_mode,
        "authAudience": auth_audience,
        "authScopes": _coerce_str_list(payload.get("auth_scopes") or payload.get("authScopes")),
        "toolTimeout": int(payload.get("tool_timeout") or payload.get("toolTimeout") or 30),
        "sensitive": bool(payload.get("sensitive", False)),
        "kind": _clean_text(payload.get("kind")) or ("local" if command else "online"),
        "category": _clean_text(payload.get("category")) or ("local" if command else "online"),
        "managed": bool(payload.get("managed", False)),
        "displayName": _clean_text(payload.get("display_name") or payload.get("displayName")) or server_id,
        "source": _clean_text(payload.get("source")) or "config",
    }


def _coerce_str_list(value: Any) -> list[str]:
    if not isinstance(value, list):
        return []
    return [str(item) for item in value if str(item).strip()]


def _coerce_str_dict(value: Any) -> dict[str, str]:
    if not isinstance(value, dict):
        return {}
    return {str(key): str(item) for key, item in value.items() if item is not None}


def _redact_mapping(value: dict[str, str]) -> dict[str, str]:
    redacted = {}
    for key, item in value.items():
        if any(token in key.lower() for token in ("key", "token", "secret", "authorization")):
            redacted[key] = _mask_secret(item)
        else:
            redacted[key] = item
    return redacted


def _model_dump(value: Any) -> dict[str, Any] | None:
    """兼容 Pydantic v1/v2 的最小导出辅助。"""

    if value is None:
        return None
    if hasattr(value, "model_dump"):
        return value.model_dump(exclude_none=True)
    if hasattr(value, "dict"):
        return value.dict(exclude_none=True)
    return dict(value)


def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
    metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
    result: dict[str, Any] = dict(metadata)
    attachments = payload.get("attachments")
    if isinstance(attachments, list):
        result["attachments"] = attachments
    return result


def _bool_or_none(value: Any) -> bool | None:
    if isinstance(value, bool):
        return value
    if value is None:
        return None
    if isinstance(value, str):
        normalized = value.strip().lower()
        if normalized in {"1", "true", "yes", "on"}:
            return True
        if normalized in {"0", "false", "no", "off"}:
            return False
    return None


def _int_or_none(value: Any) -> int | None:
    if value in (None, ""):
        return None
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
    task_id = getattr(result, "task_id", None)
    task_status = getattr(result, "task_status", None)
    return {
        "type": "message",
        "role": "assistant",
        "content": _sanitize_user_visible_assistant_content(
            role="assistant",
            content=getattr(result, "output_text", "") or "",
        ),
        "session_id": getattr(result, "session_id", None),
        "run_id": getattr(result, "run_id", None),
        "finish_reason": getattr(result, "finish_reason", None),
        "tool_iterations": getattr(result, "tool_iterations", 0),
        "provider_name": getattr(result, "provider_name", None),
        "model": getattr(result, "model", None),
        "usage": dict(getattr(result, "usage", {}) or {}),
        "task_id": task_id,
        "task_status": task_status,
        "evidence_status": "recorded" if task_id else None,
        "validation_result": None,
        "metadata": {
            "task_id": task_id,
            "task_status": task_status,
            "evidence_status": "recorded" if task_id else None,
            "input_metadata": _websocket_input_metadata(input_payload),
        },
    }


def _sanitize_user_visible_assistant_content(*, role: str, content: str) -> str:
    if role != "assistant":
        return content
    if _looks_like_raw_tool_call(content):
        return RAW_TOOL_CALL_DISPLAY_FALLBACK
    return content


def _looks_like_raw_tool_call(content: str | None) -> bool:
    if not content:
        return False
    stripped = content.strip()
    lowered = stripped.lower()
    return (
        lowered.startswith("<tool_call")
        and lowered.endswith("</tool_call>")
    ) or (
        lowered.startswith("<function=")
        and lowered.endswith("</function>")
    )


def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
    if provider_cfg is None or provider_name == "custom":
        return False
    return any(
        [
            _clean_text(getattr(provider_cfg, "api_key", None)),
            _clean_text(getattr(provider_cfg, "api_base", None)),
            bool(getattr(provider_cfg, "extra_headers", None)),
        ]
    )


def _auth_file_path() -> Path:
    raw = os.getenv("BEAVER_AUTH_FILE")
    if raw:
        return Path(raw)
    return Path.home() / ".beaver" / "web_auth_users.json"


def _load_auth_users(path: Path) -> dict[str, str]:
    if not path.exists():
        raise HTTPException(status_code=500, detail=f"Auth file not found: {path}")
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=500, detail=f"Invalid auth file: {path}") from exc

    users: dict[str, str] = {}
    if isinstance(raw, dict):
        entries = raw.get("users") or raw.get("accounts")
        if isinstance(entries, list):
            for entry in entries:
                if not isinstance(entry, dict):
                    continue
                username = _clean_text(entry.get("username"))
                password = entry.get("password")
                if username and isinstance(password, str):
                    users[username] = password
        for key, value in raw.items():
            if key in {"users", "accounts"}:
                continue
            username = _clean_text(key)
            if username and isinstance(value, str):
                users[username] = value
    if not users:
        raise HTTPException(status_code=500, detail=f"No valid users found in auth file: {path}")
    return users


def _load_auth_users_if_present(path: Path) -> dict[str, str]:
    if not path.exists():
        return {}
    return _load_auth_users(path)


def _save_auth_users(path: Path, users: dict[str, str]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "users": [
            {"username": username, "password": password}
            for username, password in sorted(users.items())
        ]
    }
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def _issue_web_token(app: FastAPI, username: str) -> str:
    token = secrets.token_urlsafe(32)
    app.state.auth_tokens[token] = username
    return token


def _handoff_ttl_seconds() -> int:
    raw = os.getenv("BEAVER_HANDOFF_CODE_TTL_SECONDS", "90").strip()
    try:
        return max(15, int(raw))
    except ValueError:
        return 90


def _handoff_replay_window_seconds() -> int:
    raw = os.getenv("BEAVER_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
    try:
        return max(1, int(raw))
    except ValueError:
        return 15


def _int_env(name: str, default: int) -> int:
    raw = os.getenv(name, "").strip()
    if not raw:
        return default
    try:
        value = int(raw)
    except ValueError:
        return default
    return value if value > 0 else default


def _human_upload_size(size: int) -> str:
    units = ("B", "KB", "MB", "GB", "TB")
    value = float(size)
    for unit in units:
        if value < 1024 or unit == units[-1]:
            return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
        value /= 1024
    return f"{size}B"


def _prune_handoff_codes(app: FastAPI) -> None:
    now = time.time()
    replay_window = _handoff_replay_window_seconds()
    expired = []
    for code, payload in list(app.state.handoff_codes.items()):
        expires_at = float(payload.get("expires_at") or 0)
        consumed_at = payload.get("consumed_at")
        if expires_at <= now:
            expired.append(code)
        elif consumed_at is not None and now - float(consumed_at) > replay_window:
            expired.append(code)
    for code in expired:
        app.state.handoff_codes.pop(code, None)


def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]:
    _prune_handoff_codes(app)
    code = secrets.token_urlsafe(24)
    expires_at = int(time.time()) + _handoff_ttl_seconds()
    app.state.handoff_codes[code] = {
        "username": username,
        "access_token": access_token,
        "refresh_token": refresh_token,
        "expires_at": expires_at,
        "consumed_at": None,
    }
    return code, expires_at


def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]:
    if not code.strip():
        raise HTTPException(status_code=400, detail="Handoff code is required")
    _prune_handoff_codes(app)
    payload = app.state.handoff_codes.get(code)
    if payload is None:
        raise HTTPException(status_code=401, detail="Invalid or expired handoff code")
    now = time.time()
    expires_at = float(payload.get("expires_at") or 0)
    if expires_at <= now:
        app.state.handoff_codes.pop(code, None)
        raise HTTPException(status_code=410, detail="Handoff code expired")
    consumed_at = payload.get("consumed_at")
    if consumed_at is None:
        payload["consumed_at"] = now
    elif now - float(consumed_at) > _handoff_replay_window_seconds():
        app.state.handoff_codes.pop(code, None)
        raise HTTPException(status_code=410, detail="Handoff code already used")
    username = str(payload.get("username") or "").strip()
    access_token = str(payload.get("access_token") or "").strip()
    if not username or not access_token:
        app.state.handoff_codes.pop(code, None)
        raise HTTPException(status_code=401, detail="Invalid handoff payload")
    return {
        "access_token": access_token,
        "refresh_token": str(payload.get("refresh_token") or ""),
        "token_type": "bearer",
        "user_id": username,
        "username": username,
        "role": "owner",
    }


def _require_web_user(app: FastAPI, authorization: str | None) -> str:
    if not authorization:
        raise HTTPException(status_code=401, detail="Missing Authorization header")
    prefix = "bearer "
    if not authorization.lower().startswith(prefix):
        raise HTTPException(status_code=401, detail="Invalid Authorization header")
    token = authorization[len(prefix):].strip()
    if not token:
        raise HTTPException(status_code=401, detail="Invalid token")
    username = app.state.auth_tokens.get(token)
    if not username:
        raise HTTPException(status_code=401, detail="Invalid or expired token")
    return username


def _backend_connection_view(request: Request) -> dict[str, Any]:
    public_base_url = (
        os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
        or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
        or str(request.base_url).rstrip("/")
    )
    backend_id = (
        os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID")
        or os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID")
    )
    client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or backend_id
    return {
        "backend_id": backend_id,
        "client_id": client_id,
        "name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or backend_id,
        "public_base_url": public_base_url,
        "api_base_url": public_base_url,
        "frontend_base_url": public_base_url,
        "ws_base_url": public_base_url.replace("http://", "ws://").replace("https://", "wss://", 1),
        "registered": bool(backend_id),
    }


def _local_backend_view() -> dict[str, Any]:
    return {
        "backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID"),
        "client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID"),
        "name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME"),
        "public_base_url": os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
        or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL"),
        "authz": {
            "enabled": os.getenv("BEAVER_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"},
            "base_url": os.getenv("BEAVER_AUTHZ__BASE_URL"),
        },
    }


def _clean_text(value: Any) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    return text or None


def _skill_detail_payload(loaded: Any, name: str, version: str | None) -> dict[str, Any]:
    record = loaded.skills_loader.get_skill_record(name)  # type: ignore[union-attr]
    if record is None:
        raise HTTPException(status_code=404, detail="Skill not found")
    selected_version = version or record.version or "legacy"
    loaded_version = loaded.skill_spec_store.read_published_skill(name, selected_version)  # type: ignore[union-attr]
    if loaded_version is not None:
        content = loaded_version.content
        frontmatter = dict(loaded_version.version.frontmatter)
        version_detail = loaded_version.version.to_dict()
    else:
        if record.source == "workspace" and selected_version != record.version:
            raise HTTPException(status_code=404, detail="Skill version not found")
        content = record.path.read_text(encoding="utf-8")
        frontmatter, _ = parse_frontmatter(content)
        version_detail = {
            "skill_name": name,
            "version": record.version or selected_version,
            "review_state": record.status,
            "frontmatter": dict(frontmatter),
            "summary": record.description,
            "tool_hints": list(record.tool_hints),
            "provenance": {"source": record.source},
        }

    spec = loaded.skill_spec_store.get_skill_spec(name)  # type: ignore[union-attr]
    base_dir = _skill_version_base_dir(loaded, record, selected_version)
    files = _list_skill_files(base_dir)
    versions = _skill_versions_payload(loaded, record)
    return {
        "skill": {
            "name": record.name,
            "description": record.description,
            "source": "builtin" if record.source == "builtin" else "workspace",
            "available": loaded.skills_loader._record_available(record),  # type: ignore[union-attr]
            "path": str(record.path),
            "version": record.version,
            "status": record.status,
            "source_kind": record.source_kind,
            "tool_hints": list(record.tool_hints),
            "provenance": version_detail.get("provenance") or {},
            "agent_cards": [],
        },
        "spec": spec.to_dict() if spec is not None else None,
        "currentVersion": selected_version,
        "versions": versions,
        "versionDetail": version_detail,
        "files": files,
        "content": content,
        "frontmatter": frontmatter,
    }


def _skill_learning_candidate_payload(loaded: Any, candidate: Any) -> dict[str, Any]:
    payload = candidate.to_dict()
    evidence = dict(payload.get("evidence") or {})
    task_text = _skill_learning_candidate_task_text(loaded, candidate)
    if task_text:
        evidence["task_text"] = task_text
        evidence["theme"] = SkillLearningService._task_theme(task_text)
        payload["evidence"] = evidence
        if candidate.kind == "new_skill":
            payload["evidence_summary"] = f"Theme: {evidence['theme']}"
    return payload


def _skill_learning_candidate_task_text(loaded: Any, candidate: Any) -> str:
    evidence = candidate.evidence if isinstance(candidate.evidence, dict) else {}
    task_id = str(evidence.get("task_id") or "").strip()
    source_run_ids = set(candidate.source_run_ids or [])
    try:
        run_store = loaded.skill_learning_pipeline.learning_service.run_store
        runs = run_store.list_runs()
    except Exception:
        return str(evidence.get("task_text") or "").strip()

    if task_id:
        task_runs = [record for record in runs if record.task_id == task_id]
        if task_runs:
            return SkillLearningService._representative_task_text(task_runs)
    source_runs = [record for record in runs if record.run_id in source_run_ids]
    if source_runs:
        return SkillLearningService._representative_task_text(source_runs)
    return str(evidence.get("task_text") or "").strip()


def _skill_draft_payload(loaded: Any, skill_name: str, draft_id: str, *, include_reviews: bool = False) -> dict[str, Any]:
    draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id)  # type: ignore[union-attr]
    safety = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id)  # type: ignore[union-attr]
    eval_report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id)  # type: ignore[union-attr]
    payload = {
        **draft.to_dict(),
        "safety_report": safety.to_dict() if safety is not None else None,
        "eval_report": eval_report.to_dict() if eval_report is not None else None,
        "target_version": _skill_draft_target_version(loaded, draft.skill_name, draft.proposal_kind),
        "base_skill": _skill_draft_base_skill_payload(loaded, draft),
    }
    if include_reviews:
        payload["reviews"] = [
            item.to_dict()
            for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id)  # type: ignore[union-attr]
        ]
    return payload


def _skill_draft_base_skill_payload(loaded: Any, draft: Any) -> dict[str, Any] | None:
    if draft.proposal_kind == "new_skill" or not draft.base_version:
        return None
    store = loaded.skill_learning_pipeline.publisher.store  # type: ignore[union-attr]
    loaded_version = store.read_published_skill(draft.skill_name, draft.base_version)
    if loaded_version is None:
        return None
    version = loaded_version.version
    return {
        "skill_name": version.skill_name,
        "version": version.version,
        "frontmatter": dict(version.frontmatter),
        "content": loaded_version.content,
        "summary": version.summary,
        "tool_hints": list(version.tool_hints),
    }


def _skill_draft_target_version(loaded: Any, skill_name: str, proposal_kind: str) -> str | None:
    if proposal_kind == "retire_skill":
        return None
    versions = [
        item
        for item in loaded.skill_learning_pipeline.publisher.store.list_versions(skill_name)  # type: ignore[union-attr]
        if isinstance(item, str) and item.startswith("v") and item[1:].isdigit()
    ]
    if not versions:
        return "v0001"
    latest = max(int(item[1:]) for item in versions)
    return f"v{latest + 1:04d}"


def _skill_learning_candidate_id_for_draft(loaded: Any, skill_name: str, draft_id: str) -> str | None:
    for candidate in loaded.skill_learning_pipeline.list_candidates():  # type: ignore[union-attr]
        if candidate.draft_skill_name == skill_name and candidate.draft_id == draft_id:
            return candidate.candidate_id
    return None


def _skill_versions_payload(loaded: Any, record: Any) -> list[dict[str, Any]]:
    if record.source != "workspace":
        return [
            {
                "version": record.version or "legacy",
                "status": record.status,
                "createdAt": None,
                "publishedAt": None,
            }
        ]
    result: list[dict[str, Any]] = []
    for version in loaded.skill_spec_store.list_versions(record.name):  # type: ignore[union-attr]
        loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version)  # type: ignore[union-attr]
        if loaded_version is None:
            continue
        result.append(
            {
                "version": loaded_version.version.version,
                "status": loaded_version.version.review_state,
                "createdAt": loaded_version.version.created_at,
                "publishedAt": loaded_version.version.created_at,
                "changeReason": loaded_version.version.change_reason,
                "parentVersion": loaded_version.version.parent_version,
                "contentHash": loaded_version.version.content_hash,
            }
        )
    if not result:
        result.append(
            {
                "version": record.version or "legacy",
                "status": record.status,
                "createdAt": None,
                "publishedAt": None,
            }
        )
    return result


def _skill_version_base_dir(loaded: Any, record: Any, version: str) -> Path:
    if record.source != "workspace" or version == "legacy":
        if record.source == "workspace":
            legacy_dir = loaded.skill_spec_store.root / record.name  # type: ignore[union-attr]
            if (legacy_dir / "SKILL.md").exists():
                return legacy_dir
        return record.path.parent
    loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version)  # type: ignore[union-attr]
    if loaded_version is None:
        raise HTTPException(status_code=404, detail="Skill version not found")
    return loaded.skill_spec_store.root / record.name / "versions" / version  # type: ignore[union-attr]


def _list_skill_files(base_dir: Path) -> list[dict[str, Any]]:
    if not base_dir.exists():
        return []
    ignored_dirs = {"drafts", "reviews", "archive", "versions", "__pycache__"}
    ignored_files = {"version.json", "skill.json", "current.json"}
    files: list[dict[str, Any]] = []
    for file_path in sorted(base_dir.rglob("*")):
        if not file_path.is_file() or file_path.is_symlink():
            continue
        rel_path = file_path.relative_to(base_dir).as_posix()
        parts = set(file_path.relative_to(base_dir).parts)
        if parts & ignored_dirs or file_path.name in ignored_files:
            continue
        stat = file_path.stat()
        files.append(
            {
                "filePath": rel_path,
                "fileSize": stat.st_size,
                "contentType": _content_type_for_path(rel_path),
                "sha256": None,
            }
        )
    return files


def _skill_file_content_payload(base_dir: Path, file_path: Path) -> dict[str, Any]:
    rel_path = file_path.relative_to(base_dir).as_posix()
    raw = file_path.read_bytes()
    is_binary = _is_probably_binary(raw)
    content = None if is_binary else raw.decode("utf-8", errors="replace")
    return {
        "filePath": rel_path,
        "fileSize": len(raw),
        "contentType": _content_type_for_path(rel_path),
        "isBinary": is_binary,
        "content": content,
    }


def _safe_child_path(base_dir: Path, rel_path: str) -> Path:
    cleaned = rel_path.replace("\\", "/").lstrip("/")
    if not cleaned or cleaned in {".", ".."}:
        raise HTTPException(status_code=400, detail="Invalid file path")
    base_resolved = base_dir.resolve()
    target = (base_dir / cleaned).resolve()
    if target != base_resolved and base_resolved not in target.parents:
        raise HTTPException(status_code=400, detail="Invalid file path")
    return target


def _content_type_for_path(path: str) -> str:
    lower = path.lower()
    if lower.endswith(".md"):
        return "text/markdown"
    if lower.endswith(".json"):
        return "application/json"
    if lower.endswith((".yaml", ".yml", ".toml", ".txt", ".csv", ".log")):
        return "text/plain"
    if lower.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".sh")):
        return "text/plain"
    if lower.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")):
        return f"image/{lower.rsplit('.', 1)[-1].replace('jpg', 'jpeg')}"
    return "application/octet-stream"


def _is_probably_binary(raw: bytes) -> bool:
    if not raw:
        return False
    if b"\x00" in raw[:4096]:
        return True
    try:
        raw[:4096].decode("utf-8")
    except UnicodeDecodeError:
        return True
    return False


def _skill_draft_http_error(exc: ValueError) -> HTTPException:
    detail = str(exc)
    status_code = 404 if detail.startswith("Draft not found:") else 400
    return HTTPException(status_code=status_code, detail=detail)


def _mask_secret(value: str | None) -> str:
    secret = _clean_text(value)
    if not secret:
        return ""
    if len(secret) <= 8:
        return "••••"
    return f"{secret[:4]}••••{secret[-4:]}"


def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]:
    secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {}
    config_payload = data.get("config") if isinstance(data.get("config"), dict) else {}
    return {
        "channel_id": channel_id,
        "enabled": bool(data.get("enabled")),
        "kind": _clean_text(data.get("kind")) or "",
        "mode": _clean_text(data.get("mode")) or "webhook",
        "account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "",
        "display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id,
        "config": dict(config_payload),
        "secrets": {
            str(key): _mask_secret(str(value) if value is not None else None)
            for key, value in secrets_payload.items()
            if str(key).strip()
        },
    }


def _read_config_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError(f"Config must be a JSON object: {path}")
    return data


def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]:
    value = parent.get(key)
    if not isinstance(value, dict):
        value = {}
        parent[key] = value
    return value


def _save_backend_identity(
    agent_service: AgentService,
    *,
    config_path: Path,
    backend_id: str,
    client_id: str,
    client_secret: str,
    name: str,
    public_base_url: str,
    authz_base_url: str,
) -> dict[str, Any]:
    raw = _read_config_json(config_path)
    authz = _ensure_dict(raw, "authz")
    authz["enabled"] = True
    authz["baseUrl"] = authz_base_url

    identity = _ensure_dict(raw, "backend_identity")
    identity["backendId"] = backend_id
    identity["clientId"] = client_id
    identity["clientSecret"] = client_secret
    identity["name"] = name
    identity["publicBaseUrl"] = public_base_url

    _write_config_json(config_path, raw)
    _reload_agent_config(agent_service, config_path)
    return {
        "backend_id": backend_id,
        "client_id": client_id,
        "name": name,
        "public_base_url": public_base_url,
        "authz": {
            "enabled": True,
            "base_url": authz_base_url,
        },
    }


def _write_config_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f"{path.name}.tmp")
    tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    tmp_path.replace(path)


def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
    config = load_config(config_path=config_path)
    agent_service.loader.config = config
    agent_service._apply_configured_profile_defaults()  # noqa: SLF001
    loop = getattr(agent_service, "_loop", None)
    loaded = getattr(loop, "loaded", None) if loop is not None else None
    if loaded is not None:
        old_manager = getattr(loaded, "mcp_manager", None)
        if old_manager is not None:
            async def _close_old_manager() -> None:
                try:
                    await old_manager.close()
                except Exception:
                    # MCP transports may own anyio cancel scopes created by a
                    # previous request task. Config reload must not leak that
                    # cleanup failure as an unhandled background exception or
                    # knock the app out of running mode.
                    pass

            try:
                running_loop = asyncio.get_running_loop()
            except RuntimeError:
                asyncio.run(_close_old_manager())
            else:
                running_loop.create_task(_close_old_manager())
        loaded.config = config
        loaded.mcp_manager = MCPConnectionManager(
            config.tools.mcp_servers,
            authz_config=config.authz,
            backend_identity=config.backend_identity,
        )
        loaded.mcp_report = {}
