Bläddra i källkod

docs: 纳入夭夭 Hermes 插件及安装说明

samien 2 månader sedan
förälder
incheckning
6893ab5544
28 ändrade filer med 24302 tillägg och 0 borttagningar
  1. 6 0
      .gitignore
  2. 52 0
      README.md
  3. 1075 0
      hermes-plugins/yaoyao/dashboard/dist/index.js
  4. 934 0
      hermes-plugins/yaoyao/dashboard/dist/index.v1.1.0.js
  5. 934 0
      hermes-plugins/yaoyao/dashboard/dist/index.v1.2.0.js
  6. 934 0
      hermes-plugins/yaoyao/dashboard/dist/index.v1.3.0.js
  7. 934 0
      hermes-plugins/yaoyao/dashboard/dist/index.v1.4.0.js
  8. 433 0
      hermes-plugins/yaoyao/dashboard/dist/style.css
  9. 433 0
      hermes-plugins/yaoyao/dashboard/dist/style.v1.1.0.css
  10. 433 0
      hermes-plugins/yaoyao/dashboard/dist/style.v1.2.0.css
  11. 433 0
      hermes-plugins/yaoyao/dashboard/dist/style.v1.3.0.css
  12. 433 0
      hermes-plugins/yaoyao/dashboard/dist/style.v1.4.0.css
  13. 2050 0
      hermes-plugins/yaoyao/dashboard/group_gateway.py
  14. 3470 0
      hermes-plugins/yaoyao/dashboard/group_orchestrator.py
  15. 1135 0
      hermes-plugins/yaoyao/dashboard/group_plugin_api.py
  16. 421 0
      hermes-plugins/yaoyao/dashboard/group_protocol.py
  17. 4934 0
      hermes-plugins/yaoyao/dashboard/group_store.py
  18. 413 0
      hermes-plugins/yaoyao/dashboard/group_stream.py
  19. 11 0
      hermes-plugins/yaoyao/dashboard/manifest.json
  20. 715 0
      hermes-plugins/yaoyao/dashboard/plugin_api.py
  21. 510 0
      hermes-plugins/yaoyao/dashboard/poller.py
  22. 984 0
      hermes-plugins/yaoyao/dashboard/store.py
  23. 158 0
      hermes-plugins/yaoyao/dashboard/tests/test_file_library_contract.py
  24. 27 0
      hermes-plugins/yaoyao/dashboard/tests/test_frontend_contract.py
  25. 280 0
      hermes-plugins/yaoyao/dashboard/tests/test_group_merge_contract.py
  26. 1570 0
      hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py
  27. 104 0
      hermes-plugins/yaoyao/dashboard/tests/test_poller_contract.py
  28. 486 0
      hermes-plugins/yaoyao/dashboard/voice_store.py

+ 6 - 0
.gitignore

@@ -13,3 +13,9 @@ test-results/
 *.key
 .hermes-yaoyao/
 .codegraph/
+
+# Hermes plugin runtime and Python test caches.
+hermes-plugins/**/__pycache__/
+hermes-plugins/**/*.py[cod]
+hermes-plugins/**/.pytest_cache/
+hermes-plugins/**/data/

+ 52 - 0
README.md

@@ -2,6 +2,58 @@
 
 夭夭 Web 是一个独立的本地 Web 工作台,通过端口 `8800` 连接现有 Hermes Dashboard/Gateway `9119`,提供普通聊天、群聊、文件库和产物浏览。它不会启动、停止或监督 Hermes。
 
+仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
+[`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
+
+## 安装 Hermes Dashboard 插件
+
+前提是目标机器已安装并可运行 Hermes。插件目录会被 Hermes 以路径加载,因此请保留下面的目录层级,不要只复制其中的 Python 文件或 `dist/`。
+
+```text
+<Hermes 数据目录>/plugins/yaoyao/dashboard/
+├── manifest.json
+├── plugin_api.py
+├── group_plugin_api.py
+└── dist/
+```
+
+默认 Hermes 数据目录是 `~/.hermes`。从本仓库根目录执行下列命令即可安装或更新插件;首次覆盖已有版本时会先创建带时间戳的备份:
+
+```bash
+HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
+PLUGIN_DIR="$HERMES_HOME/plugins/yaoyao/dashboard"
+
+if [ -d "$PLUGIN_DIR" ]; then
+  cp -a "$PLUGIN_DIR" "${PLUGIN_DIR}.backup-$(date +%Y%m%d%H%M%S)"
+fi
+
+mkdir -p "$PLUGIN_DIR"
+rsync -a --delete \
+  --exclude '__pycache__/' \
+  --exclude '*.py[cod]' \
+  --exclude '.pytest_cache/' \
+  hermes-plugins/yaoyao/dashboard/ "$PLUGIN_DIR/"
+```
+
+`rsync --delete` 只会删除目标 `dashboard/` 目录中仓库未提供的旧插件文件,不会触及其他 Hermes 插件或数据;如需保留本机额外文件,请先移出该目录或不要使用 `--delete`。
+
+安装后重启 Dashboard,使运行中的 `9119` 进程重新加载插件:
+
+```bash
+hermes dashboard --stop
+hermes dashboard --no-open
+```
+
+通过状态命令确认 Dashboard 已恢复运行:
+
+```bash
+hermes dashboard --status
+```
+
+若 Hermes 由 LaunchAgent、服务管理器或其他外部监督器启动,请使用该监督器重启 Dashboard,而不是同时手动执行 `hermes dashboard --no-open`,以免产生重复监听进程。
+
+插件的归档数据与运行状态仍位于 Hermes 数据目录中,不在此仓库的 `hermes-plugins/` 中;升级插件前的备份也不应提交到 Git。
+
 ## 开发
 
 ```bash

+ 1075 - 0
hermes-plugins/yaoyao/dashboard/dist/index.js

@@ -0,0 +1,1075 @@
+/**
+ * Yaoyao - dashboard plugin frontend.
+ *
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +
+ * shadcn-ui primitives + fetchJSON / authedFetch. Backend routes live under
+ * /api/plugins/yaoyao/ (auto-mounted by the dashboard plugin loader).
+ */
+(function () {
+  "use strict";
+
+  const SDK = window.__HERMES_PLUGIN_SDK__;
+  if (!SDK) return;
+  const { React } = SDK;
+  const h = React.createElement;
+  const { useState, useEffect, useCallback, useRef, useMemo } = SDK.hooks;
+  const {
+    Card,
+    CardContent,
+    Badge,
+    Button,
+    Input,
+    Label,
+    Select,
+    SelectOption,
+  } = SDK.components;
+  const { cn, timeAgo } = SDK.utils;
+  const fetchJSON = SDK.fetchJSON;
+  const authedFetch = SDK.authedFetch;
+
+  const API = "/api/plugins/yaoyao";
+
+  // ---------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------
+
+  function inferKind(name, mimeType) {
+    const ext = (name.split(".").pop() || "").toLowerCase();
+    if ((mimeType || "").startsWith("image/") ||
+        ["png","jpg","jpeg","gif","webp","bmp","svg","tiff","tif"].includes(ext))
+      return "image";
+    if ((mimeType || "").startsWith("video/") ||
+        ["mp4","mov","avi","mkv","webm","m4v","wmv","flv"].includes(ext))
+      return "video";
+    if ((mimeType || "").startsWith("text/") ||
+        ["txt","md","json","yaml","yml","csv","log","py","js","ts","html","xml","rst"].includes(ext))
+      return "text";
+    return "file";
+  }
+
+  function formatBytes(n) {
+    if (!n || n <= 0) return "0 B";
+    const units = ["B", "KB", "MB", "GB"];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v >= 100 ? v.toFixed(0) + " " + units[i]
+         : v >= 10  ? v.toFixed(1) + " " + units[i]
+                    : v.toFixed(2) + " " + units[i];
+  }
+
+  function senderLabel(s) {
+    return s === "user" ? "用户" : s === "agent" ? "Agent" : s;
+  }
+
+  function kindIcon(kind) {
+    switch (kind) {
+      case "image": return "🖼";
+      case "video": return "🎬";
+      case "text": return "📄";
+      default: return "📦";
+    }
+  }
+
+  // ---------------------------------------------------------------------
+  // Thumbnail image: fetch as blob via authedFetch (img tags can't set
+  // Authorization headers), then create an object URL.
+  // ---------------------------------------------------------------------
+
+  function Thumbnail({ item, profile }) {
+    const [url, setUrl] = useState(null);
+    const [err, setErr] = useState(false);
+    const kind = inferKind(item.name, item.mimeType);
+
+    useEffect(() => {
+      if (kind !== "image") return;
+      let revoke = null;
+      let cancelled = false;
+      const dlUrl = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+      authedFetch(dlUrl)
+        .then((res) => res.blob())
+        .then((blob) => {
+          if (cancelled) return;
+          const u = URL.createObjectURL(blob);
+          revoke = u;
+          setUrl(u);
+        })
+        .catch(() => { if (!cancelled) setErr(true); });
+      return () => {
+        cancelled = true;
+        if (revoke) URL.revokeObjectURL(revoke);
+      };
+    }, [item.id, profile]);
+
+    if (kind !== "image") {
+      return h("div", {
+        className: "yaoyao-thumb yaoyao-thumb-icon",
+      }, kindIcon(kind));
+    }
+    if (err) return h("div", { className: "yaoyao-thumb yaoyao-thumb-icon" }, "🖼");
+    if (!url) return h("div", { className: "yaoyao-thumb yaoyao-thumb-loading" }, "…");
+    return h("img", {
+      src: url,
+      alt: item.name,
+      className: "yaoyao-thumb yaoyao-thumb-img",
+      loading: "lazy",
+    });
+  }
+
+  // ---------------------------------------------------------------------
+  // File card
+  // ---------------------------------------------------------------------
+
+  function FileCard({ item, onDownload, profile }) {
+    const kind = inferKind(item.name, item.mimeType);
+    return h(Card, { className: "yaoyao-card" },
+      h(CardContent, { className: "yaoyao-card-content" },
+        h("div", { className: "yaoyao-card-preview" },
+          h(Thumbnail, { item: item, profile: profile })
+        ),
+        h("div", { className: "yaoyao-card-info" },
+          h("div", { className: "yaoyao-card-name", title: item.name }, item.name),
+          h("div", { className: "yaoyao-card-meta" },
+            h(Badge, { variant: "secondary" }, senderLabel(item.origins && item.origins[0] ? item.origins[0].authorKind : "agent")),
+            h("span", { className: "yaoyao-card-size" }, formatBytes(item.size)),
+            item.messageTimestamp
+              ? h("span", { className: "yaoyao-card-time" }, timeAgo(item.messageTimestamp * 1000))
+              : null
+          ),
+          h("div", { className: "yaoyao-card-actions" },
+            h(Button, {
+              variant: "outline",
+              size: "sm",
+              onClick: () => onDownload(item),
+            }, "下载")
+          )
+        )
+      )
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Main page
+  // ---------------------------------------------------------------------
+
+  function YaoyaoFilesPage() {
+    const [items, setItems] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [loadingMore, setLoadingMore] = useState(false);
+    const [error, setError] = useState(null);
+    const [cursor, setCursor] = useState(null);
+    const [hasMore, setHasMore] = useState(false);
+    const [stats, setStats] = useState(null);
+    const [profiles, setProfiles] = useState([]);
+    // Initial profile from the URL (?profile=gril) so deep links / shared
+    // links land on the right agent's library; fall back to "default".
+    const [profile, setProfile] = useState(() => {
+      try {
+        const u = new URL(window.location.href);
+        const p = u.searchParams.get("profile");
+        return p || "default";
+      } catch (e) {
+        return "default";
+      }
+    });
+    const [filter, setFilter] = useState({ sender: "", kind: "" });
+    const [search, setSearch] = useState("");
+    const [searchInput, setSearchInput] = useState("");
+    const sentinelRef = useRef(null);
+
+    // Keep the URL ?profile= in sync with the dropdown selection so the
+    // address bar reflects what's shown (and is shareable / refreshable).
+    useEffect(() => {
+      try {
+        const u = new URL(window.location.href);
+        if (profile && profile !== "default") {
+          u.searchParams.set("profile", profile);
+        } else {
+          u.searchParams.delete("profile");
+        }
+        window.history.replaceState(null, "", u.toString());
+      } catch (e) { /* ignore */ }
+    }, [profile]);
+
+    // Also react to back/forward navigation so the dropdown follows the URL.
+    useEffect(() => {
+      const onPop = () => {
+        try {
+          const u = new URL(window.location.href);
+          const p = u.searchParams.get("profile") || "default";
+          setProfile(p);
+        } catch (e) { /* ignore */ }
+      };
+      window.addEventListener("popstate", onPop);
+      return () => window.removeEventListener("popstate", onPop);
+    }, []);
+
+    // Profiles dropdown: which agents have libraries. default always present.
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((res) => {
+          const list = (res.profiles || []);
+          setProfiles(list);
+          // If the current selection vanished (profile deleted), fall back.
+          if (list.length && !list.some((p) => p.name === profile)) {
+            setProfile("default");
+          }
+        })
+        .catch(() => {});
+    }, []); // eslint-disable-line
+
+    // Fetch first page whenever filter / search / profile changes.
+    const loadFirst = useCallback(async () => {
+      setLoading(true);
+      setError(null);
+      try {
+        const params = { limit: 50 };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems(res.items || []);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }, [filter, search, profile]);
+
+    useEffect(() => { loadFirst(); }, [loadFirst]);
+
+    // Stats (re-fetch when profile changes)
+    useEffect(() => {
+      const params = {};
+      if (profile) params.profile = profile;
+      const qs = new URLSearchParams(params).toString();
+      fetchJSON(`${API}/stats?${qs}`).then(setStats).catch(() => {});
+    }, [profile]);
+
+    // Load more
+    const loadMore = useCallback(async () => {
+      if (!cursor || loadingMore) return;
+      setLoadingMore(true);
+      try {
+        const params = { limit: 50, cursor };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems((prev) => [...prev, ...(res.items || [])]);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoadingMore(false);
+      }
+    }, [cursor, filter, search, profile, loadingMore]);
+
+    // Infinite scroll via IntersectionObserver
+    useEffect(() => {
+      const el = sentinelRef.current;
+      if (!el || !hasMore) return;
+      const obs = new IntersectionObserver((entries) => {
+        if (entries[0].isIntersecting) loadMore();
+      }, { rootMargin: "200px" });
+      obs.observe(el);
+      return () => obs.disconnect();
+    }, [hasMore, loadMore]);
+
+    const onDownload = useCallback(async (item) => {
+      try {
+        const url = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+        const res = await authedFetch(url);
+        const blob = await res.blob();
+        const urlObj = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = urlObj;
+        a.download = item.name;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        setTimeout(() => URL.revokeObjectURL(urlObj), 10000);
+      } catch (e) {
+        alert("下载失败: " + (e.message || e));
+      }
+    }, [profile]);
+
+    const onSearchSubmit = useCallback((e) => {
+      e.preventDefault();
+      setSearch(searchInput.trim());
+    }, [searchInput]);
+
+    return h("div", { className: "yaoyao-page" },
+      // Header + stats
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "夭夭文件库"),
+        stats ? h("div", { className: "yaoyao-stats" },
+          profile && profile !== "default"
+            ? h(Badge, { variant: "secondary" }, `Agent: ${profile}`)
+            : null,
+          h(Badge, { variant: "secondary" }, `共 ${stats.totalAttachments} 个文件`),
+          h(Badge, { variant: "secondary" }, `${formatBytes(stats.totalBytes)}`),
+          stats.bySender && stats.bySender.agent
+            ? h(Badge, { variant: "outline" }, `Agent ${stats.bySender.agent}`)
+            : null,
+          stats.bySender && stats.bySender.user
+            ? h(Badge, { variant: "outline" }, `用户 ${stats.bySender.user}`)
+            : null,
+        ) : null
+      ),
+
+      // Filters
+      h("div", { className: "yaoyao-filters" },
+        h(Label, null, "Agent"),
+        h(Select, {
+          value: profile,
+          onValueChange: setProfile,
+          disabled: profiles.length === 0,
+        },
+          profiles.length === 0
+            ? h(SelectOption, { value: "default" }, "默认")
+            : profiles.map((p) =>
+                h(SelectOption, { key: p.name, value: p.name }, p.label)
+              )
+        ),
+        h(Label, null, "来源"),
+        h(Select, {
+          value: filter.sender,
+          onValueChange: (value) => setFilter((f) => ({ ...f, sender: value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "agent" }, "Agent"),
+          h(SelectOption, { value: "user" }, "用户")
+        ),
+        h(Label, null, "类型"),
+        h(Select, {
+          value: filter.kind,
+          onValueChange: (value) => setFilter((f) => ({ ...f, kind: value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "image" }, "图片"),
+          h(SelectOption, { value: "video" }, "视频"),
+          h(SelectOption, { value: "text" }, "文本"),
+          h(SelectOption, { value: "file" }, "文档")
+        ),
+        h("form", { onSubmit: onSearchSubmit, className: "yaoyao-search" },
+          h(Input, {
+            type: "text",
+            placeholder: "搜索文件名…",
+            value: searchInput,
+            onChange: (e) => setSearchInput(e.target.value),
+          }),
+          h(Button, { type: "submit", size: "sm" }, "搜索")
+        )
+      ),
+
+      // Error
+      error ? h("div", { className: "yaoyao-error" }, "错误: " + error) : null,
+
+      // Loading
+      loading ? h("div", { className: "yaoyao-loading" }, "加载中…") :
+      // Grid
+      items.length === 0
+        ? h("div", { className: "yaoyao-empty" }, "暂无文件")
+        : h("div", { className: "yaoyao-grid" },
+            items.map((item) =>
+              h(FileCard, { key: item.id, item: item, onDownload: onDownload, profile: profile })
+            )
+          ),
+
+      // Infinite scroll sentinel
+      hasMore ? h("div", {
+        ref: sentinelRef,
+        className: "yaoyao-sentinel"
+      }, loadingMore ? "加载更多…" : "") : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Agent display-name settings page
+  // ---------------------------------------------------------------------
+
+  function AgentSettingsPage() {
+    const [profiles, setProfiles] = useState([]);
+    const [profile, setProfile] = useState("default");
+    const [settings, setSettings] = useState(null);
+    const [draftName, setDraftName] = useState("");
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((response) => setProfiles(response.profiles || []))
+        .catch((requestError) => setError(String(requestError.message || requestError)));
+    }, []);
+
+    useEffect(() => {
+      let cancelled = false;
+      setLoading(true);
+      setError(null);
+      fetchJSON(`${API}/agent/settings?profile=${encodeURIComponent(profile)}`)
+        .then((response) => {
+          if (cancelled) return;
+          setSettings(response);
+          setDraftName(response.agentName || "");
+        })
+        .catch((requestError) => {
+          if (!cancelled) setError(String(requestError.message || requestError));
+        })
+        .finally(() => {
+          if (!cancelled) setLoading(false);
+        });
+      return () => { cancelled = true; };
+    }, [profile]);
+
+    async function saveAgentName() {
+      setSaving(true);
+      setError(null);
+      try {
+        const response = await fetchJSON(
+          `${API}/agent/settings?profile=${encodeURIComponent(profile)}`,
+          {
+            method: "PUT",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ agentName: draftName.trim() }),
+          },
+        );
+        setSettings(response);
+        setDraftName(response.agentName || "");
+        setProfiles((current) => current.map((item) =>
+          item.name === profile
+            ? {
+                ...item,
+                agentName: response.agentName,
+                label: response.agentName || (profile === "default" ? "默认" : profile),
+              }
+            : item
+        ));
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (requestError) {
+        setError(String(requestError.message || requestError));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    const effectiveName = settings?.agentName || (profile === "default" ? "default" : profile);
+    const readApi = `${API}/agent/settings?profile=${encodeURIComponent(profile)}`;
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "Agent 设置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "agent-profile" }, "Hermes Profile"),
+            h(Select, {
+              id: "agent-profile",
+              value: profile,
+              onValueChange: setProfile,
+              disabled: profiles.length === 0,
+            },
+              profiles.map((item) =>
+                h(SelectOption, { key: item.name, value: item.name },
+                  item.label === item.name ? item.name : `${item.label} (${item.name})`
+                )
+              )
+            )
+          ),
+          loading
+            ? h("div", { className: "yaoyao-loading" }, "加载中…")
+            : h(React.Fragment, null,
+                h("div", { className: "yaoyao-voice-row" },
+                  h(Label, { htmlFor: "agent-name" }, "Agent 名称"),
+                  h(Input, {
+                    id: "agent-name",
+                    type: "text",
+                    maxLength: 100,
+                    value: draftName,
+                    placeholder: `留空时使用 ${profile}`,
+                    onChange: (event) => setDraftName(event.target.value),
+                  })
+                ),
+                h("div", { className: "yaoyao-duplex-meta" },
+                  h("div", null,
+                    h("span", { className: "yaoyao-meta-label" }, "当前显示名称"),
+                    h("span", null, effectiveName)
+                  ),
+                  h("div", null,
+                    h("span", { className: "yaoyao-meta-label" }, "读取 API"),
+                    h("code", null, readApi)
+                  )
+                ),
+                h("p", { className: "yaoyao-hint" },
+                  "名称只用于展示,Profile ID 仍作为稳定的路由与数据隔离标识。留空保存可恢复默认名称。"
+                ),
+                h("div", { className: "yaoyao-voice-actions" },
+                  h(Button, {
+                    onClick: saveAgentName,
+                    disabled: saving,
+                  }, saving ? "保存中…" : "保存")
+                )
+              )
+        )
+      ),
+      error ? h("div", { className: "yaoyao-error" }, error) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Voice settings page
+  // ---------------------------------------------------------------------
+
+  // ---------------------------------------------------------------------
+  // Duplex voice settings page
+  // Mirrors yaoyao-webui's IosDuplexVoiceSettings.vue contract:
+  // {hasApiKey, voices:[{id,name}], currentVoiceId, updatedAt}
+  // ---------------------------------------------------------------------
+
+  function VoiceSettingsPage() {
+    const [settings, setSettings] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [switching, setSwitching] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    // Draft state for the editor
+    const [draftApiKey, setDraftApiKey] = useState("");
+    const [draftVoices, setDraftVoices] = useState([]);
+    const [draftCurrent, setDraftCurrent] = useState("");
+    const [editing, setEditing] = useState(false);
+
+    useEffect(() => {
+      load();
+    }, []);
+
+    async function load() {
+      setLoading(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/settings`);
+        setSettings(s);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }
+
+    const voiceOptions = useMemo(() => {
+      if (!settings) return [];
+      return settings.voices
+        .filter((v) => v.id.trim() && v.name.trim())
+        .map((v) => ({ label: v.name, value: v.id }));
+    }, [settings]);
+
+    const isConfigured = settings
+      && settings.hasApiKey
+      && voiceOptions.length > 0
+      && voiceOptions.some((o) => o.value === settings.currentVoiceId);
+
+    function openEditor() {
+      setDraftApiKey("");
+      setDraftVoices((settings?.voices || []).map((v) => ({ ...v })));
+      setDraftCurrent(settings?.currentVoiceId || "");
+      setEditing(true);
+    }
+
+    function addVoice() {
+      setDraftVoices((prev) => [...prev, { id: "", name: "" }]);
+    }
+
+    function removeVoice(idx) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        const removed = next[idx];
+        next.splice(idx, 1);
+        if (removed?.id === draftCurrent) {
+          setDraftCurrent(next[0]?.id || "");
+        }
+        return next;
+      });
+    }
+
+    function updateVoice(idx, field, value) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        next[idx] = { ...next[idx], [field]: value };
+        return next;
+      });
+    }
+
+    async function saveSettings() {
+      setSaving(true);
+      setError(null);
+      try {
+        const body = {
+          voices: draftVoices.map((v) => ({ id: v.id.trim(), name: v.name.trim() })),
+          currentVoiceId: draftCurrent,
+        };
+        if (draftApiKey.trim()) body.apiKey = draftApiKey.trim();
+        const s = await fetchJSON(`${API}/voice/settings`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(body),
+        });
+        setSettings(s);
+        setEditing(false);
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function switchVoice(voiceId) {
+      if (switching || voiceId === settings.currentVoiceId) return;
+      setSwitching(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/current-voice`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ currentVoiceId: voiceId }),
+        });
+        setSettings((prev) => ({ ...prev, ...s }));
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSwitching(false);
+      }
+    }
+
+    if (loading) return h("div", { className: "yaoyao-loading" }, "加载中…");
+    if (!settings) return h("div", { className: "yaoyao-error" }, "加载失败: " + error);
+
+    const currentVoiceName = voiceOptions.find((o) => o.value === settings.currentVoiceId)?.label
+      || settings.currentVoiceId
+      || "未选择";
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "App 双流直连配置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+
+      // Status card
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-duplex-status" },
+            h("div", { className: "yaoyao-duplex-icon" }, "D"),
+            h("div", { className: "yaoyao-duplex-info" },
+              h("div", { className: "yaoyao-duplex-title-row" },
+                h("h5", null, "火山引擎 / 豆包 TTS"),
+                h(Badge, {
+                  variant: isConfigured ? "default" : "secondary",
+                }, isConfigured ? "已配置" : "未配置")
+              ),
+              h("p", { className: "yaoyao-hint" },
+                "iOS App 直连 TTS 引擎进行双流语音对话。API Key 和声音列表在此配置后,App 从 /voice/runtime 拉取。")
+            )
+          ),
+          h("div", { className: "yaoyao-duplex-meta" },
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "当前声音"), h("span", null, currentVoiceName)),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "API Key"), h("span", null, settings.hasApiKey ? "已存储" : "未设置")),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "声音数量"), h("span", null, String(settings.voices.length))),
+          )
+        )
+      ),
+
+      // Current voice quick switch
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-current" }, "当前声音"),
+            h("div", { className: "yaoyao-voice-switch" },
+              h(Select, {
+                id: "duplex-current",
+                value: settings.currentVoiceId,
+                onValueChange: switchVoice,
+                disabled: switching || voiceOptions.length === 0,
+              },
+                voiceOptions.map((o) =>
+                  h(SelectOption, { key: o.value, value: o.value }, o.label)
+                )
+              ),
+              h(Button, {
+                variant: "outline",
+                size: "sm",
+                onClick: openEditor,
+              }, "编辑配置")
+            )
+          )
+        )
+      ),
+
+      error ? h("div", { className: "yaoyao-error" }, error) : null,
+
+      // Editor (inline form)
+      editing ? h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("h3", { className: "yaoyao-section-title" }, "编辑双流配置"),
+
+          // API Key
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-key" }, "API Key"),
+            h(Input, {
+              id: "duplex-key",
+              type: "password",
+              value: draftApiKey,
+              placeholder: settings.hasApiKey ? "已存储 (留空保持不变)" : "输入 API Key",
+              onChange: (e) => setDraftApiKey(e.target.value),
+            })
+          ),
+
+          // Current voice in draft
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-draft-current" }, "当前声音"),
+            h(Select, {
+              id: "duplex-draft-current",
+              value: draftCurrent,
+              onValueChange: setDraftCurrent,
+            },
+              draftVoices
+                .filter((v) => v.id.trim())
+                .map((v) =>
+                  h(SelectOption, { key: v.id, value: v.id }, v.name || v.id)
+                )
+            )
+          ),
+
+          // Voice list editor
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, null, "声音列表"),
+            h("div", { className: "yaoyao-voice-editor" },
+              draftVoices.map((voice, idx) =>
+                h("div", { key: idx, className: "yaoyao-voice-editor-row" },
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音名称 (如 小何 2.0)",
+                    value: voice.name,
+                    onChange: (e) => updateVoice(idx, "name", e.target.value),
+                  }),
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音 ID (如 zh_female_xiaohe_uranus_bigtts)",
+                    value: voice.id,
+                    onChange: (e) => updateVoice(idx, "id", e.target.value),
+                  }),
+                  h(Button, {
+                    variant: "ghost",
+                    size: "sm",
+                    disabled: draftVoices.length <= 1,
+                    onClick: () => removeVoice(idx),
+                  }, "×")
+                )
+              ),
+              h(Button, { variant: "outline", size: "sm", onClick: addVoice }, "+ 添加声音")
+            )
+          ),
+
+          h("div", { className: "yaoyao-voice-actions" },
+            h(Button, { variant: "ghost", onClick: () => setEditing(false) }, "取消"),
+            h(Button, { onClick: saveSettings, disabled: saving }, saving ? "保存中…" : "保存")
+          )
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // TTS / STT provider settings page
+  // Mirrors yaoyao-webui's VoiceSettings.vue + VoiceApiConfigurator.vue.
+  // ---------------------------------------------------------------------
+
+  function VoiceProvidersPage() {
+    const [ttsData, setTtsData] = useState(null);
+    const [sttData, setSttData] = useState(null);
+    const [providersInfo, setProvidersInfo] = useState(null);
+    const [error, setError] = useState(null);
+    const [saving, setSaving] = useState(false);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      Promise.all([
+        fetchJSON(`${API}/tts/settings`),
+        fetchJSON(`${API}/stt/settings`),
+        fetchJSON(`${API}/voice/providers-info`),
+      ]).then(([t, s, p]) => {
+        setTtsData(t);
+        setSttData(s);
+        setProvidersInfo(p);
+      }).catch((e) => setError(String(e.message || e)));
+    }, []);
+
+    async function setTtsActive(provider) {
+      try {
+        await fetchJSON(`${API}/tts/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setTtsData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function setSttActive(provider) {
+      try {
+        await fetchJSON(`${API}/stt/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setSttData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function saveProvider(kind, provider, settings, secrets) {
+      setSaving(true);
+      setError(null);
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        const res = await fetchJSON(endpoint, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({settings, secrets}),
+        });
+        // Refresh
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+        setSavedMsg(`${provider} 已保存`);
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function deleteProvider(kind, provider) {
+      if (!confirm(`确定删除 ${provider} 配置?`)) return;
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        await fetchJSON(endpoint, {method: "DELETE"});
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    if (!ttsData || !sttData || !providersInfo) {
+      return h("div", {className: "yaoyao-loading"}, "加载中…");
+    }
+
+    const ttsProviders = providersInfo.tts.providers;
+    const sttProviders = providersInfo.stt.providers;
+
+    function renderProviderCard(kind, info, data) {
+      const settings = (data.settings || []).find(s => s.provider === info.id);
+      const isActive = data.activeProvider === info.id;
+      const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+      const isConfigured = info.id === "edge" || info.id === "browser" || hasKey;
+
+      return h(Card, {key: info.id, className: cn("yaoyao-provider-card", isActive && "yaoyao-provider-active")},
+        h(CardContent, {className: "yaoyao-voice-card"},
+          h("div", {className: "yaoyao-provider-header"},
+            h("div", {className: "yaoyao-provider-title-row"},
+              h("h5", null, info.label),
+              h(Badge, {variant: isActive ? "default" : "secondary"},
+                isActive ? "当前" : (isConfigured ? "已配置" : "未配置"))
+            ),
+            h("p", {className: "yaoyao-hint"},
+              info.needsKey ? "需要 API Key" : "免费 / 内置")
+          ),
+          h(ProviderEditor, {
+            kind, info, settings: settings || null, saving,
+            onSave: (s, sec) => saveProvider(kind, info.id, s, sec),
+            onDelete: info.id !== "edge" && info.id !== "browser"
+              ? () => deleteProvider(kind, info.id) : null,
+            onActivate: () => kind === "tts" ? setTtsActive(info.id) : setSttActive(info.id),
+          })
+        )
+      );
+    }
+
+    return h("div", {className: "yaoyao-page"},
+      h("div", {className: "yaoyao-header"},
+        h("h2", null, "语音引擎配置"),
+        savedMsg ? h(Badge, {variant: "secondary"}, savedMsg) : null
+      ),
+      error ? h("div", {className: "yaoyao-error"}, error) : null,
+
+      h("h3", {className: "yaoyao-section-title"}, "TTS (文字转语音)"),
+      h("div", {className: "yaoyao-provider-list"},
+        ttsProviders.map(info => renderProviderCard("tts", info, ttsData))
+      ),
+
+      h("h3", {className: "yaoyao-section-title"}, "STT (语音转文字)"),
+      h("div", {className: "yaoyao-provider-list"},
+        sttProviders.map(info => renderProviderCard("stt", info, sttData))
+      )
+    );
+  }
+
+  // Inline provider editor (expandable)
+  function ProviderEditor({kind, info, settings, saving, onSave, onDelete, onActivate}) {
+    const [expanded, setExpanded] = useState(false);
+    const [draft, setDraft] = useState({});
+
+    useEffect(() => {
+      if (settings) {
+        setDraft({
+          baseUrl: settings.settings?.baseUrl || info.defaults?.baseUrl || "",
+          model: settings.settings?.model || info.defaults?.model || "",
+          voice: settings.settings?.voice || info.defaults?.voice || "",
+          language: settings.settings?.language || info.defaults?.language || "",
+          rate: settings.settings?.rate || "",
+          pitch: settings.settings?.pitch || "",
+          stylePrompt: settings.settings?.stylePrompt || "",
+          apiKey: "",
+        });
+      } else {
+        setDraft({
+          baseUrl: info.defaults?.baseUrl || "",
+          model: info.defaults?.model || "",
+          voice: info.defaults?.voice || "",
+          language: info.defaults?.language || "",
+          apiKey: "",
+        });
+      }
+    }, [info.id, settings]);
+
+    function update(field, value) {
+      setDraft(d => ({...d, [field]: value}));
+    }
+
+    function handleSave() {
+      const s = {};
+      const sec = {};
+      if (draft.baseUrl) s.baseUrl = draft.baseUrl;
+      if (draft.model) s.model = draft.model;
+      if (draft.voice) s.voice = draft.voice;
+      if (draft.language) s.language = draft.language;
+      if (draft.rate) s.rate = draft.rate;
+      if (draft.pitch) s.pitch = draft.pitch;
+      if (draft.stylePrompt) s.stylePrompt = draft.stylePrompt;
+      if (draft.apiKey) sec.apiKey = draft.apiKey;
+      onSave(s, sec);
+    }
+
+    const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+    const showFields = info.settingsKeys || [];
+
+    return h("div", {className: "yaoyao-provider-editor"},
+      h("div", {className: "yaoyao-provider-actions"},
+        h(Button, {variant: "outline", size: "sm", onClick: () => setExpanded(e => !e)},
+          expanded ? "收起" : "展开"),
+        onActivate ? h(Button, {variant: "ghost", size: "sm", onClick: onActivate}, "设为当前") : null,
+      ),
+      expanded ? h("div", {className: "yaoyao-provider-fields"},
+        showFields.includes("baseUrl") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "Base URL"),
+          h(Input, {type: "text", value: draft.baseUrl, onChange: e => update("baseUrl", e.target.value),
+            placeholder: "https://..."})
+        ) : null,
+        showFields.includes("model") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "模型"),
+          h(Input, {type: "text", value: draft.model, onChange: e => update("model", e.target.value),
+            placeholder: "model id"})
+        ) : null,
+        showFields.includes("voice") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "声音"),
+          h(Input, {type: "text", value: draft.voice, onChange: e => update("voice", e.target.value),
+            placeholder: "voice id"})
+        ) : null,
+        showFields.includes("language") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语言"),
+          h(Input, {type: "text", value: draft.language, onChange: e => update("language", e.target.value),
+            placeholder: "zh / en / ..."})
+        ) : null,
+        showFields.includes("rate") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语速"),
+          h(Input, {type: "text", value: draft.rate, onChange: e => update("rate", e.target.value),
+            placeholder: "+0%"})
+        ) : null,
+        showFields.includes("pitch") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "音调"),
+          h(Input, {type: "text", value: draft.pitch, onChange: e => update("pitch", e.target.value),
+            placeholder: "+0Hz"})
+        ) : null,
+        showFields.includes("stylePrompt") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "风格提示"),
+          h(Input, {type: "text", value: draft.stylePrompt, onChange: e => update("stylePrompt", e.target.value),
+            placeholder: "style prompt"})
+        ) : null,
+        info.needsKey ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "API Key"),
+          h(Input, {type: "password", value: draft.apiKey,
+            onChange: e => update("apiKey", e.target.value),
+            placeholder: hasKey ? "已存储 (留空保持不变)" : "输入 API Key"})
+        ) : null,
+        h("div", {className: "yaoyao-voice-actions"},
+          h(Button, {size: "sm", onClick: handleSave, disabled: saving},
+            saving ? "保存中…" : "保存"),
+          onDelete ? h(Button, {variant: "ghost", size: "sm", onClick: onDelete}, "删除") : null,
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Top-level page with internal tab switcher
+  // ---------------------------------------------------------------------
+
+  function YaoyaoPage() {
+    const [tab, setTab] = useState("files");
+    return h("div", { className: "yaoyao-root" },
+      h("div", { className: "yaoyao-tabs" },
+        h("button", {
+          className: cn("yaoyao-tab", tab === "files" && "yaoyao-tab-active"),
+          onClick: () => setTab("files"),
+        }, "文件库"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "agent" && "yaoyao-tab-active"),
+          onClick: () => setTab("agent"),
+        }, "Agent设置"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "duplex" && "yaoyao-tab-active"),
+          onClick: () => setTab("duplex"),
+        }, "App双流直连"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "engines" && "yaoyao-tab-active"),
+          onClick: () => setTab("engines"),
+        }, "语音引擎")
+      ),
+      tab === "files" ? h(YaoyaoFilesPage)
+      : tab === "agent" ? h(AgentSettingsPage)
+      : tab === "duplex" ? h(VoiceSettingsPage)
+      : h(VoiceProvidersPage)
+    );
+  }
+
+  // Register the plugin page. The name MUST match manifest.json "name".
+  if (window.__HERMES_PLUGINS__ &&
+      typeof window.__HERMES_PLUGINS__.register === "function") {
+    window.__HERMES_PLUGINS__.register("yaoyao", YaoyaoPage);
+  }
+})();

+ 934 - 0
hermes-plugins/yaoyao/dashboard/dist/index.v1.1.0.js

@@ -0,0 +1,934 @@
+/**
+ * Yaoyao File Library - dashboard plugin frontend.
+ *
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +
+ * shadcn-ui primitives + fetchJSON / authedFetch. Backend routes live under
+ * /api/plugins/yaoyao/ (auto-mounted by the dashboard plugin loader).
+ */
+(function () {
+  "use strict";
+
+  const SDK = window.__HERMES_PLUGIN_SDK__;
+  if (!SDK) return;
+  const { React } = SDK;
+  const h = React.createElement;
+  const { useState, useEffect, useCallback, useRef, useMemo } = SDK.hooks;
+  const {
+    Card,
+    CardContent,
+    Badge,
+    Button,
+    Input,
+    Label,
+    Select,
+    SelectOption,
+  } = SDK.components;
+  const { cn, timeAgo } = SDK.utils;
+  const fetchJSON = SDK.fetchJSON;
+  const authedFetch = SDK.authedFetch;
+
+  const API = "/api/plugins/yaoyao";
+
+  // ---------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------
+
+  function inferKind(name, mimeType) {
+    const ext = (name.split(".").pop() || "").toLowerCase();
+    if ((mimeType || "").startsWith("image/") ||
+        ["png","jpg","jpeg","gif","webp","bmp","svg","tiff","tif"].includes(ext))
+      return "image";
+    if ((mimeType || "").startsWith("video/") ||
+        ["mp4","mov","avi","mkv","webm","m4v","wmv","flv"].includes(ext))
+      return "video";
+    if ((mimeType || "").startsWith("text/") ||
+        ["txt","md","json","yaml","yml","csv","log","py","js","ts","html","xml","rst"].includes(ext))
+      return "text";
+    return "file";
+  }
+
+  function formatBytes(n) {
+    if (!n || n <= 0) return "0 B";
+    const units = ["B", "KB", "MB", "GB"];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v >= 100 ? v.toFixed(0) + " " + units[i]
+         : v >= 10  ? v.toFixed(1) + " " + units[i]
+                    : v.toFixed(2) + " " + units[i];
+  }
+
+  function senderLabel(s) {
+    return s === "user" ? "用户" : s === "agent" ? "Agent" : s;
+  }
+
+  function kindIcon(kind) {
+    switch (kind) {
+      case "image": return "🖼";
+      case "video": return "🎬";
+      case "text": return "📄";
+      default: return "📦";
+    }
+  }
+
+  // ---------------------------------------------------------------------
+  // Thumbnail image: fetch as blob via authedFetch (img tags can't set
+  // Authorization headers), then create an object URL.
+  // ---------------------------------------------------------------------
+
+  function Thumbnail({ item, profile }) {
+    const [url, setUrl] = useState(null);
+    const [err, setErr] = useState(false);
+    const kind = inferKind(item.name, item.mimeType);
+
+    useEffect(() => {
+      if (kind !== "image") return;
+      let revoke = null;
+      let cancelled = false;
+      const dlUrl = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+      authedFetch(dlUrl)
+        .then((res) => res.blob())
+        .then((blob) => {
+          if (cancelled) return;
+          const u = URL.createObjectURL(blob);
+          revoke = u;
+          setUrl(u);
+        })
+        .catch(() => { if (!cancelled) setErr(true); });
+      return () => {
+        cancelled = true;
+        if (revoke) URL.revokeObjectURL(revoke);
+      };
+    }, [item.id, profile]);
+
+    if (kind !== "image") {
+      return h("div", {
+        className: "yaoyao-thumb yaoyao-thumb-icon",
+      }, kindIcon(kind));
+    }
+    if (err) return h("div", { className: "yaoyao-thumb yaoyao-thumb-icon" }, "🖼");
+    if (!url) return h("div", { className: "yaoyao-thumb yaoyao-thumb-loading" }, "…");
+    return h("img", {
+      src: url,
+      alt: item.name,
+      className: "yaoyao-thumb yaoyao-thumb-img",
+      loading: "lazy",
+    });
+  }
+
+  // ---------------------------------------------------------------------
+  // File card
+  // ---------------------------------------------------------------------
+
+  function FileCard({ item, onDownload, profile }) {
+    const kind = inferKind(item.name, item.mimeType);
+    return h(Card, { className: "yaoyao-card" },
+      h(CardContent, { className: "yaoyao-card-content" },
+        h("div", { className: "yaoyao-card-preview" },
+          h(Thumbnail, { item: item, profile: profile })
+        ),
+        h("div", { className: "yaoyao-card-info" },
+          h("div", { className: "yaoyao-card-name", title: item.name }, item.name),
+          h("div", { className: "yaoyao-card-meta" },
+            h(Badge, { variant: "secondary" }, senderLabel(item.origins && item.origins[0] ? item.origins[0].authorKind : "agent")),
+            h("span", { className: "yaoyao-card-size" }, formatBytes(item.size)),
+            item.messageTimestamp
+              ? h("span", { className: "yaoyao-card-time" }, timeAgo(item.messageTimestamp * 1000))
+              : null
+          ),
+          h("div", { className: "yaoyao-card-actions" },
+            h(Button, {
+              variant: "outline",
+              size: "sm",
+              onClick: () => onDownload(item),
+            }, "下载")
+          )
+        )
+      )
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Main page
+  // ---------------------------------------------------------------------
+
+  function YaoyaoFilesPage() {
+    const [items, setItems] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [loadingMore, setLoadingMore] = useState(false);
+    const [error, setError] = useState(null);
+    const [cursor, setCursor] = useState(null);
+    const [hasMore, setHasMore] = useState(false);
+    const [stats, setStats] = useState(null);
+    const [profiles, setProfiles] = useState([]);
+    // Initial profile from the URL (?profile=gril) so deep links / shared
+    // links land on the right agent's library; fall back to "default".
+    const [profile, setProfile] = useState(() => {
+      try {
+        const u = new URL(window.location.href);
+        const p = u.searchParams.get("profile");
+        return p || "default";
+      } catch (e) {
+        return "default";
+      }
+    });
+    const [filter, setFilter] = useState({ sender: "", kind: "" });
+    const [search, setSearch] = useState("");
+    const [searchInput, setSearchInput] = useState("");
+    const sentinelRef = useRef(null);
+
+    // Keep the URL ?profile= in sync with the dropdown selection so the
+    // address bar reflects what's shown (and is shareable / refreshable).
+    useEffect(() => {
+      try {
+        const u = new URL(window.location.href);
+        if (profile && profile !== "default") {
+          u.searchParams.set("profile", profile);
+        } else {
+          u.searchParams.delete("profile");
+        }
+        window.history.replaceState(null, "", u.toString());
+      } catch (e) { /* ignore */ }
+    }, [profile]);
+
+    // Also react to back/forward navigation so the dropdown follows the URL.
+    useEffect(() => {
+      const onPop = () => {
+        try {
+          const u = new URL(window.location.href);
+          const p = u.searchParams.get("profile") || "default";
+          setProfile(p);
+        } catch (e) { /* ignore */ }
+      };
+      window.addEventListener("popstate", onPop);
+      return () => window.removeEventListener("popstate", onPop);
+    }, []);
+
+    // Profiles dropdown: which agents have libraries. default always present.
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((res) => {
+          const list = (res.profiles || []);
+          setProfiles(list);
+          // If the current selection vanished (profile deleted), fall back.
+          if (list.length && !list.some((p) => p.name === profile)) {
+            setProfile("default");
+          }
+        })
+        .catch(() => {});
+    }, []); // eslint-disable-line
+
+    // Fetch first page whenever filter / search / profile changes.
+    const loadFirst = useCallback(async () => {
+      setLoading(true);
+      setError(null);
+      try {
+        const params = { limit: 50 };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems(res.items || []);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }, [filter, search, profile]);
+
+    useEffect(() => { loadFirst(); }, [loadFirst]);
+
+    // Stats (re-fetch when profile changes)
+    useEffect(() => {
+      const params = {};
+      if (profile) params.profile = profile;
+      const qs = new URLSearchParams(params).toString();
+      fetchJSON(`${API}/stats?${qs}`).then(setStats).catch(() => {});
+    }, [profile]);
+
+    // Load more
+    const loadMore = useCallback(async () => {
+      if (!cursor || loadingMore) return;
+      setLoadingMore(true);
+      try {
+        const params = { limit: 50, cursor };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems((prev) => [...prev, ...(res.items || [])]);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoadingMore(false);
+      }
+    }, [cursor, filter, search, profile, loadingMore]);
+
+    // Infinite scroll via IntersectionObserver
+    useEffect(() => {
+      const el = sentinelRef.current;
+      if (!el || !hasMore) return;
+      const obs = new IntersectionObserver((entries) => {
+        if (entries[0].isIntersecting) loadMore();
+      }, { rootMargin: "200px" });
+      obs.observe(el);
+      return () => obs.disconnect();
+    }, [hasMore, loadMore]);
+
+    const onDownload = useCallback(async (item) => {
+      try {
+        const url = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+        const res = await authedFetch(url);
+        const blob = await res.blob();
+        const urlObj = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = urlObj;
+        a.download = item.name;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        setTimeout(() => URL.revokeObjectURL(urlObj), 10000);
+      } catch (e) {
+        alert("下载失败: " + (e.message || e));
+      }
+    }, [profile]);
+
+    const onSearchSubmit = useCallback((e) => {
+      e.preventDefault();
+      setSearch(searchInput.trim());
+    }, [searchInput]);
+
+    return h("div", { className: "yaoyao-page" },
+      // Header + stats
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "夭夭文件库"),
+        stats ? h("div", { className: "yaoyao-stats" },
+          profile && profile !== "default"
+            ? h(Badge, { variant: "secondary" }, `Agent: ${profile}`)
+            : null,
+          h(Badge, { variant: "secondary" }, `共 ${stats.totalAttachments} 个文件`),
+          h(Badge, { variant: "secondary" }, `${formatBytes(stats.totalBytes)}`),
+          stats.bySender && stats.bySender.agent
+            ? h(Badge, { variant: "outline" }, `Agent ${stats.bySender.agent}`)
+            : null,
+          stats.bySender && stats.bySender.user
+            ? h(Badge, { variant: "outline" }, `用户 ${stats.bySender.user}`)
+            : null,
+        ) : null
+      ),
+
+      // Filters
+      h("div", { className: "yaoyao-filters" },
+        h(Label, null, "Agent"),
+        h(Select, {
+          value: profile,
+          onChange: (e) => setProfile(e.target.value),
+          disabled: profiles.length === 0,
+        },
+          profiles.length === 0
+            ? h(SelectOption, { value: "default" }, "默认")
+            : profiles.map((p) =>
+                h(SelectOption, { key: p.name, value: p.name }, p.label)
+              )
+        ),
+        h(Label, null, "来源"),
+        h(Select, {
+          value: filter.sender,
+          onChange: (e) => setFilter((f) => ({ ...f, sender: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "agent" }, "Agent"),
+          h(SelectOption, { value: "user" }, "用户")
+        ),
+        h(Label, null, "类型"),
+        h(Select, {
+          value: filter.kind,
+          onChange: (e) => setFilter((f) => ({ ...f, kind: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "image" }, "图片"),
+          h(SelectOption, { value: "video" }, "视频"),
+          h(SelectOption, { value: "text" }, "文本"),
+          h(SelectOption, { value: "file" }, "文档")
+        ),
+        h("form", { onSubmit: onSearchSubmit, className: "yaoyao-search" },
+          h(Input, {
+            type: "text",
+            placeholder: "搜索文件名…",
+            value: searchInput,
+            onChange: (e) => setSearchInput(e.target.value),
+          }),
+          h(Button, { type: "submit", size: "sm" }, "搜索")
+        )
+      ),
+
+      // Error
+      error ? h("div", { className: "yaoyao-error" }, "错误: " + error) : null,
+
+      // Loading
+      loading ? h("div", { className: "yaoyao-loading" }, "加载中…") :
+      // Grid
+      items.length === 0
+        ? h("div", { className: "yaoyao-empty" }, "暂无文件")
+        : h("div", { className: "yaoyao-grid" },
+            items.map((item) =>
+              h(FileCard, { key: item.id, item: item, onDownload: onDownload, profile: profile })
+            )
+          ),
+
+      // Infinite scroll sentinel
+      hasMore ? h("div", {
+        ref: sentinelRef,
+        className: "yaoyao-sentinel"
+      }, loadingMore ? "加载更多…" : "") : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Voice settings page
+  // ---------------------------------------------------------------------
+
+  // ---------------------------------------------------------------------
+  // Duplex voice settings page
+  // Mirrors yaoyao-webui's IosDuplexVoiceSettings.vue contract:
+  // {hasApiKey, voices:[{id,name}], currentVoiceId, updatedAt}
+  // ---------------------------------------------------------------------
+
+  function VoiceSettingsPage() {
+    const [settings, setSettings] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [switching, setSwitching] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    // Draft state for the editor
+    const [draftApiKey, setDraftApiKey] = useState("");
+    const [draftVoices, setDraftVoices] = useState([]);
+    const [draftCurrent, setDraftCurrent] = useState("");
+    const [editing, setEditing] = useState(false);
+
+    useEffect(() => {
+      load();
+    }, []);
+
+    async function load() {
+      setLoading(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/settings`);
+        setSettings(s);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }
+
+    const voiceOptions = useMemo(() => {
+      if (!settings) return [];
+      return settings.voices
+        .filter((v) => v.id.trim() && v.name.trim())
+        .map((v) => ({ label: v.name, value: v.id }));
+    }, [settings]);
+
+    const isConfigured = settings
+      && settings.hasApiKey
+      && voiceOptions.length > 0
+      && voiceOptions.some((o) => o.value === settings.currentVoiceId);
+
+    function openEditor() {
+      setDraftApiKey("");
+      setDraftVoices((settings?.voices || []).map((v) => ({ ...v })));
+      setDraftCurrent(settings?.currentVoiceId || "");
+      setEditing(true);
+    }
+
+    function addVoice() {
+      setDraftVoices((prev) => [...prev, { id: "", name: "" }]);
+    }
+
+    function removeVoice(idx) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        const removed = next[idx];
+        next.splice(idx, 1);
+        if (removed?.id === draftCurrent) {
+          setDraftCurrent(next[0]?.id || "");
+        }
+        return next;
+      });
+    }
+
+    function updateVoice(idx, field, value) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        next[idx] = { ...next[idx], [field]: value };
+        return next;
+      });
+    }
+
+    async function saveSettings() {
+      setSaving(true);
+      setError(null);
+      try {
+        const body = {
+          voices: draftVoices.map((v) => ({ id: v.id.trim(), name: v.name.trim() })),
+          currentVoiceId: draftCurrent,
+        };
+        if (draftApiKey.trim()) body.apiKey = draftApiKey.trim();
+        const s = await fetchJSON(`${API}/voice/settings`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(body),
+        });
+        setSettings(s);
+        setEditing(false);
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function switchVoice(voiceId) {
+      if (switching || voiceId === settings.currentVoiceId) return;
+      setSwitching(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/current-voice`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ currentVoiceId: voiceId }),
+        });
+        setSettings((prev) => ({ ...prev, ...s }));
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSwitching(false);
+      }
+    }
+
+    if (loading) return h("div", { className: "yaoyao-loading" }, "加载中…");
+    if (!settings) return h("div", { className: "yaoyao-error" }, "加载失败: " + error);
+
+    const currentVoiceName = voiceOptions.find((o) => o.value === settings.currentVoiceId)?.label
+      || settings.currentVoiceId
+      || "未选择";
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "App 双流直连配置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+
+      // Status card
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-duplex-status" },
+            h("div", { className: "yaoyao-duplex-icon" }, "D"),
+            h("div", { className: "yaoyao-duplex-info" },
+              h("div", { className: "yaoyao-duplex-title-row" },
+                h("h5", null, "火山引擎 / 豆包 TTS"),
+                h(Badge, {
+                  variant: isConfigured ? "default" : "secondary",
+                }, isConfigured ? "已配置" : "未配置")
+              ),
+              h("p", { className: "yaoyao-hint" },
+                "iOS App 直连 TTS 引擎进行双流语音对话。API Key 和声音列表在此配置后,App 从 /voice/runtime 拉取。")
+            )
+          ),
+          h("div", { className: "yaoyao-duplex-meta" },
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "当前声音"), h("span", null, currentVoiceName)),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "API Key"), h("span", null, settings.hasApiKey ? "已存储" : "未设置")),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "声音数量"), h("span", null, String(settings.voices.length))),
+          )
+        )
+      ),
+
+      // Current voice quick switch
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-current" }, "当前声音"),
+            h("div", { className: "yaoyao-voice-switch" },
+              h(Select, {
+                id: "duplex-current",
+                value: settings.currentVoiceId,
+                onChange: (e) => switchVoice(e.target.value),
+                disabled: switching || voiceOptions.length === 0,
+              },
+                voiceOptions.map((o) =>
+                  h(SelectOption, { key: o.value, value: o.value }, o.label)
+                )
+              ),
+              h(Button, {
+                variant: "outline",
+                size: "sm",
+                onClick: openEditor,
+              }, "编辑配置")
+            )
+          )
+        )
+      ),
+
+      error ? h("div", { className: "yaoyao-error" }, error) : null,
+
+      // Editor (inline form)
+      editing ? h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("h3", { className: "yaoyao-section-title" }, "编辑双流配置"),
+
+          // API Key
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-key" }, "API Key"),
+            h(Input, {
+              id: "duplex-key",
+              type: "password",
+              value: draftApiKey,
+              placeholder: settings.hasApiKey ? "已存储 (留空保持不变)" : "输入 API Key",
+              onChange: (e) => setDraftApiKey(e.target.value),
+            })
+          ),
+
+          // Current voice in draft
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-draft-current" }, "当前声音"),
+            h(Select, {
+              id: "duplex-draft-current",
+              value: draftCurrent,
+              onChange: (e) => setDraftCurrent(e.target.value),
+            },
+              draftVoices
+                .filter((v) => v.id.trim())
+                .map((v) =>
+                  h(SelectOption, { key: v.id, value: v.id }, v.name || v.id)
+                )
+            )
+          ),
+
+          // Voice list editor
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, null, "声音列表"),
+            h("div", { className: "yaoyao-voice-editor" },
+              draftVoices.map((voice, idx) =>
+                h("div", { key: idx, className: "yaoyao-voice-editor-row" },
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音名称 (如 小何 2.0)",
+                    value: voice.name,
+                    onChange: (e) => updateVoice(idx, "name", e.target.value),
+                  }),
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音 ID (如 zh_female_xiaohe_uranus_bigtts)",
+                    value: voice.id,
+                    onChange: (e) => updateVoice(idx, "id", e.target.value),
+                  }),
+                  h(Button, {
+                    variant: "ghost",
+                    size: "sm",
+                    disabled: draftVoices.length <= 1,
+                    onClick: () => removeVoice(idx),
+                  }, "×")
+                )
+              ),
+              h(Button, { variant: "outline", size: "sm", onClick: addVoice }, "+ 添加声音")
+            )
+          ),
+
+          h("div", { className: "yaoyao-voice-actions" },
+            h(Button, { variant: "ghost", onClick: () => setEditing(false) }, "取消"),
+            h(Button, { onClick: saveSettings, disabled: saving }, saving ? "保存中…" : "保存")
+          )
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // TTS / STT provider settings page
+  // Mirrors yaoyao-webui's VoiceSettings.vue + VoiceApiConfigurator.vue.
+  // ---------------------------------------------------------------------
+
+  function VoiceProvidersPage() {
+    const [ttsData, setTtsData] = useState(null);
+    const [sttData, setSttData] = useState(null);
+    const [providersInfo, setProvidersInfo] = useState(null);
+    const [error, setError] = useState(null);
+    const [saving, setSaving] = useState(false);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      Promise.all([
+        fetchJSON(`${API}/tts/settings`),
+        fetchJSON(`${API}/stt/settings`),
+        fetchJSON(`${API}/voice/providers-info`),
+      ]).then(([t, s, p]) => {
+        setTtsData(t);
+        setSttData(s);
+        setProvidersInfo(p);
+      }).catch((e) => setError(String(e.message || e)));
+    }, []);
+
+    async function setTtsActive(provider) {
+      try {
+        await fetchJSON(`${API}/tts/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setTtsData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function setSttActive(provider) {
+      try {
+        await fetchJSON(`${API}/stt/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setSttData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function saveProvider(kind, provider, settings, secrets) {
+      setSaving(true);
+      setError(null);
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        const res = await fetchJSON(endpoint, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({settings, secrets}),
+        });
+        // Refresh
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+        setSavedMsg(`${provider} 已保存`);
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function deleteProvider(kind, provider) {
+      if (!confirm(`确定删除 ${provider} 配置?`)) return;
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        await fetchJSON(endpoint, {method: "DELETE"});
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    if (!ttsData || !sttData || !providersInfo) {
+      return h("div", {className: "yaoyao-loading"}, "加载中…");
+    }
+
+    const ttsProviders = providersInfo.tts.providers;
+    const sttProviders = providersInfo.stt.providers;
+
+    function renderProviderCard(kind, info, data) {
+      const settings = (data.settings || []).find(s => s.provider === info.id);
+      const isActive = data.activeProvider === info.id;
+      const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+      const isConfigured = info.id === "edge" || info.id === "browser" || hasKey;
+
+      return h(Card, {key: info.id, className: cn("yaoyao-provider-card", isActive && "yaoyao-provider-active")},
+        h(CardContent, {className: "yaoyao-voice-card"},
+          h("div", {className: "yaoyao-provider-header"},
+            h("div", {className: "yaoyao-provider-title-row"},
+              h("h5", null, info.label),
+              h(Badge, {variant: isActive ? "default" : "secondary"},
+                isActive ? "当前" : (isConfigured ? "已配置" : "未配置"))
+            ),
+            h("p", {className: "yaoyao-hint"},
+              info.needsKey ? "需要 API Key" : "免费 / 内置")
+          ),
+          h(ProviderEditor, {
+            kind, info, settings: settings || null, saving,
+            onSave: (s, sec) => saveProvider(kind, info.id, s, sec),
+            onDelete: info.id !== "edge" && info.id !== "browser"
+              ? () => deleteProvider(kind, info.id) : null,
+            onActivate: () => kind === "tts" ? setTtsActive(info.id) : setSttActive(info.id),
+          })
+        )
+      );
+    }
+
+    return h("div", {className: "yaoyao-page"},
+      h("div", {className: "yaoyao-header"},
+        h("h2", null, "语音引擎配置"),
+        savedMsg ? h(Badge, {variant: "secondary"}, savedMsg) : null
+      ),
+      error ? h("div", {className: "yaoyao-error"}, error) : null,
+
+      h("h3", {className: "yaoyao-section-title"}, "TTS (文字转语音)"),
+      h("div", {className: "yaoyao-provider-list"},
+        ttsProviders.map(info => renderProviderCard("tts", info, ttsData))
+      ),
+
+      h("h3", {className: "yaoyao-section-title"}, "STT (语音转文字)"),
+      h("div", {className: "yaoyao-provider-list"},
+        sttProviders.map(info => renderProviderCard("stt", info, sttData))
+      )
+    );
+  }
+
+  // Inline provider editor (expandable)
+  function ProviderEditor({kind, info, settings, saving, onSave, onDelete, onActivate}) {
+    const [expanded, setExpanded] = useState(false);
+    const [draft, setDraft] = useState({});
+
+    useEffect(() => {
+      if (settings) {
+        setDraft({
+          baseUrl: settings.settings?.baseUrl || info.defaults?.baseUrl || "",
+          model: settings.settings?.model || info.defaults?.model || "",
+          voice: settings.settings?.voice || info.defaults?.voice || "",
+          language: settings.settings?.language || info.defaults?.language || "",
+          rate: settings.settings?.rate || "",
+          pitch: settings.settings?.pitch || "",
+          stylePrompt: settings.settings?.stylePrompt || "",
+          apiKey: "",
+        });
+      } else {
+        setDraft({
+          baseUrl: info.defaults?.baseUrl || "",
+          model: info.defaults?.model || "",
+          voice: info.defaults?.voice || "",
+          language: info.defaults?.language || "",
+          apiKey: "",
+        });
+      }
+    }, [info.id, settings]);
+
+    function update(field, value) {
+      setDraft(d => ({...d, [field]: value}));
+    }
+
+    function handleSave() {
+      const s = {};
+      const sec = {};
+      if (draft.baseUrl) s.baseUrl = draft.baseUrl;
+      if (draft.model) s.model = draft.model;
+      if (draft.voice) s.voice = draft.voice;
+      if (draft.language) s.language = draft.language;
+      if (draft.rate) s.rate = draft.rate;
+      if (draft.pitch) s.pitch = draft.pitch;
+      if (draft.stylePrompt) s.stylePrompt = draft.stylePrompt;
+      if (draft.apiKey) sec.apiKey = draft.apiKey;
+      onSave(s, sec);
+    }
+
+    const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+    const showFields = info.settingsKeys || [];
+
+    return h("div", {className: "yaoyao-provider-editor"},
+      h("div", {className: "yaoyao-provider-actions"},
+        h(Button, {variant: "outline", size: "sm", onClick: () => setExpanded(e => !e)},
+          expanded ? "收起" : "展开"),
+        onActivate ? h(Button, {variant: "ghost", size: "sm", onClick: onActivate}, "设为当前") : null,
+      ),
+      expanded ? h("div", {className: "yaoyao-provider-fields"},
+        showFields.includes("baseUrl") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "Base URL"),
+          h(Input, {type: "text", value: draft.baseUrl, onChange: e => update("baseUrl", e.target.value),
+            placeholder: "https://..."})
+        ) : null,
+        showFields.includes("model") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "模型"),
+          h(Input, {type: "text", value: draft.model, onChange: e => update("model", e.target.value),
+            placeholder: "model id"})
+        ) : null,
+        showFields.includes("voice") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "声音"),
+          h(Input, {type: "text", value: draft.voice, onChange: e => update("voice", e.target.value),
+            placeholder: "voice id"})
+        ) : null,
+        showFields.includes("language") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语言"),
+          h(Input, {type: "text", value: draft.language, onChange: e => update("language", e.target.value),
+            placeholder: "zh / en / ..."})
+        ) : null,
+        showFields.includes("rate") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语速"),
+          h(Input, {type: "text", value: draft.rate, onChange: e => update("rate", e.target.value),
+            placeholder: "+0%"})
+        ) : null,
+        showFields.includes("pitch") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "音调"),
+          h(Input, {type: "text", value: draft.pitch, onChange: e => update("pitch", e.target.value),
+            placeholder: "+0Hz"})
+        ) : null,
+        showFields.includes("stylePrompt") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "风格提示"),
+          h(Input, {type: "text", value: draft.stylePrompt, onChange: e => update("stylePrompt", e.target.value),
+            placeholder: "style prompt"})
+        ) : null,
+        info.needsKey ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "API Key"),
+          h(Input, {type: "password", value: draft.apiKey,
+            onChange: e => update("apiKey", e.target.value),
+            placeholder: hasKey ? "已存储 (留空保持不变)" : "输入 API Key"})
+        ) : null,
+        h("div", {className: "yaoyao-voice-actions"},
+          h(Button, {size: "sm", onClick: handleSave, disabled: saving},
+            saving ? "保存中…" : "保存"),
+          onDelete ? h(Button, {variant: "ghost", size: "sm", onClick: onDelete}, "删除") : null,
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Top-level page with internal tab switcher (Files / Duplex / Engines)
+  // ---------------------------------------------------------------------
+
+  function YaoyaoPage() {
+    const [tab, setTab] = useState("files");
+    return h("div", { className: "yaoyao-root" },
+      h("div", { className: "yaoyao-tabs" },
+        h("button", {
+          className: cn("yaoyao-tab", tab === "files" && "yaoyao-tab-active"),
+          onClick: () => setTab("files"),
+        }, "文件库"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "duplex" && "yaoyao-tab-active"),
+          onClick: () => setTab("duplex"),
+        }, "App双流直连"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "engines" && "yaoyao-tab-active"),
+          onClick: () => setTab("engines"),
+        }, "语音引擎")
+      ),
+      tab === "files" ? h(YaoyaoFilesPage)
+      : tab === "duplex" ? h(VoiceSettingsPage)
+      : h(VoiceProvidersPage)
+    );
+  }
+
+  // Register the plugin page. The name MUST match manifest.json "name".
+  if (window.__HERMES_PLUGINS__ &&
+      typeof window.__HERMES_PLUGINS__.register === "function") {
+    window.__HERMES_PLUGINS__.register("yaoyao", YaoyaoPage);
+  }
+})();

+ 934 - 0
hermes-plugins/yaoyao/dashboard/dist/index.v1.2.0.js

@@ -0,0 +1,934 @@
+/**
+ * Yaoyao File Library - dashboard plugin frontend.
+ *
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +
+ * shadcn-ui primitives + fetchJSON / authedFetch. Backend routes live under
+ * /api/plugins/yaoyao/ (auto-mounted by the dashboard plugin loader).
+ */
+(function () {
+  "use strict";
+
+  const SDK = window.__HERMES_PLUGIN_SDK__;
+  if (!SDK) return;
+  const { React } = SDK;
+  const h = React.createElement;
+  const { useState, useEffect, useCallback, useRef, useMemo } = SDK.hooks;
+  const {
+    Card,
+    CardContent,
+    Badge,
+    Button,
+    Input,
+    Label,
+    Select,
+    SelectOption,
+  } = SDK.components;
+  const { cn, timeAgo } = SDK.utils;
+  const fetchJSON = SDK.fetchJSON;
+  const authedFetch = SDK.authedFetch;
+
+  const API = "/api/plugins/yaoyao";
+
+  // ---------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------
+
+  function inferKind(name, mimeType) {
+    const ext = (name.split(".").pop() || "").toLowerCase();
+    if ((mimeType || "").startsWith("image/") ||
+        ["png","jpg","jpeg","gif","webp","bmp","svg","tiff","tif"].includes(ext))
+      return "image";
+    if ((mimeType || "").startsWith("video/") ||
+        ["mp4","mov","avi","mkv","webm","m4v","wmv","flv"].includes(ext))
+      return "video";
+    if ((mimeType || "").startsWith("text/") ||
+        ["txt","md","json","yaml","yml","csv","log","py","js","ts","html","xml","rst"].includes(ext))
+      return "text";
+    return "file";
+  }
+
+  function formatBytes(n) {
+    if (!n || n <= 0) return "0 B";
+    const units = ["B", "KB", "MB", "GB"];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v >= 100 ? v.toFixed(0) + " " + units[i]
+         : v >= 10  ? v.toFixed(1) + " " + units[i]
+                    : v.toFixed(2) + " " + units[i];
+  }
+
+  function senderLabel(s) {
+    return s === "user" ? "用户" : s === "agent" ? "Agent" : s;
+  }
+
+  function kindIcon(kind) {
+    switch (kind) {
+      case "image": return "🖼";
+      case "video": return "🎬";
+      case "text": return "📄";
+      default: return "📦";
+    }
+  }
+
+  // ---------------------------------------------------------------------
+  // Thumbnail image: fetch as blob via authedFetch (img tags can't set
+  // Authorization headers), then create an object URL.
+  // ---------------------------------------------------------------------
+
+  function Thumbnail({ item, profile }) {
+    const [url, setUrl] = useState(null);
+    const [err, setErr] = useState(false);
+    const kind = inferKind(item.name, item.mimeType);
+
+    useEffect(() => {
+      if (kind !== "image") return;
+      let revoke = null;
+      let cancelled = false;
+      const dlUrl = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+      authedFetch(dlUrl)
+        .then((res) => res.blob())
+        .then((blob) => {
+          if (cancelled) return;
+          const u = URL.createObjectURL(blob);
+          revoke = u;
+          setUrl(u);
+        })
+        .catch(() => { if (!cancelled) setErr(true); });
+      return () => {
+        cancelled = true;
+        if (revoke) URL.revokeObjectURL(revoke);
+      };
+    }, [item.id, profile]);
+
+    if (kind !== "image") {
+      return h("div", {
+        className: "yaoyao-thumb yaoyao-thumb-icon",
+      }, kindIcon(kind));
+    }
+    if (err) return h("div", { className: "yaoyao-thumb yaoyao-thumb-icon" }, "🖼");
+    if (!url) return h("div", { className: "yaoyao-thumb yaoyao-thumb-loading" }, "…");
+    return h("img", {
+      src: url,
+      alt: item.name,
+      className: "yaoyao-thumb yaoyao-thumb-img",
+      loading: "lazy",
+    });
+  }
+
+  // ---------------------------------------------------------------------
+  // File card
+  // ---------------------------------------------------------------------
+
+  function FileCard({ item, onDownload, profile }) {
+    const kind = inferKind(item.name, item.mimeType);
+    return h(Card, { className: "yaoyao-card" },
+      h(CardContent, { className: "yaoyao-card-content" },
+        h("div", { className: "yaoyao-card-preview" },
+          h(Thumbnail, { item: item, profile: profile })
+        ),
+        h("div", { className: "yaoyao-card-info" },
+          h("div", { className: "yaoyao-card-name", title: item.name }, item.name),
+          h("div", { className: "yaoyao-card-meta" },
+            h(Badge, { variant: "secondary" }, senderLabel(item.origins && item.origins[0] ? item.origins[0].authorKind : "agent")),
+            h("span", { className: "yaoyao-card-size" }, formatBytes(item.size)),
+            item.messageTimestamp
+              ? h("span", { className: "yaoyao-card-time" }, timeAgo(item.messageTimestamp * 1000))
+              : null
+          ),
+          h("div", { className: "yaoyao-card-actions" },
+            h(Button, {
+              variant: "outline",
+              size: "sm",
+              onClick: () => onDownload(item),
+            }, "下载")
+          )
+        )
+      )
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Main page
+  // ---------------------------------------------------------------------
+
+  function YaoyaoFilesPage() {
+    const [items, setItems] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [loadingMore, setLoadingMore] = useState(false);
+    const [error, setError] = useState(null);
+    const [cursor, setCursor] = useState(null);
+    const [hasMore, setHasMore] = useState(false);
+    const [stats, setStats] = useState(null);
+    const [profiles, setProfiles] = useState([]);
+    // Initial profile from the URL (?profile=gril) so deep links / shared
+    // links land on the right agent's library; fall back to "default".
+    const [profile, setProfile] = useState(() => {
+      try {
+        const u = new URL(window.location.href);
+        const p = u.searchParams.get("profile");
+        return p || "default";
+      } catch (e) {
+        return "default";
+      }
+    });
+    const [filter, setFilter] = useState({ sender: "", kind: "" });
+    const [search, setSearch] = useState("");
+    const [searchInput, setSearchInput] = useState("");
+    const sentinelRef = useRef(null);
+
+    // Keep the URL ?profile= in sync with the dropdown selection so the
+    // address bar reflects what's shown (and is shareable / refreshable).
+    useEffect(() => {
+      try {
+        const u = new URL(window.location.href);
+        if (profile && profile !== "default") {
+          u.searchParams.set("profile", profile);
+        } else {
+          u.searchParams.delete("profile");
+        }
+        window.history.replaceState(null, "", u.toString());
+      } catch (e) { /* ignore */ }
+    }, [profile]);
+
+    // Also react to back/forward navigation so the dropdown follows the URL.
+    useEffect(() => {
+      const onPop = () => {
+        try {
+          const u = new URL(window.location.href);
+          const p = u.searchParams.get("profile") || "default";
+          setProfile(p);
+        } catch (e) { /* ignore */ }
+      };
+      window.addEventListener("popstate", onPop);
+      return () => window.removeEventListener("popstate", onPop);
+    }, []);
+
+    // Profiles dropdown: which agents have libraries. default always present.
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((res) => {
+          const list = (res.profiles || []);
+          setProfiles(list);
+          // If the current selection vanished (profile deleted), fall back.
+          if (list.length && !list.some((p) => p.name === profile)) {
+            setProfile("default");
+          }
+        })
+        .catch(() => {});
+    }, []); // eslint-disable-line
+
+    // Fetch first page whenever filter / search / profile changes.
+    const loadFirst = useCallback(async () => {
+      setLoading(true);
+      setError(null);
+      try {
+        const params = { limit: 50 };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems(res.items || []);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }, [filter, search, profile]);
+
+    useEffect(() => { loadFirst(); }, [loadFirst]);
+
+    // Stats (re-fetch when profile changes)
+    useEffect(() => {
+      const params = {};
+      if (profile) params.profile = profile;
+      const qs = new URLSearchParams(params).toString();
+      fetchJSON(`${API}/stats?${qs}`).then(setStats).catch(() => {});
+    }, [profile]);
+
+    // Load more
+    const loadMore = useCallback(async () => {
+      if (!cursor || loadingMore) return;
+      setLoadingMore(true);
+      try {
+        const params = { limit: 50, cursor };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems((prev) => [...prev, ...(res.items || [])]);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoadingMore(false);
+      }
+    }, [cursor, filter, search, profile, loadingMore]);
+
+    // Infinite scroll via IntersectionObserver
+    useEffect(() => {
+      const el = sentinelRef.current;
+      if (!el || !hasMore) return;
+      const obs = new IntersectionObserver((entries) => {
+        if (entries[0].isIntersecting) loadMore();
+      }, { rootMargin: "200px" });
+      obs.observe(el);
+      return () => obs.disconnect();
+    }, [hasMore, loadMore]);
+
+    const onDownload = useCallback(async (item) => {
+      try {
+        const url = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+        const res = await authedFetch(url);
+        const blob = await res.blob();
+        const urlObj = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = urlObj;
+        a.download = item.name;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        setTimeout(() => URL.revokeObjectURL(urlObj), 10000);
+      } catch (e) {
+        alert("下载失败: " + (e.message || e));
+      }
+    }, [profile]);
+
+    const onSearchSubmit = useCallback((e) => {
+      e.preventDefault();
+      setSearch(searchInput.trim());
+    }, [searchInput]);
+
+    return h("div", { className: "yaoyao-page" },
+      // Header + stats
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "夭夭文件库"),
+        stats ? h("div", { className: "yaoyao-stats" },
+          profile && profile !== "default"
+            ? h(Badge, { variant: "secondary" }, `Agent: ${profile}`)
+            : null,
+          h(Badge, { variant: "secondary" }, `共 ${stats.totalAttachments} 个文件`),
+          h(Badge, { variant: "secondary" }, `${formatBytes(stats.totalBytes)}`),
+          stats.bySender && stats.bySender.agent
+            ? h(Badge, { variant: "outline" }, `Agent ${stats.bySender.agent}`)
+            : null,
+          stats.bySender && stats.bySender.user
+            ? h(Badge, { variant: "outline" }, `用户 ${stats.bySender.user}`)
+            : null,
+        ) : null
+      ),
+
+      // Filters
+      h("div", { className: "yaoyao-filters" },
+        h(Label, null, "Agent"),
+        h(Select, {
+          value: profile,
+          onChange: (e) => setProfile(e.target.value),
+          disabled: profiles.length === 0,
+        },
+          profiles.length === 0
+            ? h(SelectOption, { value: "default" }, "默认")
+            : profiles.map((p) =>
+                h(SelectOption, { key: p.name, value: p.name }, p.label)
+              )
+        ),
+        h(Label, null, "来源"),
+        h(Select, {
+          value: filter.sender,
+          onChange: (e) => setFilter((f) => ({ ...f, sender: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "agent" }, "Agent"),
+          h(SelectOption, { value: "user" }, "用户")
+        ),
+        h(Label, null, "类型"),
+        h(Select, {
+          value: filter.kind,
+          onChange: (e) => setFilter((f) => ({ ...f, kind: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "image" }, "图片"),
+          h(SelectOption, { value: "video" }, "视频"),
+          h(SelectOption, { value: "text" }, "文本"),
+          h(SelectOption, { value: "file" }, "文档")
+        ),
+        h("form", { onSubmit: onSearchSubmit, className: "yaoyao-search" },
+          h(Input, {
+            type: "text",
+            placeholder: "搜索文件名…",
+            value: searchInput,
+            onChange: (e) => setSearchInput(e.target.value),
+          }),
+          h(Button, { type: "submit", size: "sm" }, "搜索")
+        )
+      ),
+
+      // Error
+      error ? h("div", { className: "yaoyao-error" }, "错误: " + error) : null,
+
+      // Loading
+      loading ? h("div", { className: "yaoyao-loading" }, "加载中…") :
+      // Grid
+      items.length === 0
+        ? h("div", { className: "yaoyao-empty" }, "暂无文件")
+        : h("div", { className: "yaoyao-grid" },
+            items.map((item) =>
+              h(FileCard, { key: item.id, item: item, onDownload: onDownload, profile: profile })
+            )
+          ),
+
+      // Infinite scroll sentinel
+      hasMore ? h("div", {
+        ref: sentinelRef,
+        className: "yaoyao-sentinel"
+      }, loadingMore ? "加载更多…" : "") : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Voice settings page
+  // ---------------------------------------------------------------------
+
+  // ---------------------------------------------------------------------
+  // Duplex voice settings page
+  // Mirrors yaoyao-webui's IosDuplexVoiceSettings.vue contract:
+  // {hasApiKey, voices:[{id,name}], currentVoiceId, updatedAt}
+  // ---------------------------------------------------------------------
+
+  function VoiceSettingsPage() {
+    const [settings, setSettings] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [switching, setSwitching] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    // Draft state for the editor
+    const [draftApiKey, setDraftApiKey] = useState("");
+    const [draftVoices, setDraftVoices] = useState([]);
+    const [draftCurrent, setDraftCurrent] = useState("");
+    const [editing, setEditing] = useState(false);
+
+    useEffect(() => {
+      load();
+    }, []);
+
+    async function load() {
+      setLoading(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/settings`);
+        setSettings(s);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }
+
+    const voiceOptions = useMemo(() => {
+      if (!settings) return [];
+      return settings.voices
+        .filter((v) => v.id.trim() && v.name.trim())
+        .map((v) => ({ label: v.name, value: v.id }));
+    }, [settings]);
+
+    const isConfigured = settings
+      && settings.hasApiKey
+      && voiceOptions.length > 0
+      && voiceOptions.some((o) => o.value === settings.currentVoiceId);
+
+    function openEditor() {
+      setDraftApiKey("");
+      setDraftVoices((settings?.voices || []).map((v) => ({ ...v })));
+      setDraftCurrent(settings?.currentVoiceId || "");
+      setEditing(true);
+    }
+
+    function addVoice() {
+      setDraftVoices((prev) => [...prev, { id: "", name: "" }]);
+    }
+
+    function removeVoice(idx) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        const removed = next[idx];
+        next.splice(idx, 1);
+        if (removed?.id === draftCurrent) {
+          setDraftCurrent(next[0]?.id || "");
+        }
+        return next;
+      });
+    }
+
+    function updateVoice(idx, field, value) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        next[idx] = { ...next[idx], [field]: value };
+        return next;
+      });
+    }
+
+    async function saveSettings() {
+      setSaving(true);
+      setError(null);
+      try {
+        const body = {
+          voices: draftVoices.map((v) => ({ id: v.id.trim(), name: v.name.trim() })),
+          currentVoiceId: draftCurrent,
+        };
+        if (draftApiKey.trim()) body.apiKey = draftApiKey.trim();
+        const s = await fetchJSON(`${API}/voice/settings`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(body),
+        });
+        setSettings(s);
+        setEditing(false);
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function switchVoice(voiceId) {
+      if (switching || voiceId === settings.currentVoiceId) return;
+      setSwitching(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/current-voice`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ currentVoiceId: voiceId }),
+        });
+        setSettings((prev) => ({ ...prev, ...s }));
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSwitching(false);
+      }
+    }
+
+    if (loading) return h("div", { className: "yaoyao-loading" }, "加载中…");
+    if (!settings) return h("div", { className: "yaoyao-error" }, "加载失败: " + error);
+
+    const currentVoiceName = voiceOptions.find((o) => o.value === settings.currentVoiceId)?.label
+      || settings.currentVoiceId
+      || "未选择";
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "App 双流直连配置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+
+      // Status card
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-duplex-status" },
+            h("div", { className: "yaoyao-duplex-icon" }, "D"),
+            h("div", { className: "yaoyao-duplex-info" },
+              h("div", { className: "yaoyao-duplex-title-row" },
+                h("h5", null, "火山引擎 / 豆包 TTS"),
+                h(Badge, {
+                  variant: isConfigured ? "default" : "secondary",
+                }, isConfigured ? "已配置" : "未配置")
+              ),
+              h("p", { className: "yaoyao-hint" },
+                "iOS App 直连 TTS 引擎进行双流语音对话。API Key 和声音列表在此配置后,App 从 /voice/runtime 拉取。")
+            )
+          ),
+          h("div", { className: "yaoyao-duplex-meta" },
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "当前声音"), h("span", null, currentVoiceName)),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "API Key"), h("span", null, settings.hasApiKey ? "已存储" : "未设置")),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "声音数量"), h("span", null, String(settings.voices.length))),
+          )
+        )
+      ),
+
+      // Current voice quick switch
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-current" }, "当前声音"),
+            h("div", { className: "yaoyao-voice-switch" },
+              h(Select, {
+                id: "duplex-current",
+                value: settings.currentVoiceId,
+                onChange: (e) => switchVoice(e.target.value),
+                disabled: switching || voiceOptions.length === 0,
+              },
+                voiceOptions.map((o) =>
+                  h(SelectOption, { key: o.value, value: o.value }, o.label)
+                )
+              ),
+              h(Button, {
+                variant: "outline",
+                size: "sm",
+                onClick: openEditor,
+              }, "编辑配置")
+            )
+          )
+        )
+      ),
+
+      error ? h("div", { className: "yaoyao-error" }, error) : null,
+
+      // Editor (inline form)
+      editing ? h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("h3", { className: "yaoyao-section-title" }, "编辑双流配置"),
+
+          // API Key
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-key" }, "API Key"),
+            h(Input, {
+              id: "duplex-key",
+              type: "password",
+              value: draftApiKey,
+              placeholder: settings.hasApiKey ? "已存储 (留空保持不变)" : "输入 API Key",
+              onChange: (e) => setDraftApiKey(e.target.value),
+            })
+          ),
+
+          // Current voice in draft
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-draft-current" }, "当前声音"),
+            h(Select, {
+              id: "duplex-draft-current",
+              value: draftCurrent,
+              onChange: (e) => setDraftCurrent(e.target.value),
+            },
+              draftVoices
+                .filter((v) => v.id.trim())
+                .map((v) =>
+                  h(SelectOption, { key: v.id, value: v.id }, v.name || v.id)
+                )
+            )
+          ),
+
+          // Voice list editor
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, null, "声音列表"),
+            h("div", { className: "yaoyao-voice-editor" },
+              draftVoices.map((voice, idx) =>
+                h("div", { key: idx, className: "yaoyao-voice-editor-row" },
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音名称 (如 小何 2.0)",
+                    value: voice.name,
+                    onChange: (e) => updateVoice(idx, "name", e.target.value),
+                  }),
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音 ID (如 zh_female_xiaohe_uranus_bigtts)",
+                    value: voice.id,
+                    onChange: (e) => updateVoice(idx, "id", e.target.value),
+                  }),
+                  h(Button, {
+                    variant: "ghost",
+                    size: "sm",
+                    disabled: draftVoices.length <= 1,
+                    onClick: () => removeVoice(idx),
+                  }, "×")
+                )
+              ),
+              h(Button, { variant: "outline", size: "sm", onClick: addVoice }, "+ 添加声音")
+            )
+          ),
+
+          h("div", { className: "yaoyao-voice-actions" },
+            h(Button, { variant: "ghost", onClick: () => setEditing(false) }, "取消"),
+            h(Button, { onClick: saveSettings, disabled: saving }, saving ? "保存中…" : "保存")
+          )
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // TTS / STT provider settings page
+  // Mirrors yaoyao-webui's VoiceSettings.vue + VoiceApiConfigurator.vue.
+  // ---------------------------------------------------------------------
+
+  function VoiceProvidersPage() {
+    const [ttsData, setTtsData] = useState(null);
+    const [sttData, setSttData] = useState(null);
+    const [providersInfo, setProvidersInfo] = useState(null);
+    const [error, setError] = useState(null);
+    const [saving, setSaving] = useState(false);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      Promise.all([
+        fetchJSON(`${API}/tts/settings`),
+        fetchJSON(`${API}/stt/settings`),
+        fetchJSON(`${API}/voice/providers-info`),
+      ]).then(([t, s, p]) => {
+        setTtsData(t);
+        setSttData(s);
+        setProvidersInfo(p);
+      }).catch((e) => setError(String(e.message || e)));
+    }, []);
+
+    async function setTtsActive(provider) {
+      try {
+        await fetchJSON(`${API}/tts/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setTtsData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function setSttActive(provider) {
+      try {
+        await fetchJSON(`${API}/stt/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setSttData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function saveProvider(kind, provider, settings, secrets) {
+      setSaving(true);
+      setError(null);
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        const res = await fetchJSON(endpoint, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({settings, secrets}),
+        });
+        // Refresh
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+        setSavedMsg(`${provider} 已保存`);
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function deleteProvider(kind, provider) {
+      if (!confirm(`确定删除 ${provider} 配置?`)) return;
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        await fetchJSON(endpoint, {method: "DELETE"});
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    if (!ttsData || !sttData || !providersInfo) {
+      return h("div", {className: "yaoyao-loading"}, "加载中…");
+    }
+
+    const ttsProviders = providersInfo.tts.providers;
+    const sttProviders = providersInfo.stt.providers;
+
+    function renderProviderCard(kind, info, data) {
+      const settings = (data.settings || []).find(s => s.provider === info.id);
+      const isActive = data.activeProvider === info.id;
+      const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+      const isConfigured = info.id === "edge" || info.id === "browser" || hasKey;
+
+      return h(Card, {key: info.id, className: cn("yaoyao-provider-card", isActive && "yaoyao-provider-active")},
+        h(CardContent, {className: "yaoyao-voice-card"},
+          h("div", {className: "yaoyao-provider-header"},
+            h("div", {className: "yaoyao-provider-title-row"},
+              h("h5", null, info.label),
+              h(Badge, {variant: isActive ? "default" : "secondary"},
+                isActive ? "当前" : (isConfigured ? "已配置" : "未配置"))
+            ),
+            h("p", {className: "yaoyao-hint"},
+              info.needsKey ? "需要 API Key" : "免费 / 内置")
+          ),
+          h(ProviderEditor, {
+            kind, info, settings: settings || null, saving,
+            onSave: (s, sec) => saveProvider(kind, info.id, s, sec),
+            onDelete: info.id !== "edge" && info.id !== "browser"
+              ? () => deleteProvider(kind, info.id) : null,
+            onActivate: () => kind === "tts" ? setTtsActive(info.id) : setSttActive(info.id),
+          })
+        )
+      );
+    }
+
+    return h("div", {className: "yaoyao-page"},
+      h("div", {className: "yaoyao-header"},
+        h("h2", null, "语音引擎配置"),
+        savedMsg ? h(Badge, {variant: "secondary"}, savedMsg) : null
+      ),
+      error ? h("div", {className: "yaoyao-error"}, error) : null,
+
+      h("h3", {className: "yaoyao-section-title"}, "TTS (文字转语音)"),
+      h("div", {className: "yaoyao-provider-list"},
+        ttsProviders.map(info => renderProviderCard("tts", info, ttsData))
+      ),
+
+      h("h3", {className: "yaoyao-section-title"}, "STT (语音转文字)"),
+      h("div", {className: "yaoyao-provider-list"},
+        sttProviders.map(info => renderProviderCard("stt", info, sttData))
+      )
+    );
+  }
+
+  // Inline provider editor (expandable)
+  function ProviderEditor({kind, info, settings, saving, onSave, onDelete, onActivate}) {
+    const [expanded, setExpanded] = useState(false);
+    const [draft, setDraft] = useState({});
+
+    useEffect(() => {
+      if (settings) {
+        setDraft({
+          baseUrl: settings.settings?.baseUrl || info.defaults?.baseUrl || "",
+          model: settings.settings?.model || info.defaults?.model || "",
+          voice: settings.settings?.voice || info.defaults?.voice || "",
+          language: settings.settings?.language || info.defaults?.language || "",
+          rate: settings.settings?.rate || "",
+          pitch: settings.settings?.pitch || "",
+          stylePrompt: settings.settings?.stylePrompt || "",
+          apiKey: "",
+        });
+      } else {
+        setDraft({
+          baseUrl: info.defaults?.baseUrl || "",
+          model: info.defaults?.model || "",
+          voice: info.defaults?.voice || "",
+          language: info.defaults?.language || "",
+          apiKey: "",
+        });
+      }
+    }, [info.id, settings]);
+
+    function update(field, value) {
+      setDraft(d => ({...d, [field]: value}));
+    }
+
+    function handleSave() {
+      const s = {};
+      const sec = {};
+      if (draft.baseUrl) s.baseUrl = draft.baseUrl;
+      if (draft.model) s.model = draft.model;
+      if (draft.voice) s.voice = draft.voice;
+      if (draft.language) s.language = draft.language;
+      if (draft.rate) s.rate = draft.rate;
+      if (draft.pitch) s.pitch = draft.pitch;
+      if (draft.stylePrompt) s.stylePrompt = draft.stylePrompt;
+      if (draft.apiKey) sec.apiKey = draft.apiKey;
+      onSave(s, sec);
+    }
+
+    const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+    const showFields = info.settingsKeys || [];
+
+    return h("div", {className: "yaoyao-provider-editor"},
+      h("div", {className: "yaoyao-provider-actions"},
+        h(Button, {variant: "outline", size: "sm", onClick: () => setExpanded(e => !e)},
+          expanded ? "收起" : "展开"),
+        onActivate ? h(Button, {variant: "ghost", size: "sm", onClick: onActivate}, "设为当前") : null,
+      ),
+      expanded ? h("div", {className: "yaoyao-provider-fields"},
+        showFields.includes("baseUrl") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "Base URL"),
+          h(Input, {type: "text", value: draft.baseUrl, onChange: e => update("baseUrl", e.target.value),
+            placeholder: "https://..."})
+        ) : null,
+        showFields.includes("model") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "模型"),
+          h(Input, {type: "text", value: draft.model, onChange: e => update("model", e.target.value),
+            placeholder: "model id"})
+        ) : null,
+        showFields.includes("voice") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "声音"),
+          h(Input, {type: "text", value: draft.voice, onChange: e => update("voice", e.target.value),
+            placeholder: "voice id"})
+        ) : null,
+        showFields.includes("language") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语言"),
+          h(Input, {type: "text", value: draft.language, onChange: e => update("language", e.target.value),
+            placeholder: "zh / en / ..."})
+        ) : null,
+        showFields.includes("rate") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语速"),
+          h(Input, {type: "text", value: draft.rate, onChange: e => update("rate", e.target.value),
+            placeholder: "+0%"})
+        ) : null,
+        showFields.includes("pitch") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "音调"),
+          h(Input, {type: "text", value: draft.pitch, onChange: e => update("pitch", e.target.value),
+            placeholder: "+0Hz"})
+        ) : null,
+        showFields.includes("stylePrompt") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "风格提示"),
+          h(Input, {type: "text", value: draft.stylePrompt, onChange: e => update("stylePrompt", e.target.value),
+            placeholder: "style prompt"})
+        ) : null,
+        info.needsKey ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "API Key"),
+          h(Input, {type: "password", value: draft.apiKey,
+            onChange: e => update("apiKey", e.target.value),
+            placeholder: hasKey ? "已存储 (留空保持不变)" : "输入 API Key"})
+        ) : null,
+        h("div", {className: "yaoyao-voice-actions"},
+          h(Button, {size: "sm", onClick: handleSave, disabled: saving},
+            saving ? "保存中…" : "保存"),
+          onDelete ? h(Button, {variant: "ghost", size: "sm", onClick: onDelete}, "删除") : null,
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Top-level page with internal tab switcher (Files / Duplex / Engines)
+  // ---------------------------------------------------------------------
+
+  function YaoyaoPage() {
+    const [tab, setTab] = useState("files");
+    return h("div", { className: "yaoyao-root" },
+      h("div", { className: "yaoyao-tabs" },
+        h("button", {
+          className: cn("yaoyao-tab", tab === "files" && "yaoyao-tab-active"),
+          onClick: () => setTab("files"),
+        }, "文件库"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "duplex" && "yaoyao-tab-active"),
+          onClick: () => setTab("duplex"),
+        }, "App双流直连"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "engines" && "yaoyao-tab-active"),
+          onClick: () => setTab("engines"),
+        }, "语音引擎")
+      ),
+      tab === "files" ? h(YaoyaoFilesPage)
+      : tab === "duplex" ? h(VoiceSettingsPage)
+      : h(VoiceProvidersPage)
+    );
+  }
+
+  // Register the plugin page. The name MUST match manifest.json "name".
+  if (window.__HERMES_PLUGINS__ &&
+      typeof window.__HERMES_PLUGINS__.register === "function") {
+    window.__HERMES_PLUGINS__.register("yaoyao", YaoyaoPage);
+  }
+})();

+ 934 - 0
hermes-plugins/yaoyao/dashboard/dist/index.v1.3.0.js

@@ -0,0 +1,934 @@
+/**
+ * Yaoyao File Library - dashboard plugin frontend.
+ *
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +
+ * shadcn-ui primitives + fetchJSON / authedFetch. Backend routes live under
+ * /api/plugins/yaoyao/ (auto-mounted by the dashboard plugin loader).
+ */
+(function () {
+  "use strict";
+
+  const SDK = window.__HERMES_PLUGIN_SDK__;
+  if (!SDK) return;
+  const { React } = SDK;
+  const h = React.createElement;
+  const { useState, useEffect, useCallback, useRef, useMemo } = SDK.hooks;
+  const {
+    Card,
+    CardContent,
+    Badge,
+    Button,
+    Input,
+    Label,
+    Select,
+    SelectOption,
+  } = SDK.components;
+  const { cn, timeAgo } = SDK.utils;
+  const fetchJSON = SDK.fetchJSON;
+  const authedFetch = SDK.authedFetch;
+
+  const API = "/api/plugins/yaoyao";
+
+  // ---------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------
+
+  function inferKind(name, mimeType) {
+    const ext = (name.split(".").pop() || "").toLowerCase();
+    if ((mimeType || "").startsWith("image/") ||
+        ["png","jpg","jpeg","gif","webp","bmp","svg","tiff","tif"].includes(ext))
+      return "image";
+    if ((mimeType || "").startsWith("video/") ||
+        ["mp4","mov","avi","mkv","webm","m4v","wmv","flv"].includes(ext))
+      return "video";
+    if ((mimeType || "").startsWith("text/") ||
+        ["txt","md","json","yaml","yml","csv","log","py","js","ts","html","xml","rst"].includes(ext))
+      return "text";
+    return "file";
+  }
+
+  function formatBytes(n) {
+    if (!n || n <= 0) return "0 B";
+    const units = ["B", "KB", "MB", "GB"];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v >= 100 ? v.toFixed(0) + " " + units[i]
+         : v >= 10  ? v.toFixed(1) + " " + units[i]
+                    : v.toFixed(2) + " " + units[i];
+  }
+
+  function senderLabel(s) {
+    return s === "user" ? "用户" : s === "agent" ? "Agent" : s;
+  }
+
+  function kindIcon(kind) {
+    switch (kind) {
+      case "image": return "🖼";
+      case "video": return "🎬";
+      case "text": return "📄";
+      default: return "📦";
+    }
+  }
+
+  // ---------------------------------------------------------------------
+  // Thumbnail image: fetch as blob via authedFetch (img tags can't set
+  // Authorization headers), then create an object URL.
+  // ---------------------------------------------------------------------
+
+  function Thumbnail({ item, profile }) {
+    const [url, setUrl] = useState(null);
+    const [err, setErr] = useState(false);
+    const kind = inferKind(item.name, item.mimeType);
+
+    useEffect(() => {
+      if (kind !== "image") return;
+      let revoke = null;
+      let cancelled = false;
+      const dlUrl = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+      authedFetch(dlUrl)
+        .then((res) => res.blob())
+        .then((blob) => {
+          if (cancelled) return;
+          const u = URL.createObjectURL(blob);
+          revoke = u;
+          setUrl(u);
+        })
+        .catch(() => { if (!cancelled) setErr(true); });
+      return () => {
+        cancelled = true;
+        if (revoke) URL.revokeObjectURL(revoke);
+      };
+    }, [item.id, profile]);
+
+    if (kind !== "image") {
+      return h("div", {
+        className: "yaoyao-thumb yaoyao-thumb-icon",
+      }, kindIcon(kind));
+    }
+    if (err) return h("div", { className: "yaoyao-thumb yaoyao-thumb-icon" }, "🖼");
+    if (!url) return h("div", { className: "yaoyao-thumb yaoyao-thumb-loading" }, "…");
+    return h("img", {
+      src: url,
+      alt: item.name,
+      className: "yaoyao-thumb yaoyao-thumb-img",
+      loading: "lazy",
+    });
+  }
+
+  // ---------------------------------------------------------------------
+  // File card
+  // ---------------------------------------------------------------------
+
+  function FileCard({ item, onDownload, profile }) {
+    const kind = inferKind(item.name, item.mimeType);
+    return h(Card, { className: "yaoyao-card" },
+      h(CardContent, { className: "yaoyao-card-content" },
+        h("div", { className: "yaoyao-card-preview" },
+          h(Thumbnail, { item: item, profile: profile })
+        ),
+        h("div", { className: "yaoyao-card-info" },
+          h("div", { className: "yaoyao-card-name", title: item.name }, item.name),
+          h("div", { className: "yaoyao-card-meta" },
+            h(Badge, { variant: "secondary" }, senderLabel(item.origins && item.origins[0] ? item.origins[0].authorKind : "agent")),
+            h("span", { className: "yaoyao-card-size" }, formatBytes(item.size)),
+            item.messageTimestamp
+              ? h("span", { className: "yaoyao-card-time" }, timeAgo(item.messageTimestamp * 1000))
+              : null
+          ),
+          h("div", { className: "yaoyao-card-actions" },
+            h(Button, {
+              variant: "outline",
+              size: "sm",
+              onClick: () => onDownload(item),
+            }, "下载")
+          )
+        )
+      )
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Main page
+  // ---------------------------------------------------------------------
+
+  function YaoyaoFilesPage() {
+    const [items, setItems] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [loadingMore, setLoadingMore] = useState(false);
+    const [error, setError] = useState(null);
+    const [cursor, setCursor] = useState(null);
+    const [hasMore, setHasMore] = useState(false);
+    const [stats, setStats] = useState(null);
+    const [profiles, setProfiles] = useState([]);
+    // Initial profile from the URL (?profile=gril) so deep links / shared
+    // links land on the right agent's library; fall back to "default".
+    const [profile, setProfile] = useState(() => {
+      try {
+        const u = new URL(window.location.href);
+        const p = u.searchParams.get("profile");
+        return p || "default";
+      } catch (e) {
+        return "default";
+      }
+    });
+    const [filter, setFilter] = useState({ sender: "", kind: "" });
+    const [search, setSearch] = useState("");
+    const [searchInput, setSearchInput] = useState("");
+    const sentinelRef = useRef(null);
+
+    // Keep the URL ?profile= in sync with the dropdown selection so the
+    // address bar reflects what's shown (and is shareable / refreshable).
+    useEffect(() => {
+      try {
+        const u = new URL(window.location.href);
+        if (profile && profile !== "default") {
+          u.searchParams.set("profile", profile);
+        } else {
+          u.searchParams.delete("profile");
+        }
+        window.history.replaceState(null, "", u.toString());
+      } catch (e) { /* ignore */ }
+    }, [profile]);
+
+    // Also react to back/forward navigation so the dropdown follows the URL.
+    useEffect(() => {
+      const onPop = () => {
+        try {
+          const u = new URL(window.location.href);
+          const p = u.searchParams.get("profile") || "default";
+          setProfile(p);
+        } catch (e) { /* ignore */ }
+      };
+      window.addEventListener("popstate", onPop);
+      return () => window.removeEventListener("popstate", onPop);
+    }, []);
+
+    // Profiles dropdown: which agents have libraries. default always present.
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((res) => {
+          const list = (res.profiles || []);
+          setProfiles(list);
+          // If the current selection vanished (profile deleted), fall back.
+          if (list.length && !list.some((p) => p.name === profile)) {
+            setProfile("default");
+          }
+        })
+        .catch(() => {});
+    }, []); // eslint-disable-line
+
+    // Fetch first page whenever filter / search / profile changes.
+    const loadFirst = useCallback(async () => {
+      setLoading(true);
+      setError(null);
+      try {
+        const params = { limit: 50 };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems(res.items || []);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }, [filter, search, profile]);
+
+    useEffect(() => { loadFirst(); }, [loadFirst]);
+
+    // Stats (re-fetch when profile changes)
+    useEffect(() => {
+      const params = {};
+      if (profile) params.profile = profile;
+      const qs = new URLSearchParams(params).toString();
+      fetchJSON(`${API}/stats?${qs}`).then(setStats).catch(() => {});
+    }, [profile]);
+
+    // Load more
+    const loadMore = useCallback(async () => {
+      if (!cursor || loadingMore) return;
+      setLoadingMore(true);
+      try {
+        const params = { limit: 50, cursor };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems((prev) => [...prev, ...(res.items || [])]);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoadingMore(false);
+      }
+    }, [cursor, filter, search, profile, loadingMore]);
+
+    // Infinite scroll via IntersectionObserver
+    useEffect(() => {
+      const el = sentinelRef.current;
+      if (!el || !hasMore) return;
+      const obs = new IntersectionObserver((entries) => {
+        if (entries[0].isIntersecting) loadMore();
+      }, { rootMargin: "200px" });
+      obs.observe(el);
+      return () => obs.disconnect();
+    }, [hasMore, loadMore]);
+
+    const onDownload = useCallback(async (item) => {
+      try {
+        const url = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+        const res = await authedFetch(url);
+        const blob = await res.blob();
+        const urlObj = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = urlObj;
+        a.download = item.name;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        setTimeout(() => URL.revokeObjectURL(urlObj), 10000);
+      } catch (e) {
+        alert("下载失败: " + (e.message || e));
+      }
+    }, [profile]);
+
+    const onSearchSubmit = useCallback((e) => {
+      e.preventDefault();
+      setSearch(searchInput.trim());
+    }, [searchInput]);
+
+    return h("div", { className: "yaoyao-page" },
+      // Header + stats
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "夭夭文件库"),
+        stats ? h("div", { className: "yaoyao-stats" },
+          profile && profile !== "default"
+            ? h(Badge, { variant: "secondary" }, `Agent: ${profile}`)
+            : null,
+          h(Badge, { variant: "secondary" }, `共 ${stats.totalAttachments} 个文件`),
+          h(Badge, { variant: "secondary" }, `${formatBytes(stats.totalBytes)}`),
+          stats.bySender && stats.bySender.agent
+            ? h(Badge, { variant: "outline" }, `Agent ${stats.bySender.agent}`)
+            : null,
+          stats.bySender && stats.bySender.user
+            ? h(Badge, { variant: "outline" }, `用户 ${stats.bySender.user}`)
+            : null,
+        ) : null
+      ),
+
+      // Filters
+      h("div", { className: "yaoyao-filters" },
+        h(Label, null, "Agent"),
+        h(Select, {
+          value: profile,
+          onChange: (e) => setProfile(e.target.value),
+          disabled: profiles.length === 0,
+        },
+          profiles.length === 0
+            ? h(SelectOption, { value: "default" }, "默认")
+            : profiles.map((p) =>
+                h(SelectOption, { key: p.name, value: p.name }, p.label)
+              )
+        ),
+        h(Label, null, "来源"),
+        h(Select, {
+          value: filter.sender,
+          onChange: (e) => setFilter((f) => ({ ...f, sender: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "agent" }, "Agent"),
+          h(SelectOption, { value: "user" }, "用户")
+        ),
+        h(Label, null, "类型"),
+        h(Select, {
+          value: filter.kind,
+          onChange: (e) => setFilter((f) => ({ ...f, kind: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "image" }, "图片"),
+          h(SelectOption, { value: "video" }, "视频"),
+          h(SelectOption, { value: "text" }, "文本"),
+          h(SelectOption, { value: "file" }, "文档")
+        ),
+        h("form", { onSubmit: onSearchSubmit, className: "yaoyao-search" },
+          h(Input, {
+            type: "text",
+            placeholder: "搜索文件名…",
+            value: searchInput,
+            onChange: (e) => setSearchInput(e.target.value),
+          }),
+          h(Button, { type: "submit", size: "sm" }, "搜索")
+        )
+      ),
+
+      // Error
+      error ? h("div", { className: "yaoyao-error" }, "错误: " + error) : null,
+
+      // Loading
+      loading ? h("div", { className: "yaoyao-loading" }, "加载中…") :
+      // Grid
+      items.length === 0
+        ? h("div", { className: "yaoyao-empty" }, "暂无文件")
+        : h("div", { className: "yaoyao-grid" },
+            items.map((item) =>
+              h(FileCard, { key: item.id, item: item, onDownload: onDownload, profile: profile })
+            )
+          ),
+
+      // Infinite scroll sentinel
+      hasMore ? h("div", {
+        ref: sentinelRef,
+        className: "yaoyao-sentinel"
+      }, loadingMore ? "加载更多…" : "") : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Voice settings page
+  // ---------------------------------------------------------------------
+
+  // ---------------------------------------------------------------------
+  // Duplex voice settings page
+  // Mirrors yaoyao-webui's IosDuplexVoiceSettings.vue contract:
+  // {hasApiKey, voices:[{id,name}], currentVoiceId, updatedAt}
+  // ---------------------------------------------------------------------
+
+  function VoiceSettingsPage() {
+    const [settings, setSettings] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [switching, setSwitching] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    // Draft state for the editor
+    const [draftApiKey, setDraftApiKey] = useState("");
+    const [draftVoices, setDraftVoices] = useState([]);
+    const [draftCurrent, setDraftCurrent] = useState("");
+    const [editing, setEditing] = useState(false);
+
+    useEffect(() => {
+      load();
+    }, []);
+
+    async function load() {
+      setLoading(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/settings`);
+        setSettings(s);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }
+
+    const voiceOptions = useMemo(() => {
+      if (!settings) return [];
+      return settings.voices
+        .filter((v) => v.id.trim() && v.name.trim())
+        .map((v) => ({ label: v.name, value: v.id }));
+    }, [settings]);
+
+    const isConfigured = settings
+      && settings.hasApiKey
+      && voiceOptions.length > 0
+      && voiceOptions.some((o) => o.value === settings.currentVoiceId);
+
+    function openEditor() {
+      setDraftApiKey("");
+      setDraftVoices((settings?.voices || []).map((v) => ({ ...v })));
+      setDraftCurrent(settings?.currentVoiceId || "");
+      setEditing(true);
+    }
+
+    function addVoice() {
+      setDraftVoices((prev) => [...prev, { id: "", name: "" }]);
+    }
+
+    function removeVoice(idx) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        const removed = next[idx];
+        next.splice(idx, 1);
+        if (removed?.id === draftCurrent) {
+          setDraftCurrent(next[0]?.id || "");
+        }
+        return next;
+      });
+    }
+
+    function updateVoice(idx, field, value) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        next[idx] = { ...next[idx], [field]: value };
+        return next;
+      });
+    }
+
+    async function saveSettings() {
+      setSaving(true);
+      setError(null);
+      try {
+        const body = {
+          voices: draftVoices.map((v) => ({ id: v.id.trim(), name: v.name.trim() })),
+          currentVoiceId: draftCurrent,
+        };
+        if (draftApiKey.trim()) body.apiKey = draftApiKey.trim();
+        const s = await fetchJSON(`${API}/voice/settings`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(body),
+        });
+        setSettings(s);
+        setEditing(false);
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function switchVoice(voiceId) {
+      if (switching || voiceId === settings.currentVoiceId) return;
+      setSwitching(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/current-voice`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ currentVoiceId: voiceId }),
+        });
+        setSettings((prev) => ({ ...prev, ...s }));
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSwitching(false);
+      }
+    }
+
+    if (loading) return h("div", { className: "yaoyao-loading" }, "加载中…");
+    if (!settings) return h("div", { className: "yaoyao-error" }, "加载失败: " + error);
+
+    const currentVoiceName = voiceOptions.find((o) => o.value === settings.currentVoiceId)?.label
+      || settings.currentVoiceId
+      || "未选择";
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "App 双流直连配置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+
+      // Status card
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-duplex-status" },
+            h("div", { className: "yaoyao-duplex-icon" }, "D"),
+            h("div", { className: "yaoyao-duplex-info" },
+              h("div", { className: "yaoyao-duplex-title-row" },
+                h("h5", null, "火山引擎 / 豆包 TTS"),
+                h(Badge, {
+                  variant: isConfigured ? "default" : "secondary",
+                }, isConfigured ? "已配置" : "未配置")
+              ),
+              h("p", { className: "yaoyao-hint" },
+                "iOS App 直连 TTS 引擎进行双流语音对话。API Key 和声音列表在此配置后,App 从 /voice/runtime 拉取。")
+            )
+          ),
+          h("div", { className: "yaoyao-duplex-meta" },
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "当前声音"), h("span", null, currentVoiceName)),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "API Key"), h("span", null, settings.hasApiKey ? "已存储" : "未设置")),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "声音数量"), h("span", null, String(settings.voices.length))),
+          )
+        )
+      ),
+
+      // Current voice quick switch
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-current" }, "当前声音"),
+            h("div", { className: "yaoyao-voice-switch" },
+              h(Select, {
+                id: "duplex-current",
+                value: settings.currentVoiceId,
+                onChange: (e) => switchVoice(e.target.value),
+                disabled: switching || voiceOptions.length === 0,
+              },
+                voiceOptions.map((o) =>
+                  h(SelectOption, { key: o.value, value: o.value }, o.label)
+                )
+              ),
+              h(Button, {
+                variant: "outline",
+                size: "sm",
+                onClick: openEditor,
+              }, "编辑配置")
+            )
+          )
+        )
+      ),
+
+      error ? h("div", { className: "yaoyao-error" }, error) : null,
+
+      // Editor (inline form)
+      editing ? h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("h3", { className: "yaoyao-section-title" }, "编辑双流配置"),
+
+          // API Key
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-key" }, "API Key"),
+            h(Input, {
+              id: "duplex-key",
+              type: "password",
+              value: draftApiKey,
+              placeholder: settings.hasApiKey ? "已存储 (留空保持不变)" : "输入 API Key",
+              onChange: (e) => setDraftApiKey(e.target.value),
+            })
+          ),
+
+          // Current voice in draft
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-draft-current" }, "当前声音"),
+            h(Select, {
+              id: "duplex-draft-current",
+              value: draftCurrent,
+              onChange: (e) => setDraftCurrent(e.target.value),
+            },
+              draftVoices
+                .filter((v) => v.id.trim())
+                .map((v) =>
+                  h(SelectOption, { key: v.id, value: v.id }, v.name || v.id)
+                )
+            )
+          ),
+
+          // Voice list editor
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, null, "声音列表"),
+            h("div", { className: "yaoyao-voice-editor" },
+              draftVoices.map((voice, idx) =>
+                h("div", { key: idx, className: "yaoyao-voice-editor-row" },
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音名称 (如 小何 2.0)",
+                    value: voice.name,
+                    onChange: (e) => updateVoice(idx, "name", e.target.value),
+                  }),
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音 ID (如 zh_female_xiaohe_uranus_bigtts)",
+                    value: voice.id,
+                    onChange: (e) => updateVoice(idx, "id", e.target.value),
+                  }),
+                  h(Button, {
+                    variant: "ghost",
+                    size: "sm",
+                    disabled: draftVoices.length <= 1,
+                    onClick: () => removeVoice(idx),
+                  }, "×")
+                )
+              ),
+              h(Button, { variant: "outline", size: "sm", onClick: addVoice }, "+ 添加声音")
+            )
+          ),
+
+          h("div", { className: "yaoyao-voice-actions" },
+            h(Button, { variant: "ghost", onClick: () => setEditing(false) }, "取消"),
+            h(Button, { onClick: saveSettings, disabled: saving }, saving ? "保存中…" : "保存")
+          )
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // TTS / STT provider settings page
+  // Mirrors yaoyao-webui's VoiceSettings.vue + VoiceApiConfigurator.vue.
+  // ---------------------------------------------------------------------
+
+  function VoiceProvidersPage() {
+    const [ttsData, setTtsData] = useState(null);
+    const [sttData, setSttData] = useState(null);
+    const [providersInfo, setProvidersInfo] = useState(null);
+    const [error, setError] = useState(null);
+    const [saving, setSaving] = useState(false);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      Promise.all([
+        fetchJSON(`${API}/tts/settings`),
+        fetchJSON(`${API}/stt/settings`),
+        fetchJSON(`${API}/voice/providers-info`),
+      ]).then(([t, s, p]) => {
+        setTtsData(t);
+        setSttData(s);
+        setProvidersInfo(p);
+      }).catch((e) => setError(String(e.message || e)));
+    }, []);
+
+    async function setTtsActive(provider) {
+      try {
+        await fetchJSON(`${API}/tts/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setTtsData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function setSttActive(provider) {
+      try {
+        await fetchJSON(`${API}/stt/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setSttData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function saveProvider(kind, provider, settings, secrets) {
+      setSaving(true);
+      setError(null);
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        const res = await fetchJSON(endpoint, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({settings, secrets}),
+        });
+        // Refresh
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+        setSavedMsg(`${provider} 已保存`);
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function deleteProvider(kind, provider) {
+      if (!confirm(`确定删除 ${provider} 配置?`)) return;
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        await fetchJSON(endpoint, {method: "DELETE"});
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    if (!ttsData || !sttData || !providersInfo) {
+      return h("div", {className: "yaoyao-loading"}, "加载中…");
+    }
+
+    const ttsProviders = providersInfo.tts.providers;
+    const sttProviders = providersInfo.stt.providers;
+
+    function renderProviderCard(kind, info, data) {
+      const settings = (data.settings || []).find(s => s.provider === info.id);
+      const isActive = data.activeProvider === info.id;
+      const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+      const isConfigured = info.id === "edge" || info.id === "browser" || hasKey;
+
+      return h(Card, {key: info.id, className: cn("yaoyao-provider-card", isActive && "yaoyao-provider-active")},
+        h(CardContent, {className: "yaoyao-voice-card"},
+          h("div", {className: "yaoyao-provider-header"},
+            h("div", {className: "yaoyao-provider-title-row"},
+              h("h5", null, info.label),
+              h(Badge, {variant: isActive ? "default" : "secondary"},
+                isActive ? "当前" : (isConfigured ? "已配置" : "未配置"))
+            ),
+            h("p", {className: "yaoyao-hint"},
+              info.needsKey ? "需要 API Key" : "免费 / 内置")
+          ),
+          h(ProviderEditor, {
+            kind, info, settings: settings || null, saving,
+            onSave: (s, sec) => saveProvider(kind, info.id, s, sec),
+            onDelete: info.id !== "edge" && info.id !== "browser"
+              ? () => deleteProvider(kind, info.id) : null,
+            onActivate: () => kind === "tts" ? setTtsActive(info.id) : setSttActive(info.id),
+          })
+        )
+      );
+    }
+
+    return h("div", {className: "yaoyao-page"},
+      h("div", {className: "yaoyao-header"},
+        h("h2", null, "语音引擎配置"),
+        savedMsg ? h(Badge, {variant: "secondary"}, savedMsg) : null
+      ),
+      error ? h("div", {className: "yaoyao-error"}, error) : null,
+
+      h("h3", {className: "yaoyao-section-title"}, "TTS (文字转语音)"),
+      h("div", {className: "yaoyao-provider-list"},
+        ttsProviders.map(info => renderProviderCard("tts", info, ttsData))
+      ),
+
+      h("h3", {className: "yaoyao-section-title"}, "STT (语音转文字)"),
+      h("div", {className: "yaoyao-provider-list"},
+        sttProviders.map(info => renderProviderCard("stt", info, sttData))
+      )
+    );
+  }
+
+  // Inline provider editor (expandable)
+  function ProviderEditor({kind, info, settings, saving, onSave, onDelete, onActivate}) {
+    const [expanded, setExpanded] = useState(false);
+    const [draft, setDraft] = useState({});
+
+    useEffect(() => {
+      if (settings) {
+        setDraft({
+          baseUrl: settings.settings?.baseUrl || info.defaults?.baseUrl || "",
+          model: settings.settings?.model || info.defaults?.model || "",
+          voice: settings.settings?.voice || info.defaults?.voice || "",
+          language: settings.settings?.language || info.defaults?.language || "",
+          rate: settings.settings?.rate || "",
+          pitch: settings.settings?.pitch || "",
+          stylePrompt: settings.settings?.stylePrompt || "",
+          apiKey: "",
+        });
+      } else {
+        setDraft({
+          baseUrl: info.defaults?.baseUrl || "",
+          model: info.defaults?.model || "",
+          voice: info.defaults?.voice || "",
+          language: info.defaults?.language || "",
+          apiKey: "",
+        });
+      }
+    }, [info.id, settings]);
+
+    function update(field, value) {
+      setDraft(d => ({...d, [field]: value}));
+    }
+
+    function handleSave() {
+      const s = {};
+      const sec = {};
+      if (draft.baseUrl) s.baseUrl = draft.baseUrl;
+      if (draft.model) s.model = draft.model;
+      if (draft.voice) s.voice = draft.voice;
+      if (draft.language) s.language = draft.language;
+      if (draft.rate) s.rate = draft.rate;
+      if (draft.pitch) s.pitch = draft.pitch;
+      if (draft.stylePrompt) s.stylePrompt = draft.stylePrompt;
+      if (draft.apiKey) sec.apiKey = draft.apiKey;
+      onSave(s, sec);
+    }
+
+    const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+    const showFields = info.settingsKeys || [];
+
+    return h("div", {className: "yaoyao-provider-editor"},
+      h("div", {className: "yaoyao-provider-actions"},
+        h(Button, {variant: "outline", size: "sm", onClick: () => setExpanded(e => !e)},
+          expanded ? "收起" : "展开"),
+        onActivate ? h(Button, {variant: "ghost", size: "sm", onClick: onActivate}, "设为当前") : null,
+      ),
+      expanded ? h("div", {className: "yaoyao-provider-fields"},
+        showFields.includes("baseUrl") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "Base URL"),
+          h(Input, {type: "text", value: draft.baseUrl, onChange: e => update("baseUrl", e.target.value),
+            placeholder: "https://..."})
+        ) : null,
+        showFields.includes("model") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "模型"),
+          h(Input, {type: "text", value: draft.model, onChange: e => update("model", e.target.value),
+            placeholder: "model id"})
+        ) : null,
+        showFields.includes("voice") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "声音"),
+          h(Input, {type: "text", value: draft.voice, onChange: e => update("voice", e.target.value),
+            placeholder: "voice id"})
+        ) : null,
+        showFields.includes("language") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语言"),
+          h(Input, {type: "text", value: draft.language, onChange: e => update("language", e.target.value),
+            placeholder: "zh / en / ..."})
+        ) : null,
+        showFields.includes("rate") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语速"),
+          h(Input, {type: "text", value: draft.rate, onChange: e => update("rate", e.target.value),
+            placeholder: "+0%"})
+        ) : null,
+        showFields.includes("pitch") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "音调"),
+          h(Input, {type: "text", value: draft.pitch, onChange: e => update("pitch", e.target.value),
+            placeholder: "+0Hz"})
+        ) : null,
+        showFields.includes("stylePrompt") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "风格提示"),
+          h(Input, {type: "text", value: draft.stylePrompt, onChange: e => update("stylePrompt", e.target.value),
+            placeholder: "style prompt"})
+        ) : null,
+        info.needsKey ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "API Key"),
+          h(Input, {type: "password", value: draft.apiKey,
+            onChange: e => update("apiKey", e.target.value),
+            placeholder: hasKey ? "已存储 (留空保持不变)" : "输入 API Key"})
+        ) : null,
+        h("div", {className: "yaoyao-voice-actions"},
+          h(Button, {size: "sm", onClick: handleSave, disabled: saving},
+            saving ? "保存中…" : "保存"),
+          onDelete ? h(Button, {variant: "ghost", size: "sm", onClick: onDelete}, "删除") : null,
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Top-level page with internal tab switcher (Files / Duplex / Engines)
+  // ---------------------------------------------------------------------
+
+  function YaoyaoPage() {
+    const [tab, setTab] = useState("files");
+    return h("div", { className: "yaoyao-root" },
+      h("div", { className: "yaoyao-tabs" },
+        h("button", {
+          className: cn("yaoyao-tab", tab === "files" && "yaoyao-tab-active"),
+          onClick: () => setTab("files"),
+        }, "文件库"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "duplex" && "yaoyao-tab-active"),
+          onClick: () => setTab("duplex"),
+        }, "App双流直连"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "engines" && "yaoyao-tab-active"),
+          onClick: () => setTab("engines"),
+        }, "语音引擎")
+      ),
+      tab === "files" ? h(YaoyaoFilesPage)
+      : tab === "duplex" ? h(VoiceSettingsPage)
+      : h(VoiceProvidersPage)
+    );
+  }
+
+  // Register the plugin page. The name MUST match manifest.json "name".
+  if (window.__HERMES_PLUGINS__ &&
+      typeof window.__HERMES_PLUGINS__.register === "function") {
+    window.__HERMES_PLUGINS__.register("yaoyao", YaoyaoPage);
+  }
+})();

+ 934 - 0
hermes-plugins/yaoyao/dashboard/dist/index.v1.4.0.js

@@ -0,0 +1,934 @@
+/**
+ * Yaoyao File Library - dashboard plugin frontend.
+ *
+ * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React +
+ * shadcn-ui primitives + fetchJSON / authedFetch. Backend routes live under
+ * /api/plugins/yaoyao/ (auto-mounted by the dashboard plugin loader).
+ */
+(function () {
+  "use strict";
+
+  const SDK = window.__HERMES_PLUGIN_SDK__;
+  if (!SDK) return;
+  const { React } = SDK;
+  const h = React.createElement;
+  const { useState, useEffect, useCallback, useRef, useMemo } = SDK.hooks;
+  const {
+    Card,
+    CardContent,
+    Badge,
+    Button,
+    Input,
+    Label,
+    Select,
+    SelectOption,
+  } = SDK.components;
+  const { cn, timeAgo } = SDK.utils;
+  const fetchJSON = SDK.fetchJSON;
+  const authedFetch = SDK.authedFetch;
+
+  const API = "/api/plugins/yaoyao";
+
+  // ---------------------------------------------------------------------
+  // Helpers
+  // ---------------------------------------------------------------------
+
+  function inferKind(name, mimeType) {
+    const ext = (name.split(".").pop() || "").toLowerCase();
+    if ((mimeType || "").startsWith("image/") ||
+        ["png","jpg","jpeg","gif","webp","bmp","svg","tiff","tif"].includes(ext))
+      return "image";
+    if ((mimeType || "").startsWith("video/") ||
+        ["mp4","mov","avi","mkv","webm","m4v","wmv","flv"].includes(ext))
+      return "video";
+    if ((mimeType || "").startsWith("text/") ||
+        ["txt","md","json","yaml","yml","csv","log","py","js","ts","html","xml","rst"].includes(ext))
+      return "text";
+    return "file";
+  }
+
+  function formatBytes(n) {
+    if (!n || n <= 0) return "0 B";
+    const units = ["B", "KB", "MB", "GB"];
+    let i = 0, v = n;
+    while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+    return v >= 100 ? v.toFixed(0) + " " + units[i]
+         : v >= 10  ? v.toFixed(1) + " " + units[i]
+                    : v.toFixed(2) + " " + units[i];
+  }
+
+  function senderLabel(s) {
+    return s === "user" ? "用户" : s === "agent" ? "Agent" : s;
+  }
+
+  function kindIcon(kind) {
+    switch (kind) {
+      case "image": return "🖼";
+      case "video": return "🎬";
+      case "text": return "📄";
+      default: return "📦";
+    }
+  }
+
+  // ---------------------------------------------------------------------
+  // Thumbnail image: fetch as blob via authedFetch (img tags can't set
+  // Authorization headers), then create an object URL.
+  // ---------------------------------------------------------------------
+
+  function Thumbnail({ item, profile }) {
+    const [url, setUrl] = useState(null);
+    const [err, setErr] = useState(false);
+    const kind = inferKind(item.name, item.mimeType);
+
+    useEffect(() => {
+      if (kind !== "image") return;
+      let revoke = null;
+      let cancelled = false;
+      const dlUrl = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+      authedFetch(dlUrl)
+        .then((res) => res.blob())
+        .then((blob) => {
+          if (cancelled) return;
+          const u = URL.createObjectURL(blob);
+          revoke = u;
+          setUrl(u);
+        })
+        .catch(() => { if (!cancelled) setErr(true); });
+      return () => {
+        cancelled = true;
+        if (revoke) URL.revokeObjectURL(revoke);
+      };
+    }, [item.id, profile]);
+
+    if (kind !== "image") {
+      return h("div", {
+        className: "yaoyao-thumb yaoyao-thumb-icon",
+      }, kindIcon(kind));
+    }
+    if (err) return h("div", { className: "yaoyao-thumb yaoyao-thumb-icon" }, "🖼");
+    if (!url) return h("div", { className: "yaoyao-thumb yaoyao-thumb-loading" }, "…");
+    return h("img", {
+      src: url,
+      alt: item.name,
+      className: "yaoyao-thumb yaoyao-thumb-img",
+      loading: "lazy",
+    });
+  }
+
+  // ---------------------------------------------------------------------
+  // File card
+  // ---------------------------------------------------------------------
+
+  function FileCard({ item, onDownload, profile }) {
+    const kind = inferKind(item.name, item.mimeType);
+    return h(Card, { className: "yaoyao-card" },
+      h(CardContent, { className: "yaoyao-card-content" },
+        h("div", { className: "yaoyao-card-preview" },
+          h(Thumbnail, { item: item, profile: profile })
+        ),
+        h("div", { className: "yaoyao-card-info" },
+          h("div", { className: "yaoyao-card-name", title: item.name }, item.name),
+          h("div", { className: "yaoyao-card-meta" },
+            h(Badge, { variant: "secondary" }, senderLabel(item.origins && item.origins[0] ? item.origins[0].authorKind : "agent")),
+            h("span", { className: "yaoyao-card-size" }, formatBytes(item.size)),
+            item.messageTimestamp
+              ? h("span", { className: "yaoyao-card-time" }, timeAgo(item.messageTimestamp * 1000))
+              : null
+          ),
+          h("div", { className: "yaoyao-card-actions" },
+            h(Button, {
+              variant: "outline",
+              size: "sm",
+              onClick: () => onDownload(item),
+            }, "下载")
+          )
+        )
+      )
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Main page
+  // ---------------------------------------------------------------------
+
+  function YaoyaoFilesPage() {
+    const [items, setItems] = useState([]);
+    const [loading, setLoading] = useState(true);
+    const [loadingMore, setLoadingMore] = useState(false);
+    const [error, setError] = useState(null);
+    const [cursor, setCursor] = useState(null);
+    const [hasMore, setHasMore] = useState(false);
+    const [stats, setStats] = useState(null);
+    const [profiles, setProfiles] = useState([]);
+    // Initial profile from the URL (?profile=gril) so deep links / shared
+    // links land on the right agent's library; fall back to "default".
+    const [profile, setProfile] = useState(() => {
+      try {
+        const u = new URL(window.location.href);
+        const p = u.searchParams.get("profile");
+        return p || "default";
+      } catch (e) {
+        return "default";
+      }
+    });
+    const [filter, setFilter] = useState({ sender: "", kind: "" });
+    const [search, setSearch] = useState("");
+    const [searchInput, setSearchInput] = useState("");
+    const sentinelRef = useRef(null);
+
+    // Keep the URL ?profile= in sync with the dropdown selection so the
+    // address bar reflects what's shown (and is shareable / refreshable).
+    useEffect(() => {
+      try {
+        const u = new URL(window.location.href);
+        if (profile && profile !== "default") {
+          u.searchParams.set("profile", profile);
+        } else {
+          u.searchParams.delete("profile");
+        }
+        window.history.replaceState(null, "", u.toString());
+      } catch (e) { /* ignore */ }
+    }, [profile]);
+
+    // Also react to back/forward navigation so the dropdown follows the URL.
+    useEffect(() => {
+      const onPop = () => {
+        try {
+          const u = new URL(window.location.href);
+          const p = u.searchParams.get("profile") || "default";
+          setProfile(p);
+        } catch (e) { /* ignore */ }
+      };
+      window.addEventListener("popstate", onPop);
+      return () => window.removeEventListener("popstate", onPop);
+    }, []);
+
+    // Profiles dropdown: which agents have libraries. default always present.
+    useEffect(() => {
+      fetchJSON(`${API}/profiles`)
+        .then((res) => {
+          const list = (res.profiles || []);
+          setProfiles(list);
+          // If the current selection vanished (profile deleted), fall back.
+          if (list.length && !list.some((p) => p.name === profile)) {
+            setProfile("default");
+          }
+        })
+        .catch(() => {});
+    }, []); // eslint-disable-line
+
+    // Fetch first page whenever filter / search / profile changes.
+    const loadFirst = useCallback(async () => {
+      setLoading(true);
+      setError(null);
+      try {
+        const params = { limit: 50 };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems(res.items || []);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }, [filter, search, profile]);
+
+    useEffect(() => { loadFirst(); }, [loadFirst]);
+
+    // Stats (re-fetch when profile changes)
+    useEffect(() => {
+      const params = {};
+      if (profile) params.profile = profile;
+      const qs = new URLSearchParams(params).toString();
+      fetchJSON(`${API}/stats?${qs}`).then(setStats).catch(() => {});
+    }, [profile]);
+
+    // Load more
+    const loadMore = useCallback(async () => {
+      if (!cursor || loadingMore) return;
+      setLoadingMore(true);
+      try {
+        const params = { limit: 50, cursor };
+        if (profile) params.profile = profile;
+        if (filter.sender) params.sender = filter.sender;
+        if (filter.kind) params.kind = filter.kind;
+        if (search) params.search = search;
+        const qs = new URLSearchParams(params).toString();
+        const res = await fetchJSON(`${API}/files?${qs}`);
+        setItems((prev) => [...prev, ...(res.items || [])]);
+        setCursor(res.nextCursor || null);
+        setHasMore(!!res.nextCursor);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoadingMore(false);
+      }
+    }, [cursor, filter, search, profile, loadingMore]);
+
+    // Infinite scroll via IntersectionObserver
+    useEffect(() => {
+      const el = sentinelRef.current;
+      if (!el || !hasMore) return;
+      const obs = new IntersectionObserver((entries) => {
+        if (entries[0].isIntersecting) loadMore();
+      }, { rootMargin: "200px" });
+      obs.observe(el);
+      return () => obs.disconnect();
+    }, [hasMore, loadMore]);
+
+    const onDownload = useCallback(async (item) => {
+      try {
+        const url = `${API}/${item.id}/download` + (profile ? `?profile=${encodeURIComponent(profile)}` : "");
+        const res = await authedFetch(url);
+        const blob = await res.blob();
+        const urlObj = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = urlObj;
+        a.download = item.name;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        setTimeout(() => URL.revokeObjectURL(urlObj), 10000);
+      } catch (e) {
+        alert("下载失败: " + (e.message || e));
+      }
+    }, [profile]);
+
+    const onSearchSubmit = useCallback((e) => {
+      e.preventDefault();
+      setSearch(searchInput.trim());
+    }, [searchInput]);
+
+    return h("div", { className: "yaoyao-page" },
+      // Header + stats
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "夭夭文件库"),
+        stats ? h("div", { className: "yaoyao-stats" },
+          profile && profile !== "default"
+            ? h(Badge, { variant: "secondary" }, `Agent: ${profile}`)
+            : null,
+          h(Badge, { variant: "secondary" }, `共 ${stats.totalAttachments} 个文件`),
+          h(Badge, { variant: "secondary" }, `${formatBytes(stats.totalBytes)}`),
+          stats.bySender && stats.bySender.agent
+            ? h(Badge, { variant: "outline" }, `Agent ${stats.bySender.agent}`)
+            : null,
+          stats.bySender && stats.bySender.user
+            ? h(Badge, { variant: "outline" }, `用户 ${stats.bySender.user}`)
+            : null,
+        ) : null
+      ),
+
+      // Filters
+      h("div", { className: "yaoyao-filters" },
+        h(Label, null, "Agent"),
+        h(Select, {
+          value: profile,
+          onChange: (e) => setProfile(e.target.value),
+          disabled: profiles.length === 0,
+        },
+          profiles.length === 0
+            ? h(SelectOption, { value: "default" }, "默认")
+            : profiles.map((p) =>
+                h(SelectOption, { key: p.name, value: p.name }, p.label)
+              )
+        ),
+        h(Label, null, "来源"),
+        h(Select, {
+          value: filter.sender,
+          onChange: (e) => setFilter((f) => ({ ...f, sender: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "agent" }, "Agent"),
+          h(SelectOption, { value: "user" }, "用户")
+        ),
+        h(Label, null, "类型"),
+        h(Select, {
+          value: filter.kind,
+          onChange: (e) => setFilter((f) => ({ ...f, kind: e.target.value })),
+        },
+          h(SelectOption, { value: "" }, "全部"),
+          h(SelectOption, { value: "image" }, "图片"),
+          h(SelectOption, { value: "video" }, "视频"),
+          h(SelectOption, { value: "text" }, "文本"),
+          h(SelectOption, { value: "file" }, "文档")
+        ),
+        h("form", { onSubmit: onSearchSubmit, className: "yaoyao-search" },
+          h(Input, {
+            type: "text",
+            placeholder: "搜索文件名…",
+            value: searchInput,
+            onChange: (e) => setSearchInput(e.target.value),
+          }),
+          h(Button, { type: "submit", size: "sm" }, "搜索")
+        )
+      ),
+
+      // Error
+      error ? h("div", { className: "yaoyao-error" }, "错误: " + error) : null,
+
+      // Loading
+      loading ? h("div", { className: "yaoyao-loading" }, "加载中…") :
+      // Grid
+      items.length === 0
+        ? h("div", { className: "yaoyao-empty" }, "暂无文件")
+        : h("div", { className: "yaoyao-grid" },
+            items.map((item) =>
+              h(FileCard, { key: item.id, item: item, onDownload: onDownload, profile: profile })
+            )
+          ),
+
+      // Infinite scroll sentinel
+      hasMore ? h("div", {
+        ref: sentinelRef,
+        className: "yaoyao-sentinel"
+      }, loadingMore ? "加载更多…" : "") : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Voice settings page
+  // ---------------------------------------------------------------------
+
+  // ---------------------------------------------------------------------
+  // Duplex voice settings page
+  // Mirrors yaoyao-webui's IosDuplexVoiceSettings.vue contract:
+  // {hasApiKey, voices:[{id,name}], currentVoiceId, updatedAt}
+  // ---------------------------------------------------------------------
+
+  function VoiceSettingsPage() {
+    const [settings, setSettings] = useState(null);
+    const [loading, setLoading] = useState(true);
+    const [saving, setSaving] = useState(false);
+    const [switching, setSwitching] = useState(false);
+    const [error, setError] = useState(null);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    // Draft state for the editor
+    const [draftApiKey, setDraftApiKey] = useState("");
+    const [draftVoices, setDraftVoices] = useState([]);
+    const [draftCurrent, setDraftCurrent] = useState("");
+    const [editing, setEditing] = useState(false);
+
+    useEffect(() => {
+      load();
+    }, []);
+
+    async function load() {
+      setLoading(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/settings`);
+        setSettings(s);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setLoading(false);
+      }
+    }
+
+    const voiceOptions = useMemo(() => {
+      if (!settings) return [];
+      return settings.voices
+        .filter((v) => v.id.trim() && v.name.trim())
+        .map((v) => ({ label: v.name, value: v.id }));
+    }, [settings]);
+
+    const isConfigured = settings
+      && settings.hasApiKey
+      && voiceOptions.length > 0
+      && voiceOptions.some((o) => o.value === settings.currentVoiceId);
+
+    function openEditor() {
+      setDraftApiKey("");
+      setDraftVoices((settings?.voices || []).map((v) => ({ ...v })));
+      setDraftCurrent(settings?.currentVoiceId || "");
+      setEditing(true);
+    }
+
+    function addVoice() {
+      setDraftVoices((prev) => [...prev, { id: "", name: "" }]);
+    }
+
+    function removeVoice(idx) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        const removed = next[idx];
+        next.splice(idx, 1);
+        if (removed?.id === draftCurrent) {
+          setDraftCurrent(next[0]?.id || "");
+        }
+        return next;
+      });
+    }
+
+    function updateVoice(idx, field, value) {
+      setDraftVoices((prev) => {
+        const next = [...prev];
+        next[idx] = { ...next[idx], [field]: value };
+        return next;
+      });
+    }
+
+    async function saveSettings() {
+      setSaving(true);
+      setError(null);
+      try {
+        const body = {
+          voices: draftVoices.map((v) => ({ id: v.id.trim(), name: v.name.trim() })),
+          currentVoiceId: draftCurrent,
+        };
+        if (draftApiKey.trim()) body.apiKey = draftApiKey.trim();
+        const s = await fetchJSON(`${API}/voice/settings`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify(body),
+        });
+        setSettings(s);
+        setEditing(false);
+        setSavedMsg("已保存");
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function switchVoice(voiceId) {
+      if (switching || voiceId === settings.currentVoiceId) return;
+      setSwitching(true);
+      try {
+        const s = await fetchJSON(`${API}/voice/current-voice`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ currentVoiceId: voiceId }),
+        });
+        setSettings((prev) => ({ ...prev, ...s }));
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSwitching(false);
+      }
+    }
+
+    if (loading) return h("div", { className: "yaoyao-loading" }, "加载中…");
+    if (!settings) return h("div", { className: "yaoyao-error" }, "加载失败: " + error);
+
+    const currentVoiceName = voiceOptions.find((o) => o.value === settings.currentVoiceId)?.label
+      || settings.currentVoiceId
+      || "未选择";
+
+    return h("div", { className: "yaoyao-page" },
+      h("div", { className: "yaoyao-header" },
+        h("h2", null, "App 双流直连配置"),
+        savedMsg ? h(Badge, { variant: "secondary" }, savedMsg) : null
+      ),
+
+      // Status card
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-duplex-status" },
+            h("div", { className: "yaoyao-duplex-icon" }, "D"),
+            h("div", { className: "yaoyao-duplex-info" },
+              h("div", { className: "yaoyao-duplex-title-row" },
+                h("h5", null, "火山引擎 / 豆包 TTS"),
+                h(Badge, {
+                  variant: isConfigured ? "default" : "secondary",
+                }, isConfigured ? "已配置" : "未配置")
+              ),
+              h("p", { className: "yaoyao-hint" },
+                "iOS App 直连 TTS 引擎进行双流语音对话。API Key 和声音列表在此配置后,App 从 /voice/runtime 拉取。")
+            )
+          ),
+          h("div", { className: "yaoyao-duplex-meta" },
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "当前声音"), h("span", null, currentVoiceName)),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "API Key"), h("span", null, settings.hasApiKey ? "已存储" : "未设置")),
+            h("div", null, h("span", { className: "yaoyao-meta-label" }, "声音数量"), h("span", null, String(settings.voices.length))),
+          )
+        )
+      ),
+
+      // Current voice quick switch
+      h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-current" }, "当前声音"),
+            h("div", { className: "yaoyao-voice-switch" },
+              h(Select, {
+                id: "duplex-current",
+                value: settings.currentVoiceId,
+                onChange: (e) => switchVoice(e.target.value),
+                disabled: switching || voiceOptions.length === 0,
+              },
+                voiceOptions.map((o) =>
+                  h(SelectOption, { key: o.value, value: o.value }, o.label)
+                )
+              ),
+              h(Button, {
+                variant: "outline",
+                size: "sm",
+                onClick: openEditor,
+              }, "编辑配置")
+            )
+          )
+        )
+      ),
+
+      error ? h("div", { className: "yaoyao-error" }, error) : null,
+
+      // Editor (inline form)
+      editing ? h(Card, null,
+        h(CardContent, { className: "yaoyao-voice-card" },
+          h("h3", { className: "yaoyao-section-title" }, "编辑双流配置"),
+
+          // API Key
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-key" }, "API Key"),
+            h(Input, {
+              id: "duplex-key",
+              type: "password",
+              value: draftApiKey,
+              placeholder: settings.hasApiKey ? "已存储 (留空保持不变)" : "输入 API Key",
+              onChange: (e) => setDraftApiKey(e.target.value),
+            })
+          ),
+
+          // Current voice in draft
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, { htmlFor: "duplex-draft-current" }, "当前声音"),
+            h(Select, {
+              id: "duplex-draft-current",
+              value: draftCurrent,
+              onChange: (e) => setDraftCurrent(e.target.value),
+            },
+              draftVoices
+                .filter((v) => v.id.trim())
+                .map((v) =>
+                  h(SelectOption, { key: v.id, value: v.id }, v.name || v.id)
+                )
+            )
+          ),
+
+          // Voice list editor
+          h("div", { className: "yaoyao-voice-row" },
+            h(Label, null, "声音列表"),
+            h("div", { className: "yaoyao-voice-editor" },
+              draftVoices.map((voice, idx) =>
+                h("div", { key: idx, className: "yaoyao-voice-editor-row" },
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音名称 (如 小何 2.0)",
+                    value: voice.name,
+                    onChange: (e) => updateVoice(idx, "name", e.target.value),
+                  }),
+                  h(Input, {
+                    type: "text",
+                    placeholder: "声音 ID (如 zh_female_xiaohe_uranus_bigtts)",
+                    value: voice.id,
+                    onChange: (e) => updateVoice(idx, "id", e.target.value),
+                  }),
+                  h(Button, {
+                    variant: "ghost",
+                    size: "sm",
+                    disabled: draftVoices.length <= 1,
+                    onClick: () => removeVoice(idx),
+                  }, "×")
+                )
+              ),
+              h(Button, { variant: "outline", size: "sm", onClick: addVoice }, "+ 添加声音")
+            )
+          ),
+
+          h("div", { className: "yaoyao-voice-actions" },
+            h(Button, { variant: "ghost", onClick: () => setEditing(false) }, "取消"),
+            h(Button, { onClick: saveSettings, disabled: saving }, saving ? "保存中…" : "保存")
+          )
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // TTS / STT provider settings page
+  // Mirrors yaoyao-webui's VoiceSettings.vue + VoiceApiConfigurator.vue.
+  // ---------------------------------------------------------------------
+
+  function VoiceProvidersPage() {
+    const [ttsData, setTtsData] = useState(null);
+    const [sttData, setSttData] = useState(null);
+    const [providersInfo, setProvidersInfo] = useState(null);
+    const [error, setError] = useState(null);
+    const [saving, setSaving] = useState(false);
+    const [savedMsg, setSavedMsg] = useState(null);
+
+    useEffect(() => {
+      Promise.all([
+        fetchJSON(`${API}/tts/settings`),
+        fetchJSON(`${API}/stt/settings`),
+        fetchJSON(`${API}/voice/providers-info`),
+      ]).then(([t, s, p]) => {
+        setTtsData(t);
+        setSttData(s);
+        setProvidersInfo(p);
+      }).catch((e) => setError(String(e.message || e)));
+    }, []);
+
+    async function setTtsActive(provider) {
+      try {
+        await fetchJSON(`${API}/tts/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setTtsData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function setSttActive(provider) {
+      try {
+        await fetchJSON(`${API}/stt/settings/active`, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({provider}),
+        });
+        setSttData((d) => ({...d, activeProvider: provider}));
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    async function saveProvider(kind, provider, settings, secrets) {
+      setSaving(true);
+      setError(null);
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        const res = await fetchJSON(endpoint, {
+          method: "PUT", headers: {"Content-Type":"application/json"},
+          body: JSON.stringify({settings, secrets}),
+        });
+        // Refresh
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+        setSavedMsg(`${provider} 已保存`);
+        setTimeout(() => setSavedMsg(null), 2000);
+      } catch (e) {
+        setError(String(e.message || e));
+      } finally {
+        setSaving(false);
+      }
+    }
+
+    async function deleteProvider(kind, provider) {
+      if (!confirm(`确定删除 ${provider} 配置?`)) return;
+      try {
+        const endpoint = kind === "tts" ? `${API}/tts/settings/${provider}` : `${API}/stt/settings/${provider}`;
+        await fetchJSON(endpoint, {method: "DELETE"});
+        if (kind === "tts") {
+          const fresh = await fetchJSON(`${API}/tts/settings`);
+          setTtsData(fresh);
+        } else {
+          const fresh = await fetchJSON(`${API}/stt/settings`);
+          setSttData(fresh);
+        }
+      } catch (e) { setError(String(e.message || e)); }
+    }
+
+    if (!ttsData || !sttData || !providersInfo) {
+      return h("div", {className: "yaoyao-loading"}, "加载中…");
+    }
+
+    const ttsProviders = providersInfo.tts.providers;
+    const sttProviders = providersInfo.stt.providers;
+
+    function renderProviderCard(kind, info, data) {
+      const settings = (data.settings || []).find(s => s.provider === info.id);
+      const isActive = data.activeProvider === info.id;
+      const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+      const isConfigured = info.id === "edge" || info.id === "browser" || hasKey;
+
+      return h(Card, {key: info.id, className: cn("yaoyao-provider-card", isActive && "yaoyao-provider-active")},
+        h(CardContent, {className: "yaoyao-voice-card"},
+          h("div", {className: "yaoyao-provider-header"},
+            h("div", {className: "yaoyao-provider-title-row"},
+              h("h5", null, info.label),
+              h(Badge, {variant: isActive ? "default" : "secondary"},
+                isActive ? "当前" : (isConfigured ? "已配置" : "未配置"))
+            ),
+            h("p", {className: "yaoyao-hint"},
+              info.needsKey ? "需要 API Key" : "免费 / 内置")
+          ),
+          h(ProviderEditor, {
+            kind, info, settings: settings || null, saving,
+            onSave: (s, sec) => saveProvider(kind, info.id, s, sec),
+            onDelete: info.id !== "edge" && info.id !== "browser"
+              ? () => deleteProvider(kind, info.id) : null,
+            onActivate: () => kind === "tts" ? setTtsActive(info.id) : setSttActive(info.id),
+          })
+        )
+      );
+    }
+
+    return h("div", {className: "yaoyao-page"},
+      h("div", {className: "yaoyao-header"},
+        h("h2", null, "语音引擎配置"),
+        savedMsg ? h(Badge, {variant: "secondary"}, savedMsg) : null
+      ),
+      error ? h("div", {className: "yaoyao-error"}, error) : null,
+
+      h("h3", {className: "yaoyao-section-title"}, "TTS (文字转语音)"),
+      h("div", {className: "yaoyao-provider-list"},
+        ttsProviders.map(info => renderProviderCard("tts", info, ttsData))
+      ),
+
+      h("h3", {className: "yaoyao-section-title"}, "STT (语音转文字)"),
+      h("div", {className: "yaoyao-provider-list"},
+        sttProviders.map(info => renderProviderCard("stt", info, sttData))
+      )
+    );
+  }
+
+  // Inline provider editor (expandable)
+  function ProviderEditor({kind, info, settings, saving, onSave, onDelete, onActivate}) {
+    const [expanded, setExpanded] = useState(false);
+    const [draft, setDraft] = useState({});
+
+    useEffect(() => {
+      if (settings) {
+        setDraft({
+          baseUrl: settings.settings?.baseUrl || info.defaults?.baseUrl || "",
+          model: settings.settings?.model || info.defaults?.model || "",
+          voice: settings.settings?.voice || info.defaults?.voice || "",
+          language: settings.settings?.language || info.defaults?.language || "",
+          rate: settings.settings?.rate || "",
+          pitch: settings.settings?.pitch || "",
+          stylePrompt: settings.settings?.stylePrompt || "",
+          apiKey: "",
+        });
+      } else {
+        setDraft({
+          baseUrl: info.defaults?.baseUrl || "",
+          model: info.defaults?.model || "",
+          voice: info.defaults?.voice || "",
+          language: info.defaults?.language || "",
+          apiKey: "",
+        });
+      }
+    }, [info.id, settings]);
+
+    function update(field, value) {
+      setDraft(d => ({...d, [field]: value}));
+    }
+
+    function handleSave() {
+      const s = {};
+      const sec = {};
+      if (draft.baseUrl) s.baseUrl = draft.baseUrl;
+      if (draft.model) s.model = draft.model;
+      if (draft.voice) s.voice = draft.voice;
+      if (draft.language) s.language = draft.language;
+      if (draft.rate) s.rate = draft.rate;
+      if (draft.pitch) s.pitch = draft.pitch;
+      if (draft.stylePrompt) s.stylePrompt = draft.stylePrompt;
+      if (draft.apiKey) sec.apiKey = draft.apiKey;
+      onSave(s, sec);
+    }
+
+    const hasKey = settings && settings.secrets && settings.secrets.apiKey === "[stored]";
+    const showFields = info.settingsKeys || [];
+
+    return h("div", {className: "yaoyao-provider-editor"},
+      h("div", {className: "yaoyao-provider-actions"},
+        h(Button, {variant: "outline", size: "sm", onClick: () => setExpanded(e => !e)},
+          expanded ? "收起" : "展开"),
+        onActivate ? h(Button, {variant: "ghost", size: "sm", onClick: onActivate}, "设为当前") : null,
+      ),
+      expanded ? h("div", {className: "yaoyao-provider-fields"},
+        showFields.includes("baseUrl") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "Base URL"),
+          h(Input, {type: "text", value: draft.baseUrl, onChange: e => update("baseUrl", e.target.value),
+            placeholder: "https://..."})
+        ) : null,
+        showFields.includes("model") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "模型"),
+          h(Input, {type: "text", value: draft.model, onChange: e => update("model", e.target.value),
+            placeholder: "model id"})
+        ) : null,
+        showFields.includes("voice") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "声音"),
+          h(Input, {type: "text", value: draft.voice, onChange: e => update("voice", e.target.value),
+            placeholder: "voice id"})
+        ) : null,
+        showFields.includes("language") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语言"),
+          h(Input, {type: "text", value: draft.language, onChange: e => update("language", e.target.value),
+            placeholder: "zh / en / ..."})
+        ) : null,
+        showFields.includes("rate") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "语速"),
+          h(Input, {type: "text", value: draft.rate, onChange: e => update("rate", e.target.value),
+            placeholder: "+0%"})
+        ) : null,
+        showFields.includes("pitch") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "音调"),
+          h(Input, {type: "text", value: draft.pitch, onChange: e => update("pitch", e.target.value),
+            placeholder: "+0Hz"})
+        ) : null,
+        showFields.includes("stylePrompt") ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "风格提示"),
+          h(Input, {type: "text", value: draft.stylePrompt, onChange: e => update("stylePrompt", e.target.value),
+            placeholder: "style prompt"})
+        ) : null,
+        info.needsKey ? h("div", {className: "yaoyao-voice-row"},
+          h(Label, null, "API Key"),
+          h(Input, {type: "password", value: draft.apiKey,
+            onChange: e => update("apiKey", e.target.value),
+            placeholder: hasKey ? "已存储 (留空保持不变)" : "输入 API Key"})
+        ) : null,
+        h("div", {className: "yaoyao-voice-actions"},
+          h(Button, {size: "sm", onClick: handleSave, disabled: saving},
+            saving ? "保存中…" : "保存"),
+          onDelete ? h(Button, {variant: "ghost", size: "sm", onClick: onDelete}, "删除") : null,
+        )
+      ) : null
+    );
+  }
+
+  // ---------------------------------------------------------------------
+  // Top-level page with internal tab switcher (Files / Duplex / Engines)
+  // ---------------------------------------------------------------------
+
+  function YaoyaoPage() {
+    const [tab, setTab] = useState("files");
+    return h("div", { className: "yaoyao-root" },
+      h("div", { className: "yaoyao-tabs" },
+        h("button", {
+          className: cn("yaoyao-tab", tab === "files" && "yaoyao-tab-active"),
+          onClick: () => setTab("files"),
+        }, "文件库"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "duplex" && "yaoyao-tab-active"),
+          onClick: () => setTab("duplex"),
+        }, "App双流直连"),
+        h("button", {
+          className: cn("yaoyao-tab", tab === "engines" && "yaoyao-tab-active"),
+          onClick: () => setTab("engines"),
+        }, "语音引擎")
+      ),
+      tab === "files" ? h(YaoyaoFilesPage)
+      : tab === "duplex" ? h(VoiceSettingsPage)
+      : h(VoiceProvidersPage)
+    );
+  }
+
+  // Register the plugin page. The name MUST match manifest.json "name".
+  if (window.__HERMES_PLUGINS__ &&
+      typeof window.__HERMES_PLUGINS__.register === "function") {
+    window.__HERMES_PLUGINS__.register("yaoyao", YaoyaoPage);
+  }
+})();

+ 433 - 0
hermes-plugins/yaoyao/dashboard/dist/style.css

@@ -0,0 +1,433 @@
+/* Yaoyao file library - dashboard plugin styles */
+
+.yaoyao-root {
+  width: 100%;
+}
+
+.yaoyao-tabs {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0 1.5rem;
+  border-bottom: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.08));
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.yaoyao-tab {
+  padding: 0.6rem 1rem;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 0.9rem;
+  color: var(--muted-foreground, #888);
+  border-bottom: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.yaoyao-tab:hover {
+  color: var(--foreground, #333);
+}
+
+.yaoyao-tab-active {
+  color: var(--foreground, #333);
+  border-bottom-color: var(--primary, #6366f1);
+  font-weight: 500;
+}
+
+.yaoyao-page {
+  width: 100%;
+  padding: 1.5rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.yaoyao-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-header h2 {
+  margin: 0;
+  font-size: 1.4rem;
+  font-weight: 600;
+}
+
+.yaoyao-stats {
+  display: flex;
+  gap: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.yaoyao-filters {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.25rem;
+  padding: 0.75rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.02));
+  border-radius: 0.5rem;
+}
+
+.yaoyao-filters label {
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+  margin-bottom: 0.15rem;
+  display: block;
+}
+
+.yaoyao-filters select,
+.yaoyao-filters input {
+  min-width: 100px;
+}
+
+.yaoyao-search {
+  display: flex;
+  gap: 0.4rem;
+  flex: 1;
+  min-width: 200px;
+}
+
+.yaoyao-search input {
+  flex: 1;
+}
+
+/* Grid */
+.yaoyao-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 0.75rem;
+}
+
+/* Card */
+.yaoyao-card {
+  overflow: hidden;
+}
+
+.yaoyao-card-content {
+  padding: 0.5rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-card-preview {
+  width: 100%;
+  aspect-ratio: 16 / 10;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.yaoyao-thumb {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.yaoyao-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.yaoyao-thumb-icon {
+  font-size: 2.5rem;
+  opacity: 0.5;
+}
+
+.yaoyao-thumb-loading {
+  font-size: 1.2rem;
+  opacity: 0.4;
+}
+
+.yaoyao-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-card-name {
+  font-size: 0.8rem;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.yaoyao-card-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  flex-wrap: wrap;
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-card-size {
+  font-variant-numeric: tabular-nums;
+}
+
+.yaoyao-card-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* States */
+.yaoyao-error {
+  padding: 0.75rem;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 0.5rem;
+  color: #b91c1c;
+  font-size: 0.85rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-loading,
+.yaoyao-empty {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.95rem;
+}
+
+.yaoyao-sentinel {
+  text-align: center;
+  padding: 1.5rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.85rem;
+}
+
+/* Voice settings */
+.yaoyao-voice-card {
+  padding: 1rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-voice-row {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-voice-row label {
+  font-size: 0.8rem;
+  font-weight: 500;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-row select,
+.yaoyao-voice-row input[type="number"] {
+  max-width: 320px;
+}
+
+.yaoyao-voice-check {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-check label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--foreground, #333);
+}
+
+.yaoyao-voice-actions {
+  display: flex;
+  gap: 0.5rem;
+  margin-top: 0.25rem;
+}
+
+.yaoyao-tts-textarea {
+  width: 100%;
+  max-width: 600px;
+  padding: 0.5rem;
+  border: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.12));
+  border-radius: 0.375rem;
+  font-size: 0.85rem;
+  resize: vertical;
+  background: var(--hermes-input-bg, transparent);
+  color: var(--foreground, inherit);
+  box-sizing: border-box;
+}
+
+.yaoyao-audio-player {
+  width: 100%;
+  max-width: 600px;
+  margin-top: 0.5rem;
+}
+
+.yaoyao-section-title {
+  margin: 0 0 0.25rem 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.yaoyao-hint {
+  margin: 0 0 0.5rem 0;
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-agent-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-agent-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.3rem 0.5rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.03));
+  border-radius: 0.3rem;
+  font-size: 0.85rem;
+}
+
+.yaoyao-agent-add {
+  display: flex;
+  gap: 0.4rem;
+}
+
+/* Duplex voice status card */
+.yaoyao-duplex-status {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-duplex-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border: 1px solid var(--hermes-border, rgba(0,0,0,0.12));
+  border-radius: 0.3rem;
+  background: var(--hermes-card, rgba(0,0,0,0.03));
+  font-size: 0.8rem;
+  font-weight: 700;
+}
+
+.yaoyao-duplex-info {
+  min-width: 0;
+}
+
+.yaoyao-duplex-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.25rem;
+}
+
+.yaoyao-duplex-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-duplex-meta {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 1rem;
+  font-size: 0.8rem;
+}
+
+.yaoyao-duplex-meta > div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.yaoyao-meta-label {
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-switch {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor-row {
+  display: grid;
+  grid-template-columns: 0.85fr 1.15fr auto;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+/* Provider cards */
+.yaoyao-provider-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+
+.yaoyao-provider-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.yaoyao-provider-card.yaoyao-provider-active::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: var(--primary, #6366f1);
+}
+
+.yaoyao-provider-header {
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-provider-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-provider-actions {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}

+ 433 - 0
hermes-plugins/yaoyao/dashboard/dist/style.v1.1.0.css

@@ -0,0 +1,433 @@
+/* Yaoyao file library - dashboard plugin styles */
+
+.yaoyao-root {
+  width: 100%;
+}
+
+.yaoyao-tabs {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0 1.5rem;
+  border-bottom: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.08));
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.yaoyao-tab {
+  padding: 0.6rem 1rem;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 0.9rem;
+  color: var(--muted-foreground, #888);
+  border-bottom: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.yaoyao-tab:hover {
+  color: var(--foreground, #333);
+}
+
+.yaoyao-tab-active {
+  color: var(--foreground, #333);
+  border-bottom-color: var(--primary, #6366f1);
+  font-weight: 500;
+}
+
+.yaoyao-page {
+  width: 100%;
+  padding: 1.5rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.yaoyao-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-header h2 {
+  margin: 0;
+  font-size: 1.4rem;
+  font-weight: 600;
+}
+
+.yaoyao-stats {
+  display: flex;
+  gap: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.yaoyao-filters {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.25rem;
+  padding: 0.75rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.02));
+  border-radius: 0.5rem;
+}
+
+.yaoyao-filters label {
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+  margin-bottom: 0.15rem;
+  display: block;
+}
+
+.yaoyao-filters select,
+.yaoyao-filters input {
+  min-width: 100px;
+}
+
+.yaoyao-search {
+  display: flex;
+  gap: 0.4rem;
+  flex: 1;
+  min-width: 200px;
+}
+
+.yaoyao-search input {
+  flex: 1;
+}
+
+/* Grid */
+.yaoyao-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 0.75rem;
+}
+
+/* Card */
+.yaoyao-card {
+  overflow: hidden;
+}
+
+.yaoyao-card-content {
+  padding: 0.5rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-card-preview {
+  width: 100%;
+  aspect-ratio: 16 / 10;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.yaoyao-thumb {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.yaoyao-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.yaoyao-thumb-icon {
+  font-size: 2.5rem;
+  opacity: 0.5;
+}
+
+.yaoyao-thumb-loading {
+  font-size: 1.2rem;
+  opacity: 0.4;
+}
+
+.yaoyao-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-card-name {
+  font-size: 0.8rem;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.yaoyao-card-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  flex-wrap: wrap;
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-card-size {
+  font-variant-numeric: tabular-nums;
+}
+
+.yaoyao-card-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* States */
+.yaoyao-error {
+  padding: 0.75rem;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 0.5rem;
+  color: #b91c1c;
+  font-size: 0.85rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-loading,
+.yaoyao-empty {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.95rem;
+}
+
+.yaoyao-sentinel {
+  text-align: center;
+  padding: 1.5rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.85rem;
+}
+
+/* Voice settings */
+.yaoyao-voice-card {
+  padding: 1rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-voice-row {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-voice-row label {
+  font-size: 0.8rem;
+  font-weight: 500;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-row select,
+.yaoyao-voice-row input[type="number"] {
+  max-width: 320px;
+}
+
+.yaoyao-voice-check {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-check label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--foreground, #333);
+}
+
+.yaoyao-voice-actions {
+  display: flex;
+  gap: 0.5rem;
+  margin-top: 0.25rem;
+}
+
+.yaoyao-tts-textarea {
+  width: 100%;
+  max-width: 600px;
+  padding: 0.5rem;
+  border: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.12));
+  border-radius: 0.375rem;
+  font-size: 0.85rem;
+  resize: vertical;
+  background: var(--hermes-input-bg, transparent);
+  color: var(--foreground, inherit);
+  box-sizing: border-box;
+}
+
+.yaoyao-audio-player {
+  width: 100%;
+  max-width: 600px;
+  margin-top: 0.5rem;
+}
+
+.yaoyao-section-title {
+  margin: 0 0 0.25rem 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.yaoyao-hint {
+  margin: 0 0 0.5rem 0;
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-agent-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-agent-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.3rem 0.5rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.03));
+  border-radius: 0.3rem;
+  font-size: 0.85rem;
+}
+
+.yaoyao-agent-add {
+  display: flex;
+  gap: 0.4rem;
+}
+
+/* Duplex voice status card */
+.yaoyao-duplex-status {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-duplex-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border: 1px solid var(--hermes-border, rgba(0,0,0,0.12));
+  border-radius: 0.3rem;
+  background: var(--hermes-card, rgba(0,0,0,0.03));
+  font-size: 0.8rem;
+  font-weight: 700;
+}
+
+.yaoyao-duplex-info {
+  min-width: 0;
+}
+
+.yaoyao-duplex-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.25rem;
+}
+
+.yaoyao-duplex-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-duplex-meta {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 1rem;
+  font-size: 0.8rem;
+}
+
+.yaoyao-duplex-meta > div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.yaoyao-meta-label {
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-switch {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor-row {
+  display: grid;
+  grid-template-columns: 0.85fr 1.15fr auto;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+/* Provider cards */
+.yaoyao-provider-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+
+.yaoyao-provider-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.yaoyao-provider-card.yaoyao-provider-active::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: var(--primary, #6366f1);
+}
+
+.yaoyao-provider-header {
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-provider-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-provider-actions {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}

+ 433 - 0
hermes-plugins/yaoyao/dashboard/dist/style.v1.2.0.css

@@ -0,0 +1,433 @@
+/* Yaoyao file library - dashboard plugin styles */
+
+.yaoyao-root {
+  width: 100%;
+}
+
+.yaoyao-tabs {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0 1.5rem;
+  border-bottom: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.08));
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.yaoyao-tab {
+  padding: 0.6rem 1rem;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 0.9rem;
+  color: var(--muted-foreground, #888);
+  border-bottom: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.yaoyao-tab:hover {
+  color: var(--foreground, #333);
+}
+
+.yaoyao-tab-active {
+  color: var(--foreground, #333);
+  border-bottom-color: var(--primary, #6366f1);
+  font-weight: 500;
+}
+
+.yaoyao-page {
+  width: 100%;
+  padding: 1.5rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.yaoyao-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-header h2 {
+  margin: 0;
+  font-size: 1.4rem;
+  font-weight: 600;
+}
+
+.yaoyao-stats {
+  display: flex;
+  gap: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.yaoyao-filters {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.25rem;
+  padding: 0.75rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.02));
+  border-radius: 0.5rem;
+}
+
+.yaoyao-filters label {
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+  margin-bottom: 0.15rem;
+  display: block;
+}
+
+.yaoyao-filters select,
+.yaoyao-filters input {
+  min-width: 100px;
+}
+
+.yaoyao-search {
+  display: flex;
+  gap: 0.4rem;
+  flex: 1;
+  min-width: 200px;
+}
+
+.yaoyao-search input {
+  flex: 1;
+}
+
+/* Grid */
+.yaoyao-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 0.75rem;
+}
+
+/* Card */
+.yaoyao-card {
+  overflow: hidden;
+}
+
+.yaoyao-card-content {
+  padding: 0.5rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-card-preview {
+  width: 100%;
+  aspect-ratio: 16 / 10;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.yaoyao-thumb {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.yaoyao-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.yaoyao-thumb-icon {
+  font-size: 2.5rem;
+  opacity: 0.5;
+}
+
+.yaoyao-thumb-loading {
+  font-size: 1.2rem;
+  opacity: 0.4;
+}
+
+.yaoyao-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-card-name {
+  font-size: 0.8rem;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.yaoyao-card-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  flex-wrap: wrap;
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-card-size {
+  font-variant-numeric: tabular-nums;
+}
+
+.yaoyao-card-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* States */
+.yaoyao-error {
+  padding: 0.75rem;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 0.5rem;
+  color: #b91c1c;
+  font-size: 0.85rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-loading,
+.yaoyao-empty {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.95rem;
+}
+
+.yaoyao-sentinel {
+  text-align: center;
+  padding: 1.5rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.85rem;
+}
+
+/* Voice settings */
+.yaoyao-voice-card {
+  padding: 1rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-voice-row {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-voice-row label {
+  font-size: 0.8rem;
+  font-weight: 500;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-row select,
+.yaoyao-voice-row input[type="number"] {
+  max-width: 320px;
+}
+
+.yaoyao-voice-check {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-check label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--foreground, #333);
+}
+
+.yaoyao-voice-actions {
+  display: flex;
+  gap: 0.5rem;
+  margin-top: 0.25rem;
+}
+
+.yaoyao-tts-textarea {
+  width: 100%;
+  max-width: 600px;
+  padding: 0.5rem;
+  border: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.12));
+  border-radius: 0.375rem;
+  font-size: 0.85rem;
+  resize: vertical;
+  background: var(--hermes-input-bg, transparent);
+  color: var(--foreground, inherit);
+  box-sizing: border-box;
+}
+
+.yaoyao-audio-player {
+  width: 100%;
+  max-width: 600px;
+  margin-top: 0.5rem;
+}
+
+.yaoyao-section-title {
+  margin: 0 0 0.25rem 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.yaoyao-hint {
+  margin: 0 0 0.5rem 0;
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-agent-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-agent-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.3rem 0.5rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.03));
+  border-radius: 0.3rem;
+  font-size: 0.85rem;
+}
+
+.yaoyao-agent-add {
+  display: flex;
+  gap: 0.4rem;
+}
+
+/* Duplex voice status card */
+.yaoyao-duplex-status {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-duplex-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border: 1px solid var(--hermes-border, rgba(0,0,0,0.12));
+  border-radius: 0.3rem;
+  background: var(--hermes-card, rgba(0,0,0,0.03));
+  font-size: 0.8rem;
+  font-weight: 700;
+}
+
+.yaoyao-duplex-info {
+  min-width: 0;
+}
+
+.yaoyao-duplex-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.25rem;
+}
+
+.yaoyao-duplex-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-duplex-meta {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 1rem;
+  font-size: 0.8rem;
+}
+
+.yaoyao-duplex-meta > div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.yaoyao-meta-label {
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-switch {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor-row {
+  display: grid;
+  grid-template-columns: 0.85fr 1.15fr auto;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+/* Provider cards */
+.yaoyao-provider-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+
+.yaoyao-provider-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.yaoyao-provider-card.yaoyao-provider-active::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: var(--primary, #6366f1);
+}
+
+.yaoyao-provider-header {
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-provider-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-provider-actions {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}

+ 433 - 0
hermes-plugins/yaoyao/dashboard/dist/style.v1.3.0.css

@@ -0,0 +1,433 @@
+/* Yaoyao file library - dashboard plugin styles */
+
+.yaoyao-root {
+  width: 100%;
+}
+
+.yaoyao-tabs {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0 1.5rem;
+  border-bottom: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.08));
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.yaoyao-tab {
+  padding: 0.6rem 1rem;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 0.9rem;
+  color: var(--muted-foreground, #888);
+  border-bottom: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.yaoyao-tab:hover {
+  color: var(--foreground, #333);
+}
+
+.yaoyao-tab-active {
+  color: var(--foreground, #333);
+  border-bottom-color: var(--primary, #6366f1);
+  font-weight: 500;
+}
+
+.yaoyao-page {
+  width: 100%;
+  padding: 1.5rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.yaoyao-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-header h2 {
+  margin: 0;
+  font-size: 1.4rem;
+  font-weight: 600;
+}
+
+.yaoyao-stats {
+  display: flex;
+  gap: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.yaoyao-filters {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.25rem;
+  padding: 0.75rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.02));
+  border-radius: 0.5rem;
+}
+
+.yaoyao-filters label {
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+  margin-bottom: 0.15rem;
+  display: block;
+}
+
+.yaoyao-filters select,
+.yaoyao-filters input {
+  min-width: 100px;
+}
+
+.yaoyao-search {
+  display: flex;
+  gap: 0.4rem;
+  flex: 1;
+  min-width: 200px;
+}
+
+.yaoyao-search input {
+  flex: 1;
+}
+
+/* Grid */
+.yaoyao-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 0.75rem;
+}
+
+/* Card */
+.yaoyao-card {
+  overflow: hidden;
+}
+
+.yaoyao-card-content {
+  padding: 0.5rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-card-preview {
+  width: 100%;
+  aspect-ratio: 16 / 10;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.yaoyao-thumb {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.yaoyao-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.yaoyao-thumb-icon {
+  font-size: 2.5rem;
+  opacity: 0.5;
+}
+
+.yaoyao-thumb-loading {
+  font-size: 1.2rem;
+  opacity: 0.4;
+}
+
+.yaoyao-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-card-name {
+  font-size: 0.8rem;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.yaoyao-card-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  flex-wrap: wrap;
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-card-size {
+  font-variant-numeric: tabular-nums;
+}
+
+.yaoyao-card-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* States */
+.yaoyao-error {
+  padding: 0.75rem;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 0.5rem;
+  color: #b91c1c;
+  font-size: 0.85rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-loading,
+.yaoyao-empty {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.95rem;
+}
+
+.yaoyao-sentinel {
+  text-align: center;
+  padding: 1.5rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.85rem;
+}
+
+/* Voice settings */
+.yaoyao-voice-card {
+  padding: 1rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-voice-row {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-voice-row label {
+  font-size: 0.8rem;
+  font-weight: 500;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-row select,
+.yaoyao-voice-row input[type="number"] {
+  max-width: 320px;
+}
+
+.yaoyao-voice-check {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-check label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--foreground, #333);
+}
+
+.yaoyao-voice-actions {
+  display: flex;
+  gap: 0.5rem;
+  margin-top: 0.25rem;
+}
+
+.yaoyao-tts-textarea {
+  width: 100%;
+  max-width: 600px;
+  padding: 0.5rem;
+  border: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.12));
+  border-radius: 0.375rem;
+  font-size: 0.85rem;
+  resize: vertical;
+  background: var(--hermes-input-bg, transparent);
+  color: var(--foreground, inherit);
+  box-sizing: border-box;
+}
+
+.yaoyao-audio-player {
+  width: 100%;
+  max-width: 600px;
+  margin-top: 0.5rem;
+}
+
+.yaoyao-section-title {
+  margin: 0 0 0.25rem 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.yaoyao-hint {
+  margin: 0 0 0.5rem 0;
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-agent-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-agent-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.3rem 0.5rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.03));
+  border-radius: 0.3rem;
+  font-size: 0.85rem;
+}
+
+.yaoyao-agent-add {
+  display: flex;
+  gap: 0.4rem;
+}
+
+/* Duplex voice status card */
+.yaoyao-duplex-status {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-duplex-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border: 1px solid var(--hermes-border, rgba(0,0,0,0.12));
+  border-radius: 0.3rem;
+  background: var(--hermes-card, rgba(0,0,0,0.03));
+  font-size: 0.8rem;
+  font-weight: 700;
+}
+
+.yaoyao-duplex-info {
+  min-width: 0;
+}
+
+.yaoyao-duplex-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.25rem;
+}
+
+.yaoyao-duplex-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-duplex-meta {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 1rem;
+  font-size: 0.8rem;
+}
+
+.yaoyao-duplex-meta > div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.yaoyao-meta-label {
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-switch {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor-row {
+  display: grid;
+  grid-template-columns: 0.85fr 1.15fr auto;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+/* Provider cards */
+.yaoyao-provider-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+
+.yaoyao-provider-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.yaoyao-provider-card.yaoyao-provider-active::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: var(--primary, #6366f1);
+}
+
+.yaoyao-provider-header {
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-provider-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-provider-actions {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}

+ 433 - 0
hermes-plugins/yaoyao/dashboard/dist/style.v1.4.0.css

@@ -0,0 +1,433 @@
+/* Yaoyao file library - dashboard plugin styles */
+
+.yaoyao-root {
+  width: 100%;
+}
+
+.yaoyao-tabs {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0 1.5rem;
+  border-bottom: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.08));
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.yaoyao-tab {
+  padding: 0.6rem 1rem;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 0.9rem;
+  color: var(--muted-foreground, #888);
+  border-bottom: 2px solid transparent;
+  transition: color 0.15s, border-color 0.15s;
+}
+
+.yaoyao-tab:hover {
+  color: var(--foreground, #333);
+}
+
+.yaoyao-tab-active {
+  color: var(--foreground, #333);
+  border-bottom-color: var(--primary, #6366f1);
+  font-weight: 500;
+}
+
+.yaoyao-page {
+  width: 100%;
+  padding: 1.5rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.yaoyao-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-header h2 {
+  margin: 0;
+  font-size: 1.4rem;
+  font-weight: 600;
+}
+
+.yaoyao-stats {
+  display: flex;
+  gap: 0.4rem;
+  flex-wrap: wrap;
+}
+
+.yaoyao-filters {
+  display: flex;
+  align-items: flex-end;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  margin-bottom: 1.25rem;
+  padding: 0.75rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.02));
+  border-radius: 0.5rem;
+}
+
+.yaoyao-filters label {
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+  margin-bottom: 0.15rem;
+  display: block;
+}
+
+.yaoyao-filters select,
+.yaoyao-filters input {
+  min-width: 100px;
+}
+
+.yaoyao-search {
+  display: flex;
+  gap: 0.4rem;
+  flex: 1;
+  min-width: 200px;
+}
+
+.yaoyao-search input {
+  flex: 1;
+}
+
+/* Grid */
+.yaoyao-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 0.75rem;
+}
+
+/* Card */
+.yaoyao-card {
+  overflow: hidden;
+}
+
+.yaoyao-card-content {
+  padding: 0.5rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-card-preview {
+  width: 100%;
+  aspect-ratio: 16 / 10;
+  background: rgba(0, 0, 0, 0.03);
+  border-radius: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.yaoyao-thumb {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.yaoyao-thumb-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.yaoyao-thumb-icon {
+  font-size: 2.5rem;
+  opacity: 0.5;
+}
+
+.yaoyao-thumb-loading {
+  font-size: 1.2rem;
+  opacity: 0.4;
+}
+
+.yaoyao-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-card-name {
+  font-size: 0.8rem;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.yaoyao-card-meta {
+  display: flex;
+  align-items: center;
+  gap: 0.3rem;
+  flex-wrap: wrap;
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-card-size {
+  font-variant-numeric: tabular-nums;
+}
+
+.yaoyao-card-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* States */
+.yaoyao-error {
+  padding: 0.75rem;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 0.5rem;
+  color: #b91c1c;
+  font-size: 0.85rem;
+  margin-bottom: 1rem;
+}
+
+.yaoyao-loading,
+.yaoyao-empty {
+  text-align: center;
+  padding: 3rem 1rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.95rem;
+}
+
+.yaoyao-sentinel {
+  text-align: center;
+  padding: 1.5rem;
+  color: var(--muted-foreground, #888);
+  font-size: 0.85rem;
+}
+
+/* Voice settings */
+.yaoyao-voice-card {
+  padding: 1rem !important;
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-voice-row {
+  display: flex;
+  flex-direction: column;
+  gap: 0.25rem;
+}
+
+.yaoyao-voice-row label {
+  font-size: 0.8rem;
+  font-weight: 500;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-row select,
+.yaoyao-voice-row input[type="number"] {
+  max-width: 320px;
+}
+
+.yaoyao-voice-check {
+  flex-direction: row;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-check label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  color: var(--foreground, #333);
+}
+
+.yaoyao-voice-actions {
+  display: flex;
+  gap: 0.5rem;
+  margin-top: 0.25rem;
+}
+
+.yaoyao-tts-textarea {
+  width: 100%;
+  max-width: 600px;
+  padding: 0.5rem;
+  border: 1px solid var(--hermes-border, rgba(0, 0, 0, 0.12));
+  border-radius: 0.375rem;
+  font-size: 0.85rem;
+  resize: vertical;
+  background: var(--hermes-input-bg, transparent);
+  color: var(--foreground, inherit);
+  box-sizing: border-box;
+}
+
+.yaoyao-audio-player {
+  width: 100%;
+  max-width: 600px;
+  margin-top: 0.5rem;
+}
+
+.yaoyao-section-title {
+  margin: 0 0 0.25rem 0;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+.yaoyao-hint {
+  margin: 0 0 0.5rem 0;
+  font-size: 0.75rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-agent-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+}
+
+.yaoyao-agent-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.3rem 0.5rem;
+  background: var(--hermes-card, rgba(0, 0, 0, 0.03));
+  border-radius: 0.3rem;
+  font-size: 0.85rem;
+}
+
+.yaoyao-agent-add {
+  display: flex;
+  gap: 0.4rem;
+}
+
+/* Duplex voice status card */
+.yaoyao-duplex-status {
+  display: flex;
+  align-items: flex-start;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.yaoyao-duplex-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border: 1px solid var(--hermes-border, rgba(0,0,0,0.12));
+  border-radius: 0.3rem;
+  background: var(--hermes-card, rgba(0,0,0,0.03));
+  font-size: 0.8rem;
+  font-weight: 700;
+}
+
+.yaoyao-duplex-info {
+  min-width: 0;
+}
+
+.yaoyao-duplex-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.25rem;
+}
+
+.yaoyao-duplex-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-duplex-meta {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.5rem 1rem;
+  font-size: 0.8rem;
+}
+
+.yaoyao-duplex-meta > div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.1rem;
+}
+
+.yaoyao-meta-label {
+  font-size: 0.7rem;
+  color: var(--muted-foreground, #888);
+}
+
+.yaoyao-voice-switch {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.yaoyao-voice-editor-row {
+  display: grid;
+  grid-template-columns: 0.85fr 1.15fr auto;
+  gap: 0.4rem;
+  align-items: center;
+}
+
+/* Provider cards */
+.yaoyao-provider-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+
+.yaoyao-provider-card {
+  position: relative;
+  overflow: hidden;
+}
+
+.yaoyao-provider-card.yaoyao-provider-active::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: var(--primary, #6366f1);
+}
+
+.yaoyao-provider-header {
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-title-row {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.yaoyao-provider-title-row h5 {
+  margin: 0;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.yaoyao-provider-actions {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+
+.yaoyao-provider-fields {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}

+ 2050 - 0
hermes-plugins/yaoyao/dashboard/group_gateway.py

@@ -0,0 +1,2050 @@
+"""Stable in-process adapter for the Hermes TUI Gateway JSON-RPC API."""
+
+from __future__ import annotations
+
+import copy
+import inspect
+import logging
+import math
+import threading
+import time
+import uuid
+from collections import deque
+from dataclasses import dataclass, field
+from functools import partial
+from pathlib import Path
+from typing import Any, Callable
+
+
+logger = logging.getLogger(__name__)
+
+_DELTA_EVENTS = frozenset({
+    "message.delta",
+    "reasoning.delta",
+    "thinking.delta",
+})
+_APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
+
+MAX_DELTA_BUFFER_BYTES = 64 * 1024
+MAX_CONTROL_PAYLOAD_BYTES = 256 * 1024
+MAX_TERMINAL_TEXT_BYTES = 64 * 1024
+MAX_SAFE_JSON_INTEGER = (1 << 53) - 1
+_MAX_DELTA_METADATA_BYTES = 16 * 1024
+_MAX_CONTROL_STRING_BYTES = 32 * 1024
+_MAX_DIAGNOSTIC_BYTES = 12 * 1024
+_MAX_SUMMARY_BYTES = 12 * 1024
+_MAX_PREVIEW_BYTES = 32 * 1024
+_MAX_TOOL_PREVIEW_BYTES = 16 * 1024
+_MAX_ROUTE_FIELD_BYTES = 4096
+_MAX_EVENT_TYPE_BYTES = 256
+_MAX_RUNTIME_ID_BYTES = 4096
+_MAX_JSON_DEPTH = 64
+_MAX_JSON_NODES = 100_000
+_MAX_JSON_INSPECTION_BYTES = 4 * 1024 * 1024
+_MAX_FINITE_JSON_FLOAT = 1.7976931348623157e308
+_SESSION_INFO_STRING_LIMITS = {
+    "model": _MAX_ROUTE_FIELD_BYTES,
+    "provider": _MAX_ROUTE_FIELD_BYTES,
+    "reasoning_effort": _MAX_ROUTE_FIELD_BYTES,
+    "service_tier": _MAX_ROUTE_FIELD_BYTES,
+    "approval_mode": _MAX_ROUTE_FIELD_BYTES,
+    "cwd": _MAX_CONTROL_STRING_BYTES,
+    "branch": _MAX_ROUTE_FIELD_BYTES,
+    "personality": _MAX_ROUTE_FIELD_BYTES,
+    "title": _MAX_DIAGNOSTIC_BYTES,
+    "stored_session_id": _MAX_ROUTE_FIELD_BYTES,
+    "version": _MAX_ROUTE_FIELD_BYTES,
+    "release_date": _MAX_ROUTE_FIELD_BYTES,
+    "update_command": _MAX_DIAGNOSTIC_BYTES,
+    "credential_warning": _MAX_DIAGNOSTIC_BYTES,
+    "profile_name": _MAX_ROUTE_FIELD_BYTES,
+}
+_SESSION_INFO_PREVIEW_LIMITS = {
+    "tools": _MAX_TOOL_PREVIEW_BYTES,
+    "skills": _MAX_TOOL_PREVIEW_BYTES,
+    "project": _MAX_SUMMARY_BYTES,
+    "usage": _MAX_SUMMARY_BYTES,
+    "mcp_servers": _MAX_TOOL_PREVIEW_BYTES,
+}
+_SESSION_INFO_BOOLEAN_FIELDS = ("running", "fast", "yolo")
+_SESSION_INFO_INTEGER_FIELDS = ("desktop_contract",)
+_SESSION_INFO_OPTIONAL_INTEGER_FIELDS = ("update_behind",)
+_SESSION_INFO_DROPPED_FIELDS = frozenset({"system_prompt"})
+_SESSION_INFO_EXACT_ROUTE_FIELDS = frozenset({
+    "stored_session_id",
+    "profile_name",
+    "cwd",
+})
+
+
+class GatewayError(RuntimeError):
+    """Base exception for the plugin's stable Gateway boundary."""
+
+
+class GatewayValidationError(GatewayError):
+    """A caller supplied an invalid Gateway argument."""
+
+
+class GatewayTransportClosed(GatewayError):
+    """The in-process transport was closed while a request was pending."""
+
+
+class GatewayTimeoutError(GatewayError):
+    """A Gateway request did not receive a response before its deadline."""
+
+    def __init__(self, method: str) -> None:
+        self.method = method
+        super().__init__(f"{method} timed out")
+
+
+class GatewayDispatchError(GatewayError):
+    """The core dispatcher failed synchronously."""
+
+    def __init__(self, method: str) -> None:
+        self.method = method
+        super().__init__(f"{method} dispatcher failed")
+
+
+class GatewayProtocolError(GatewayError):
+    """The core dispatcher returned a response outside the stable contract."""
+
+    def __init__(self, method: str, detail: str) -> None:
+        self.method = method
+        self.detail = detail
+        super().__init__(f"{method}: {detail}")
+
+
+class GatewayRPCError(GatewayError):
+    """A well-formed JSON-RPC error returned by the core Gateway."""
+
+    def __init__(
+        self,
+        method: str,
+        code: int,
+        rpc_message: str,
+        data: Any = None,
+    ) -> None:
+        self.method = method
+        self.code = code
+        self.rpc_message = rpc_message
+        self.data = data
+        # Do not place core-provided text/data in the display string. HTTP callers
+        # can safely surface this exception without disclosing internal details.
+        super().__init__(f"{method} RPC failed ({code})")
+
+
+@dataclass(frozen=True)
+class SessionIdentity:
+    """Persistent and process-local identities returned by session operations."""
+
+    stored_id: str
+    runtime_id: str
+    running: bool
+
+
+@dataclass
+class _ResponseWaiter:
+    request_id: str
+    method: str
+    on_late_response: Callable[[str, dict[str, Any]], None] | None = None
+    event: threading.Event = field(default_factory=threading.Event)
+    response: dict[str, Any] | None = None
+    error: BaseException | None = None
+
+
+@dataclass(frozen=True)
+class _LateResponseTombstone:
+    method: str
+    expires_at: float
+    callback: Callable[[str, dict[str, Any]], None]
+
+
+@dataclass(frozen=True)
+class _LateSessionContext:
+    request_id: str
+    method: str
+    profile: str
+    requested_stored_id: str | None
+    adoption_generation: int
+
+
+@dataclass(frozen=True)
+class _JsonInspection:
+    valid: bool
+    encoded_bytes: int
+    reason: str = ""
+
+
+@dataclass
+class _QueuedEvent:
+    runtime_id: str
+    event_type: str
+    payload: dict[str, Any]
+    delta_key: tuple[str, str] | None = None
+    delta_bytes: int = 0
+
+
+EventCallback = Callable[..., None]
+Dispatcher = Callable[[dict[str, Any], "GroupGatewayTransport"], dict[str, Any] | None]
+
+
+def _is_safe_json_integer(value: Any) -> bool:
+    """Return whether an exact integer round-trips through IEEE-754 JSON peers."""
+
+    return (
+        type(value) is int
+        and value.bit_length() <= MAX_SAFE_JSON_INTEGER.bit_length()
+        and -MAX_SAFE_JSON_INTEGER <= value <= MAX_SAFE_JSON_INTEGER
+    )
+
+
+def _is_safe_json_float(value: Any) -> bool:
+    """Return whether an exact float is a finite JSON-interoperable scalar."""
+
+    return (
+        type(value) is float
+        and math.isfinite(value)
+        and abs(value) <= _MAX_FINITE_JSON_FLOAT
+    )
+
+
+def _is_safe_json_scalar(value: Any) -> bool:
+    return (
+        value is None
+        or type(value) is bool
+        or _is_safe_json_integer(value)
+        or _is_safe_json_float(value)
+    )
+
+
+def _inspect_json_payload(value: Any) -> _JsonInspection:
+    """Validate strict JSON and measure its UTF-8 encoding without serializing."""
+
+    encoded_bytes = 0
+    nodes = 0
+    ancestors: set[int] = set()
+    failure_reason = ""
+
+    def consume(size: int) -> bool:
+        nonlocal encoded_bytes, failure_reason
+        encoded_bytes += size
+        if encoded_bytes > _MAX_JSON_INSPECTION_BYTES:
+            failure_reason = "payload exceeds inspection budget"
+            return False
+        return True
+
+    def string_size(text: str) -> int | None:
+        nonlocal failure_reason
+        if len(text) > _MAX_JSON_INSPECTION_BYTES:
+            failure_reason = "string exceeds inspection budget"
+            return None
+        size = 2
+        for character in text:
+            codepoint = ord(character)
+            if 0xD800 <= codepoint <= 0xDFFF:
+                failure_reason = "string contains an invalid Unicode surrogate"
+                return None
+            if character in {'"', "\\"}:
+                size += 2
+            elif codepoint <= 0x1F:
+                size += 6
+            else:
+                size += len(character.encode("utf-8"))
+            if size > _MAX_JSON_INSPECTION_BYTES:
+                failure_reason = "string exceeds inspection budget"
+                return None
+        return size
+
+    def walk(item: Any, depth: int) -> bool:
+        nonlocal nodes, failure_reason
+        nodes += 1
+        if nodes > _MAX_JSON_NODES:
+            failure_reason = "payload contains too many JSON nodes"
+            return False
+        if depth > _MAX_JSON_DEPTH:
+            failure_reason = "payload exceeds the JSON depth limit"
+            return False
+        if item is None:
+            return consume(4)
+        if type(item) is bool:
+            return consume(4 if item else 5)
+        if type(item) is int:
+            if not _is_safe_json_integer(item):
+                failure_reason = "integer exceeds the interoperable JSON range"
+                return False
+            return consume(len(str(item)))
+        if type(item) is float:
+            if not _is_safe_json_float(item):
+                failure_reason = "float is outside the finite JSON range"
+                return False
+            return consume(len(repr(item)))
+        if isinstance(item, str):
+            encoded_size = string_size(item)
+            return encoded_size is not None and consume(encoded_size)
+        if isinstance(item, list):
+            identity = id(item)
+            if identity in ancestors:
+                failure_reason = "cyclic list is not JSON"
+                return False
+            if not consume(2 + max(0, len(item) - 1)):
+                return False
+            ancestors.add(identity)
+            try:
+                return all(walk(child, depth + 1) for child in item)
+            finally:
+                ancestors.remove(identity)
+        if isinstance(item, dict):
+            identity = id(item)
+            if identity in ancestors:
+                failure_reason = "cyclic object is not JSON"
+                return False
+            if not consume(2 + max(0, len(item) - 1)):
+                return False
+            ancestors.add(identity)
+            try:
+                for key, child in item.items():
+                    if not isinstance(key, str):
+                        failure_reason = "JSON object keys must be strings"
+                        return False
+                    encoded_size = string_size(key)
+                    if encoded_size is None or not consume(encoded_size + 1):
+                        return False
+                    if not walk(child, depth + 1):
+                        return False
+                return True
+            finally:
+                ancestors.remove(identity)
+        failure_reason = "payload contains a non-JSON value"
+        return False
+
+    try:
+        valid = walk(value, 0)
+    except Exception:  # noqa: BLE001 - malformed core payloads are rejected
+        return _JsonInspection(False, encoded_bytes, "payload validation failed")
+    return _JsonInspection(valid, encoded_bytes, failure_reason)
+
+
+def _json_payload_within_limit(value: Any, limit: int) -> bool:
+    inspection = _inspect_json_payload(value)
+    return inspection.valid and inspection.encoded_bytes <= limit
+
+
+def _utf8_chunks(text: str, limit: int):
+    """Yield non-empty codepoint-safe chunks no larger than ``limit`` bytes."""
+
+    if not text:
+        yield "", 0
+        return
+    characters: list[str] = []
+    encoded_bytes = 0
+    for character in text:
+        character_bytes = len(character.encode("utf-8"))
+        if characters and encoded_bytes + character_bytes > limit:
+            yield "".join(characters), encoded_bytes
+            characters = []
+            encoded_bytes = 0
+        characters.append(character)
+        encoded_bytes += character_bytes
+    if characters:
+        yield "".join(characters), encoded_bytes
+
+
+def _bounded_utf8(text: str, limit: int) -> bool:
+    if len(text) > limit:
+        return False
+    try:
+        return len(text.encode("utf-8")) <= limit
+    except UnicodeError:
+        return False
+
+
+def _utf8_prefix(text: str, limit: int) -> tuple[str, bool]:
+    characters: list[str] = []
+    encoded_bytes = 0
+    try:
+        for character in text:
+            character_bytes = len(character.encode("utf-8"))
+            if encoded_bytes + character_bytes > limit:
+                return "".join(characters), True
+            characters.append(character)
+            encoded_bytes += character_bytes
+    except UnicodeError:
+        return "".join(characters), True
+    return text, False
+
+
+def _json_string_prefix(text: str, limit: int) -> tuple[str, bool]:
+    """Bound a string by its strict UTF-8 JSON encoding, including escapes."""
+
+    characters: list[str] = []
+    encoded_bytes = 2  # Opening and closing quotes.
+    for character in text:
+        codepoint = ord(character)
+        if 0xD800 <= codepoint <= 0xDFFF:
+            return "".join(characters), True
+        if character in {'"', "\\"}:
+            character_bytes = 2
+        elif codepoint <= 0x1F:
+            character_bytes = 6
+        else:
+            character_bytes = len(character.encode("utf-8"))
+        if encoded_bytes + character_bytes > limit:
+            return "".join(characters), True
+        characters.append(character)
+        encoded_bytes += character_bytes
+    return text, False
+
+
+def _contains_control_character(text: str) -> bool:
+    return any(
+        ord(character) <= 0x1F or ord(character) == 0x7F
+        for character in text
+    )
+
+
+@dataclass
+class _PreviewBudget:
+    remaining_bytes: int
+    remaining_nodes: int = 128
+    truncated: bool = False
+
+
+def _bounded_json_preview(value: Any, limit: int) -> tuple[Any, bool]:
+    if _json_payload_within_limit(value, limit):
+        try:
+            return copy.deepcopy(value), False
+        except Exception:  # noqa: BLE001 - fall through to the safe projector
+            pass
+
+    budget = _PreviewBudget(limit)
+
+    def walk(item: Any, depth: int) -> Any:
+        budget.remaining_nodes -= 1
+        if budget.remaining_nodes < 0 or depth > 4:
+            budget.truncated = True
+            return "<truncated>"
+        if item is None or type(item) is bool:
+            return item
+        if type(item) is int:
+            if _is_safe_json_integer(item):
+                return item
+            budget.truncated = True
+            return "<integer outside safe JSON range>"
+        if type(item) is float:
+            if _is_safe_json_float(item):
+                return item
+            budget.truncated = True
+            return "<float outside finite JSON range>"
+        if isinstance(item, str):
+            per_string_limit = min(8192, max(0, budget.remaining_bytes))
+            projected, truncated = _utf8_prefix(item, per_string_limit)
+            budget.remaining_bytes -= len(projected.encode("utf-8"))
+            budget.truncated = budget.truncated or truncated
+            return projected
+        if isinstance(item, list):
+            projected_items = []
+            for child in item[:32]:
+                projected_items.append(walk(child, depth + 1))
+            if len(item) > len(projected_items):
+                budget.truncated = True
+            return projected_items
+        if isinstance(item, dict):
+            projected_object: dict[str, Any] = {}
+            for index, (key, child) in enumerate(item.items()):
+                if index >= 32 or budget.remaining_nodes <= 0:
+                    budget.truncated = True
+                    break
+                if not isinstance(key, str):
+                    budget.truncated = True
+                    continue
+                projected_key, key_truncated = _utf8_prefix(key, 256)
+                if not projected_key or projected_key in projected_object:
+                    budget.truncated = True
+                    continue
+                budget.truncated = budget.truncated or key_truncated
+                projected_object[projected_key] = walk(child, depth + 1)
+            return projected_object
+        budget.truncated = True
+        return "<invalid value>"
+
+    projected = walk(value, 0)
+    if not _json_payload_within_limit(projected, limit):
+        return "<preview omitted>", True
+    return projected, True
+
+
+def _copy_bounded_string(
+    source: dict[str, Any],
+    target: dict[str, Any],
+    field_name: str,
+    limit: int,
+    truncated_fields: list[str],
+) -> None:
+    if field_name not in source:
+        return
+    value = source[field_name]
+    if not isinstance(value, str):
+        truncated_fields.append(field_name)
+        return
+    projected, truncated = _utf8_prefix(value, limit)
+    target[field_name] = projected
+    if truncated:
+        truncated_fields.append(field_name)
+
+
+def _copy_bounded_preview(
+    source: dict[str, Any],
+    target: dict[str, Any],
+    field_name: str,
+    limit: int,
+    truncated_fields: list[str],
+) -> None:
+    if field_name not in source:
+        return
+    projected, truncated = _bounded_json_preview(source[field_name], limit)
+    target[field_name] = projected
+    if truncated:
+        truncated_fields.append(field_name)
+
+
+def _copy_scalar_fields(
+    source: dict[str, Any],
+    target: dict[str, Any],
+    fields: tuple[str, ...],
+    invalid_fields: list[str],
+) -> None:
+    for field_name in fields:
+        if field_name not in source:
+            continue
+        value = source[field_name]
+        if _is_safe_json_scalar(value):
+            target[field_name] = value
+        else:
+            invalid_fields.append(field_name)
+
+
+def _is_session_info_scalar_valid(field_name: str, value: Any) -> bool:
+    if field_name in _SESSION_INFO_BOOLEAN_FIELDS:
+        return type(value) is bool
+    if field_name in _SESSION_INFO_INTEGER_FIELDS:
+        return _is_safe_json_integer(value)
+    if field_name in _SESSION_INFO_OPTIONAL_INTEGER_FIELDS:
+        return value is None or _is_safe_json_integer(value)
+    return False
+
+
+def _copy_session_info_scalars(
+    source: dict[str, Any],
+    target: dict[str, Any],
+    invalid_fields: list[str],
+) -> None:
+    scalar_fields = (
+        *_SESSION_INFO_BOOLEAN_FIELDS,
+        *_SESSION_INFO_INTEGER_FIELDS,
+        *_SESSION_INFO_OPTIONAL_INTEGER_FIELDS,
+    )
+    for field_name in scalar_fields:
+        if field_name not in source:
+            continue
+        value = source[field_name]
+        if _is_session_info_scalar_valid(field_name, value):
+            target[field_name] = value
+        else:
+            invalid_fields.append(field_name)
+
+
+def _copy_session_info_string(
+    source: dict[str, Any],
+    target: dict[str, Any],
+    field_name: str,
+    limit: int,
+    truncated_fields: list[str],
+    invalid_fields: list[str],
+) -> None:
+    if field_name not in source:
+        return
+    value = source[field_name]
+    if not isinstance(value, str):
+        invalid_fields.append(field_name)
+        return
+    if field_name in _SESSION_INFO_EXACT_ROUTE_FIELDS:
+        if _contains_control_character(value):
+            invalid_fields.append(field_name)
+            return
+        if not _json_payload_within_limit(value, limit):
+            truncated_fields.append(field_name)
+            return
+        target[field_name] = value
+        return
+    projected, truncated = _json_string_prefix(value, limit)
+    target[field_name] = projected
+    if truncated:
+        truncated_fields.append(field_name)
+
+
+def _append_dropped_fields(
+    source: dict[str, Any],
+    selected: set[str],
+    truncated_fields: list[str],
+) -> None:
+    for field_name in source:
+        if not isinstance(field_name, str) or field_name in selected:
+            continue
+        if len(truncated_fields) >= 64:
+            break
+        projected_name, _truncated = _utf8_prefix(field_name, 256)
+        if projected_name:
+            truncated_fields.append(projected_name)
+
+
+def _mark_projected_control(
+    payload: dict[str, Any],
+    inspection: _JsonInspection,
+    truncated_fields: list[str],
+    invalid_fields: list[str],
+) -> dict[str, Any]:
+    if inspection.valid:
+        payload["transportTruncated"] = True
+        payload["originalBytes"] = inspection.encoded_bytes
+        payload["transportWarning"] = "Gateway control payload was safely truncated"
+    else:
+        payload["transportInvalidPayload"] = True
+        payload["transportWarning"] = (
+            "Gateway control payload was invalid and safely projected"
+        )
+    unique_fields = list(dict.fromkeys(truncated_fields))[:64]
+    if unique_fields:
+        payload["truncatedFields"] = unique_fields
+    unique_invalid_fields = list(dict.fromkeys(invalid_fields))[:64]
+    if unique_invalid_fields:
+        payload["invalidFields"] = unique_invalid_fields
+    return payload
+
+
+def _project_message_control(
+    source: dict[str, Any],
+    inspection: _JsonInspection,
+) -> dict[str, Any]:
+    projected: dict[str, Any] = {}
+    truncated_fields: list[str] = []
+    invalid_fields: list[str] = []
+    string_limits = {
+        "status": 256,
+        "text": MAX_TERMINAL_TEXT_BYTES,
+        "reasoning": MAX_TERMINAL_TEXT_BYTES,
+        "error": _MAX_DIAGNOSTIC_BYTES,
+        "failure_reason": _MAX_DIAGNOSTIC_BYTES,
+        "warning": _MAX_DIAGNOSTIC_BYTES,
+        "message": _MAX_DIAGNOSTIC_BYTES,
+    }
+    for field_name, limit in string_limits.items():
+        _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
+    _copy_scalar_fields(
+        source,
+        projected,
+        ("recoverable", "partial", "response_previewed"),
+        invalid_fields,
+    )
+    for field_name in ("usage", "billing"):
+        _copy_bounded_preview(
+            source, projected, field_name, _MAX_SUMMARY_BYTES, truncated_fields
+        )
+    selected = set(string_limits) | {
+        "recoverable",
+        "partial",
+        "response_previewed",
+        "usage",
+        "billing",
+    }
+    _append_dropped_fields(source, selected, truncated_fields)
+    return _mark_projected_control(
+        projected, inspection, truncated_fields, invalid_fields
+    )
+
+
+def _project_interaction_control(
+    source: dict[str, Any],
+    inspection: _JsonInspection,
+) -> dict[str, Any]:
+    projected: dict[str, Any] = {}
+    truncated_fields: list[str] = []
+    invalid_fields: list[str] = []
+    string_limits = {
+        "request_id": _MAX_ROUTE_FIELD_BYTES,
+        "interaction_id": _MAX_ROUTE_FIELD_BYTES,
+        "question": _MAX_CONTROL_STRING_BYTES,
+        "command": _MAX_CONTROL_STRING_BYTES,
+        "reason": _MAX_DIAGNOSTIC_BYTES,
+        "message": _MAX_DIAGNOSTIC_BYTES,
+        "description": _MAX_DIAGNOSTIC_BYTES,
+        "tool": _MAX_ROUTE_FIELD_BYTES,
+        "name": _MAX_ROUTE_FIELD_BYTES,
+    }
+    for field_name, limit in string_limits.items():
+        _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
+    for field_name in ("choices", "flags"):
+        _copy_bounded_preview(
+            source, projected, field_name, _MAX_PREVIEW_BYTES, truncated_fields
+        )
+    scalar_fields = (
+        "allow_permanent",
+        "smart_denied",
+        "multi_select",
+        "redacted",
+        "all",
+    )
+    _copy_scalar_fields(source, projected, scalar_fields, invalid_fields)
+    selected = set(string_limits) | {"choices", "flags", *scalar_fields}
+    _append_dropped_fields(source, selected, truncated_fields)
+    return _mark_projected_control(
+        projected, inspection, truncated_fields, invalid_fields
+    )
+
+
+def _project_tool_control(
+    source: dict[str, Any],
+    inspection: _JsonInspection,
+) -> dict[str, Any]:
+    projected: dict[str, Any] = {}
+    truncated_fields: list[str] = []
+    invalid_fields: list[str] = []
+    string_limits = {
+        "tool_id": _MAX_ROUTE_FIELD_BYTES,
+        "id": _MAX_ROUTE_FIELD_BYTES,
+        "name": _MAX_ROUTE_FIELD_BYTES,
+        "status": 256,
+        "risk": _MAX_ROUTE_FIELD_BYTES,
+        "summary": _MAX_SUMMARY_BYTES,
+        "context": _MAX_SUMMARY_BYTES,
+        "error": _MAX_DIAGNOSTIC_BYTES,
+        "warning": _MAX_DIAGNOSTIC_BYTES,
+        "args_text": _MAX_TOOL_PREVIEW_BYTES,
+        "result_text": _MAX_TOOL_PREVIEW_BYTES,
+        "inline_diff": _MAX_TOOL_PREVIEW_BYTES,
+    }
+    for field_name, limit in string_limits.items():
+        _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
+    for field_name in ("args", "result", "findings", "todos", "metadata"):
+        _copy_bounded_preview(
+            source, projected, field_name, _MAX_TOOL_PREVIEW_BYTES, truncated_fields
+        )
+    scalar_fields = ("redacted", "duration_s", "progress")
+    _copy_scalar_fields(source, projected, scalar_fields, invalid_fields)
+    selected = set(string_limits) | {
+        "args",
+        "result",
+        "findings",
+        "todos",
+        "metadata",
+        *scalar_fields,
+    }
+    _append_dropped_fields(source, selected, truncated_fields)
+    return _mark_projected_control(
+        projected, inspection, truncated_fields, invalid_fields
+    )
+
+
+def _project_session_info_control(
+    source: dict[str, Any],
+    inspection: _JsonInspection,
+) -> dict[str, Any]:
+    """Keep session routing and settle state when diagnostics exceed the wire cap."""
+
+    projected: dict[str, Any] = {}
+    truncated_fields: list[str] = []
+    invalid_fields: list[str] = []
+    for field_name, limit in _SESSION_INFO_STRING_LIMITS.items():
+        _copy_session_info_string(
+            source,
+            projected,
+            field_name,
+            limit,
+            truncated_fields,
+            invalid_fields,
+        )
+    _copy_session_info_scalars(source, projected, invalid_fields)
+    for field_name, limit in _SESSION_INFO_PREVIEW_LIMITS.items():
+        _copy_bounded_preview(
+            source, projected, field_name, limit, truncated_fields
+        )
+    for field_name in _SESSION_INFO_DROPPED_FIELDS:
+        if field_name in source:
+            truncated_fields.append(field_name)
+    selected = (
+        set(_SESSION_INFO_STRING_LIMITS)
+        | set(_SESSION_INFO_PREVIEW_LIMITS)
+        | set(_SESSION_INFO_BOOLEAN_FIELDS)
+        | set(_SESSION_INFO_INTEGER_FIELDS)
+        | set(_SESSION_INFO_OPTIONAL_INTEGER_FIELDS)
+        | set(_SESSION_INFO_DROPPED_FIELDS)
+    )
+    _append_dropped_fields(source, selected, truncated_fields)
+    marked = _mark_projected_control(
+        projected, inspection, truncated_fields, invalid_fields
+    )
+    if invalid_fields:
+        marked["transportInvalidPayload"] = True
+    return marked
+
+
+def _control_payload_sentinel(inspection: _JsonInspection) -> dict[str, Any]:
+    if not inspection.valid:
+        return {
+            "transportInvalidPayload": True,
+            "transportWarning": "Gateway control payload was invalid and replaced",
+        }
+    return {
+        "transportTruncated": True,
+        "originalBytes": inspection.encoded_bytes,
+        "transportWarning": "Gateway control payload was replaced at the size limit",
+    }
+
+
+def _sanitize_control_payload(
+    event_type: str,
+    source: Any,
+    inspection: _JsonInspection,
+) -> dict[str, Any]:
+    if not isinstance(source, dict):
+        return _control_payload_sentinel(inspection)
+    if event_type == "session.info":
+        projected = _project_session_info_control(source, inspection)
+    elif (
+        event_type == "message.complete"
+        or event_type == "error"
+        or event_type.endswith((".error", ".failed"))
+    ):
+        projected = _project_message_control(source, inspection)
+    elif event_type == "approval.request" or event_type in {
+        "clarify.request",
+        "clarify.expire",
+    }:
+        projected = _project_interaction_control(source, inspection)
+    elif event_type.startswith("tool."):
+        projected = _project_tool_control(source, inspection)
+    else:
+        return _control_payload_sentinel(inspection)
+
+    if _json_payload_within_limit(projected, MAX_CONTROL_PAYLOAD_BYTES):
+        return projected
+    logger.error("Gateway control projection exceeded its hard byte limit")
+    return _control_payload_sentinel(
+        _JsonInspection(False, inspection.encoded_bytes, "projection exceeded limit")
+    )
+
+
+def _control_requires_projection(
+    event_type: str,
+    source: Any,
+    inspection: _JsonInspection,
+) -> bool:
+    if (
+        not isinstance(source, dict)
+        or not inspection.valid
+        or inspection.encoded_bytes > MAX_CONTROL_PAYLOAD_BYTES
+    ):
+        return True
+    limits: dict[str, int] = {}
+    preview_limits: dict[str, int] = {}
+    scalar_fields: tuple[str, ...] = ()
+    typed_session_info_scalars = False
+    if event_type == "session.info":
+        limits = _SESSION_INFO_STRING_LIMITS
+        preview_limits = _SESSION_INFO_PREVIEW_LIMITS
+        typed_session_info_scalars = True
+    elif (
+        event_type == "message.complete"
+        or event_type == "error"
+        or event_type.endswith((".error", ".failed"))
+    ):
+        limits = {
+            "status": 256,
+            "text": MAX_TERMINAL_TEXT_BYTES,
+            "reasoning": MAX_TERMINAL_TEXT_BYTES,
+            "error": _MAX_DIAGNOSTIC_BYTES,
+            "failure_reason": _MAX_DIAGNOSTIC_BYTES,
+            "warning": _MAX_DIAGNOSTIC_BYTES,
+            "message": _MAX_DIAGNOSTIC_BYTES,
+        }
+        preview_limits = {"usage": _MAX_SUMMARY_BYTES, "billing": _MAX_SUMMARY_BYTES}
+        scalar_fields = ("recoverable", "partial", "response_previewed")
+    elif event_type == "approval.request" or event_type in {
+        "clarify.request",
+        "clarify.expire",
+    }:
+        limits = {
+            "request_id": _MAX_ROUTE_FIELD_BYTES,
+            "interaction_id": _MAX_ROUTE_FIELD_BYTES,
+            "question": _MAX_CONTROL_STRING_BYTES,
+            "command": _MAX_CONTROL_STRING_BYTES,
+            "reason": _MAX_DIAGNOSTIC_BYTES,
+            "message": _MAX_DIAGNOSTIC_BYTES,
+            "description": _MAX_DIAGNOSTIC_BYTES,
+            "tool": _MAX_ROUTE_FIELD_BYTES,
+            "name": _MAX_ROUTE_FIELD_BYTES,
+        }
+        preview_limits = {
+            "choices": _MAX_PREVIEW_BYTES,
+            "flags": _MAX_PREVIEW_BYTES,
+        }
+        scalar_fields = (
+            "allow_permanent",
+            "smart_denied",
+            "multi_select",
+            "redacted",
+            "all",
+        )
+    elif event_type.startswith("tool."):
+        limits = {
+            "tool_id": _MAX_ROUTE_FIELD_BYTES,
+            "id": _MAX_ROUTE_FIELD_BYTES,
+            "name": _MAX_ROUTE_FIELD_BYTES,
+            "status": 256,
+            "risk": _MAX_ROUTE_FIELD_BYTES,
+            "summary": _MAX_SUMMARY_BYTES,
+            "context": _MAX_SUMMARY_BYTES,
+            "error": _MAX_DIAGNOSTIC_BYTES,
+            "warning": _MAX_DIAGNOSTIC_BYTES,
+            "args_text": _MAX_TOOL_PREVIEW_BYTES,
+            "result_text": _MAX_TOOL_PREVIEW_BYTES,
+            "inline_diff": _MAX_TOOL_PREVIEW_BYTES,
+        }
+        preview_limits = {
+            field_name: _MAX_TOOL_PREVIEW_BYTES
+            for field_name in ("args", "result", "findings", "todos", "metadata")
+        }
+        scalar_fields = ("redacted", "duration_s", "progress")
+    for field_name, limit in limits.items():
+        if field_name not in source:
+            continue
+        value = source[field_name]
+        if not isinstance(value, str):
+            return True
+        if event_type == "session.info":
+            if (
+                field_name in _SESSION_INFO_EXACT_ROUTE_FIELDS
+                and _contains_control_character(value)
+            ):
+                return True
+            if not _json_payload_within_limit(value, limit):
+                return True
+        elif not _bounded_utf8(value, limit):
+            return True
+    if any(
+        field_name in source
+        and not _json_payload_within_limit(source[field_name], limit)
+        for field_name, limit in preview_limits.items()
+    ):
+        return True
+    if typed_session_info_scalars:
+        typed_fields = (
+            *_SESSION_INFO_BOOLEAN_FIELDS,
+            *_SESSION_INFO_INTEGER_FIELDS,
+            *_SESSION_INFO_OPTIONAL_INTEGER_FIELDS,
+        )
+        return any(
+            field_name in source
+            and not _is_session_info_scalar_valid(field_name, source[field_name])
+            for field_name in typed_fields
+        )
+    return any(
+        field_name in source and not _is_safe_json_scalar(source[field_name])
+        for field_name in scalar_fields
+    )
+
+
+class GroupGatewayTransport:
+    """Thread-safe response router and bounded serial event transport."""
+
+    def __init__(
+        self,
+        on_event: EventCallback,
+        *,
+        max_control_frames: int = 128,
+        late_response_ttl: float = 30.0,
+        max_late_responses: int = 128,
+    ) -> None:
+        if not callable(on_event):
+            raise GatewayValidationError("on_event must be callable")
+        if (
+            isinstance(max_control_frames, bool)
+            or not isinstance(max_control_frames, int)
+            or max_control_frames < 1
+        ):
+            raise GatewayValidationError(
+                "max_control_frames must be a positive integer"
+            )
+        if (
+            isinstance(late_response_ttl, bool)
+            or not isinstance(late_response_ttl, (int, float))
+            or not math.isfinite(late_response_ttl)
+            or late_response_ttl <= 0
+        ):
+            raise GatewayValidationError("late_response_ttl must be positive")
+        if (
+            isinstance(max_late_responses, bool)
+            or not isinstance(max_late_responses, int)
+            or max_late_responses < 1
+        ):
+            raise GatewayValidationError(
+                "max_late_responses must be a positive integer"
+            )
+
+        self._on_event = self._normalize_callback(on_event)
+        self._max_queued_frames = max_control_frames
+        self._late_response_ttl = float(late_response_ttl)
+        self._max_late_responses = max_late_responses
+        self._condition = threading.Condition(threading.RLock())
+        self._closed = False
+        self._waiters: dict[str, _ResponseWaiter] = {}
+        self._tombstones: dict[str, _LateResponseTombstone] = {}
+        self._events: deque[_QueuedEvent] = deque()
+        self._callback_active = False
+        self._worker = threading.Thread(
+            target=self._event_worker,
+            name="yaoyao-group-gateway-events",
+            daemon=True,
+        )
+        self._worker.start()
+
+    @staticmethod
+    def _normalize_callback(on_event: EventCallback) -> EventCallback:
+        try:
+            signature = inspect.signature(on_event)
+        except (TypeError, ValueError):
+            return on_event
+        try:
+            signature.bind("runtime", "event.type", {})
+        except TypeError:
+            try:
+                signature.bind("runtime", {"type": "event.type", "payload": {}})
+            except TypeError as exc:
+                raise GatewayValidationError(
+                    "on_event must accept either two or three positional arguments"
+                ) from exc
+
+            def design_callback(
+                runtime_id: str, event_type: str, payload: dict[str, Any]
+            ) -> None:
+                on_event(runtime_id, {"type": event_type, "payload": payload})
+
+            return design_callback
+        return on_event
+
+    @property
+    def closed(self) -> bool:
+        with self._condition:
+            return self._closed
+
+    @property
+    def pending_waiter_count(self) -> int:
+        with self._condition:
+            return len(self._waiters)
+
+    @property
+    def pending_tombstone_count(self) -> int:
+        with self._condition:
+            self._prune_tombstones_locked(time.monotonic())
+            return len(self._tombstones)
+
+    def register_waiter(
+        self,
+        request_id: str,
+        *,
+        method: str = "request",
+        on_late_response: Callable[[str, dict[str, Any]], None] | None = None,
+    ) -> _ResponseWaiter:
+        if on_late_response is not None and not callable(on_late_response):
+            raise GatewayValidationError("on_late_response must be callable")
+        waiter = _ResponseWaiter(
+            request_id=request_id,
+            method=method,
+            on_late_response=on_late_response,
+        )
+        with self._condition:
+            if self._closed:
+                raise GatewayTransportClosed("Gateway transport is closed")
+            self._prune_tombstones_locked(time.monotonic())
+            if request_id in self._waiters or request_id in self._tombstones:
+                raise GatewayProtocolError("request", "duplicate request id")
+            self._waiters[request_id] = waiter
+        return waiter
+
+    def cancel_waiter(self, waiter: _ResponseWaiter) -> None:
+        with self._condition:
+            current = self._waiters.get(waiter.request_id)
+            if current is waiter:
+                del self._waiters[waiter.request_id]
+
+    def wait_for_response(
+        self,
+        waiter: _ResponseWaiter,
+        *,
+        timeout: float,
+        method: str,
+    ) -> dict[str, Any]:
+        if not waiter.event.wait(timeout):
+            with self._condition:
+                current = self._waiters.get(waiter.request_id)
+                if current is waiter:
+                    del self._waiters[waiter.request_id]
+                    if waiter.on_late_response is not None:
+                        self._add_tombstone_locked(waiter, time.monotonic())
+                    raise GatewayTimeoutError(method)
+                # A response or close won the timeout race while this thread
+                # was acquiring the condition. Its waiter state is complete.
+
+        if waiter.error is not None:
+            raise waiter.error
+        if waiter.response is None:
+            raise GatewayProtocolError(
+                method, "response waiter completed without a response"
+            )
+        return waiter.response
+
+    def write(self, obj: dict[str, Any]) -> bool:
+        """Route a core response or event; return false after close."""
+
+        with self._condition:
+            if self._closed:
+                return False
+
+        if not isinstance(obj, dict):
+            return True
+
+        if "id" in obj and obj.get("method") != "event":
+            self._write_response(obj)
+            return True
+        if obj.get("method") == "event":
+            event = self._parse_event(obj)
+            if event is not None:
+                return self._enqueue_event(event)
+        return True
+
+    def _write_response(self, response: dict[str, Any]) -> None:
+        request_id = response.get("id")
+        if not isinstance(request_id, str):
+            return
+        late_callback: Callable[[str, dict[str, Any]], None] | None = None
+        late_method = ""
+        late_response: dict[str, Any] | None = None
+        with self._condition:
+            if self._closed:
+                return
+            self._prune_tombstones_locked(time.monotonic())
+            waiter = self._waiters.pop(request_id, None)
+            if waiter is not None:
+                try:
+                    waiter.response = copy.deepcopy(response)
+                except Exception:  # noqa: BLE001 - malformed core objects are ignored safely
+                    waiter.response = dict(response)
+                waiter.event.set()
+                return
+            tombstone = self._tombstones.pop(request_id, None)
+            if tombstone is not None:
+                late_callback = tombstone.callback
+                late_method = tombstone.method
+                try:
+                    late_response = copy.deepcopy(response)
+                except Exception:  # noqa: BLE001 - malformed late results are ignored
+                    late_response = dict(response)
+
+        if late_callback is not None and late_response is not None:
+            try:
+                late_callback(late_method, late_response)
+            except Exception:  # noqa: BLE001 - never bind core response writers
+                logger.exception("YaoYao group late Gateway response callback failed")
+
+    def _prune_tombstones_locked(self, now: float) -> None:
+        expired = [
+            request_id
+            for request_id, tombstone in self._tombstones.items()
+            if tombstone.expires_at <= now
+        ]
+        for request_id in expired:
+            self._tombstones.pop(request_id, None)
+
+    def _add_tombstone_locked(self, waiter: _ResponseWaiter, now: float) -> None:
+        callback = waiter.on_late_response
+        if callback is None:
+            return
+        self._prune_tombstones_locked(now)
+        while len(self._tombstones) >= self._max_late_responses:
+            oldest = next(iter(self._tombstones))
+            del self._tombstones[oldest]
+            logger.warning("Evicted an expired-session cleanup tombstone at capacity")
+        self._tombstones[waiter.request_id] = _LateResponseTombstone(
+            method=waiter.method,
+            expires_at=now + self._late_response_ttl,
+            callback=callback,
+        )
+        self._condition.notify_all()
+
+    @staticmethod
+    def _parse_event(frame: dict[str, Any]) -> _QueuedEvent | None:
+        params = frame.get("params")
+        if not isinstance(params, dict):
+            return None
+        runtime_id = params.get("session_id")
+        event_type = params.get("type")
+        if not isinstance(runtime_id, str) or not runtime_id:
+            return None
+        if not isinstance(event_type, str) or not event_type:
+            return None
+        if not _bounded_utf8(runtime_id, _MAX_RUNTIME_ID_BYTES) or not _bounded_utf8(
+            event_type, _MAX_EVENT_TYPE_BYTES
+        ):
+            logger.warning("Rejected oversized YaoYao group Gateway event identity")
+            return None
+        raw_payload = params.get("payload", {})
+        if raw_payload is None:
+            raw_payload = {}
+        delta_key: tuple[str, str] | None = None
+        if event_type in _DELTA_EVENTS:
+            if not isinstance(raw_payload, dict):
+                return None
+            text = raw_payload.get("text")
+            if not isinstance(text, str):
+                return None
+            if any(0xD800 <= ord(character) <= 0xDFFF for character in text):
+                logger.warning("Rejected non-UTF-8 Gateway delta text")
+                return None
+            metadata = dict(raw_payload)
+            metadata["text"] = ""
+            if not _json_payload_within_limit(metadata, _MAX_DELTA_METADATA_BYTES):
+                logger.warning("Rejected oversized or non-JSON Gateway delta metadata")
+                return None
+            delta_key = (runtime_id, event_type)
+            try:
+                payload = copy.deepcopy(raw_payload)
+            except Exception:  # noqa: BLE001 - malformed delta objects are ignored safely
+                return None
+        else:
+            inspection = _inspect_json_payload(raw_payload)
+            if inspection.valid and not isinstance(raw_payload, dict):
+                inspection = _JsonInspection(
+                    False,
+                    inspection.encoded_bytes,
+                    "event payload must be a JSON object",
+                )
+            if (
+                isinstance(raw_payload, dict)
+                and inspection.valid
+                and not _control_requires_projection(
+                    event_type,
+                    raw_payload,
+                    inspection,
+                )
+            ):
+                try:
+                    payload = copy.deepcopy(raw_payload)
+                except Exception:  # noqa: BLE001 - deliver an invalid sentinel instead
+                    payload = _control_payload_sentinel(
+                        _JsonInspection(False, 0, "payload copy failed")
+                    )
+            else:
+                payload = _sanitize_control_payload(
+                    event_type,
+                    raw_payload,
+                    inspection,
+                )
+        return _QueuedEvent(runtime_id, event_type, payload, delta_key)
+
+    def _enqueue_event(self, event: _QueuedEvent) -> bool:
+        if event.delta_key is not None:
+            return self._enqueue_delta(event)
+        return self._enqueue_control(event)
+
+    def _enqueue_delta(self, event: _QueuedEvent) -> bool:
+        source_text = event.payload["text"]
+        for chunk, chunk_bytes in _utf8_chunks(source_text, MAX_DELTA_BUFFER_BYTES):
+            payload = dict(event.payload)
+            payload["text"] = chunk
+            queued = _QueuedEvent(
+                event.runtime_id,
+                event.event_type,
+                payload,
+                event.delta_key,
+                chunk_bytes,
+            )
+            with self._condition:
+                while True:
+                    if self._closed:
+                        return False
+                    previous = self._events[-1] if self._events else None
+                    if (
+                        previous is not None
+                        and previous.delta_key == queued.delta_key
+                        and previous.delta_bytes + queued.delta_bytes
+                        <= MAX_DELTA_BUFFER_BYTES
+                    ):
+                        merged_text = previous.payload["text"] + queued.payload["text"]
+                        previous.payload.update(queued.payload)
+                        previous.payload["text"] = merged_text
+                        previous.delta_bytes += queued.delta_bytes
+                        break
+                    if len(self._events) < self._max_queued_frames:
+                        self._events.append(queued)
+                        self._condition.notify_all()
+                        break
+                    self._condition.wait()
+        return True
+
+    def _enqueue_control(self, event: _QueuedEvent) -> bool:
+        with self._condition:
+            if self._closed:
+                return False
+            while len(self._events) >= self._max_queued_frames and not self._closed:
+                self._condition.wait()
+            if self._closed:
+                return False
+            self._events.append(event)
+            self._condition.notify_all()
+            return True
+
+    def _event_worker(self) -> None:
+        while True:
+            with self._condition:
+                while not self._events and not self._closed:
+                    now = time.monotonic()
+                    self._prune_tombstones_locked(now)
+                    if self._tombstones:
+                        next_expiry = min(
+                            tombstone.expires_at
+                            for tombstone in self._tombstones.values()
+                        )
+                        self._condition.wait(max(0.0, next_expiry - now))
+                    else:
+                        self._condition.wait()
+                if self._closed:
+                    return
+                event = self._events.popleft()
+                self._callback_active = True
+                self._condition.notify_all()
+
+            try:
+                self._on_event(event.runtime_id, event.event_type, event.payload)
+            except Exception:  # noqa: BLE001 - isolate plugin consumer failures
+                logger.exception("YaoYao group Gateway event callback failed")
+            finally:
+                with self._condition:
+                    self._callback_active = False
+                    self._condition.notify_all()
+
+    def drain(self, timeout: float | None = None) -> bool:
+        """Wait until queued callbacks finish; intended for orderly shutdown/tests."""
+
+        if timeout is not None and timeout < 0:
+            raise GatewayValidationError("drain timeout must be non-negative")
+        deadline = None if timeout is None else time.monotonic() + timeout
+        with self._condition:
+            if threading.current_thread() is self._worker:
+                return not self._events
+            while self._events or self._callback_active:
+                if deadline is None:
+                    self._condition.wait()
+                    continue
+                remaining = deadline - time.monotonic()
+                if remaining <= 0:
+                    return False
+                self._condition.wait(remaining)
+            return True
+
+    def close(self) -> None:
+        waiters: list[_ResponseWaiter]
+        with self._condition:
+            if not self._closed:
+                self._closed = True
+                waiters = list(self._waiters.values())
+                self._waiters.clear()
+                self._tombstones.clear()
+                self._events.clear()
+                for waiter in waiters:
+                    waiter.error = GatewayTransportClosed("Gateway transport is closed")
+                    waiter.event.set()
+                self._condition.notify_all()
+
+        if threading.current_thread() is not self._worker:
+            self._worker.join()
+
+
+def _default_dispatcher(
+    request: dict[str, Any],
+    transport: GroupGatewayTransport,
+) -> dict[str, Any] | None:
+    # tui_gateway.server is intentionally private and expensive. Keep the import
+    # inside this one boundary and defer it until the first actual request.
+    from tui_gateway.server import dispatch
+
+    return dispatch(request, transport)
+
+
+def _nonempty_string(value: Any, field_name: str) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise GatewayValidationError(f"{field_name} must be a non-empty string")
+    return value
+
+
+def _response_string(result: dict[str, Any], field_name: str, method: str) -> str:
+    value = result.get(field_name)
+    if not isinstance(value, str) or not value:
+        raise GatewayProtocolError(method, f"missing or invalid {field_name}")
+    return value
+
+
+def _response_bool(
+    result: dict[str, Any],
+    field_name: str,
+    method: str,
+    *,
+    default: bool | None = None,
+) -> bool:
+    if field_name not in result and default is not None:
+        return default
+    value = result.get(field_name)
+    if not isinstance(value, bool):
+        raise GatewayProtocolError(method, f"missing or invalid {field_name}")
+    return value
+
+
+class GroupGatewayAdapter:
+    """Stable plugin facade over ``tui_gateway.server.dispatch``."""
+
+    def __init__(
+        self,
+        *,
+        dispatcher: Dispatcher | None = None,
+        on_event: EventCallback | None = None,
+        request_timeout: float = 30.0,
+        max_control_frames: int = 128,
+        late_response_ttl: float = 30.0,
+        max_late_responses: int = 128,
+    ) -> None:
+        if dispatcher is not None and not callable(dispatcher):
+            raise GatewayValidationError("dispatcher must be callable")
+        if (
+            isinstance(request_timeout, bool)
+            or not isinstance(request_timeout, (int, float))
+            or not math.isfinite(request_timeout)
+            or request_timeout <= 0
+        ):
+            raise GatewayValidationError("request_timeout must be positive")
+        self._dispatcher = _default_dispatcher if dispatcher is None else dispatcher
+        self._request_timeout = float(request_timeout)
+        self._dispatch_condition = threading.Condition(threading.RLock())
+        self._lifecycle_state = "open"
+        self._dispatch_inflight = 0
+        self._cleanup_timeout = min(self._request_timeout, 5.0)
+        self._cleanup_condition = threading.Condition(threading.RLock())
+        self._cleanup_queue: deque[tuple[str, int]] = deque()
+        self._cleanup_pending: set[str] = set()
+        self._cleanup_claimed: set[str] = set()
+        self._cleanup_active: str | None = None
+        self._adoption_generation = 0
+        self._cleanup_closed = False
+        self._max_cleanup_queue = max_late_responses
+        self.transport = GroupGatewayTransport(
+            on_event
+            if on_event is not None
+            else (lambda _runtime_id, _event_type, _payload: None),
+            max_control_frames=max_control_frames,
+            late_response_ttl=late_response_ttl,
+            max_late_responses=max_late_responses,
+        )
+        self._cleanup_worker = threading.Thread(
+            target=self._late_cleanup_worker,
+            name="yaoyao-group-gateway-late-cleanup",
+            daemon=True,
+        )
+        self._cleanup_worker.start()
+
+    def _call_dispatcher(
+        self,
+        request: dict[str, Any],
+        transport: GroupGatewayTransport,
+    ) -> dict[str, Any] | None:
+        with self._dispatch_condition:
+            if self._lifecycle_state != "open":
+                raise GatewayTransportClosed("Gateway adapter is shutting down")
+            self._dispatch_inflight += 1
+        try:
+            return self._dispatcher(request, transport)
+        finally:
+            with self._dispatch_condition:
+                self._dispatch_inflight -= 1
+                self._dispatch_condition.notify_all()
+
+    def request(
+        self,
+        method: str,
+        params: dict[str, Any],
+        timeout: float | None = None,
+    ) -> Any:
+        method = _nonempty_string(method, "method")
+        if not isinstance(params, dict):
+            raise GatewayValidationError("params must be an object")
+        effective_timeout = self._request_timeout if timeout is None else timeout
+        if (
+            isinstance(effective_timeout, bool)
+            or not isinstance(effective_timeout, (int, float))
+            or not math.isfinite(effective_timeout)
+            or effective_timeout <= 0
+        ):
+            raise GatewayValidationError("timeout must be positive")
+
+        try:
+            request_params = copy.deepcopy(params)
+        except Exception:  # noqa: BLE001 - reject caller objects at the boundary
+            raise GatewayValidationError(
+                "params must contain copyable values"
+            ) from None
+
+        request_id = str(uuid.uuid4())
+        request = {
+            "jsonrpc": "2.0",
+            "id": request_id,
+            "method": method,
+            "params": request_params,
+        }
+        late_context = self._late_session_context(request_id, method, request_params)
+        late_callback = (
+            partial(self._handle_late_session_response, late_context)
+            if late_context is not None
+            else None
+        )
+
+        waiter = self.transport.register_waiter(
+            request_id,
+            method=method,
+            on_late_response=late_callback,
+        )
+        try:
+            inline_response = self._call_dispatcher(request, self.transport)
+        except GatewayTransportClosed:
+            self.transport.cancel_waiter(waiter)
+            raise
+        except Exception as exc:
+            self.transport.cancel_waiter(waiter)
+            raise GatewayDispatchError(method) from exc
+
+        if inline_response is not None:
+            if not isinstance(inline_response, dict):
+                self.transport.cancel_waiter(waiter)
+                raise GatewayProtocolError(
+                    method, "dispatcher returned a non-object response"
+                )
+            if (
+                inline_response.get("jsonrpc") != "2.0"
+                or inline_response.get("id") != request_id
+                or "method" in inline_response
+            ):
+                self.transport.cancel_waiter(waiter)
+                raise GatewayProtocolError(
+                    method, "dispatcher returned an unrelated or invalid response"
+                )
+            accepted = self.transport.write(inline_response)
+            if not accepted:
+                # close() has already completed the registered waiter.
+                return self._parse_response(
+                    self.transport.wait_for_response(
+                        waiter,
+                        timeout=float(effective_timeout),
+                        method=method,
+                    ),
+                    request_id,
+                    method,
+                )
+
+        response = self.transport.wait_for_response(
+            waiter,
+            timeout=float(effective_timeout),
+            method=method,
+        )
+        return self._parse_response(response, request_id, method)
+
+    @staticmethod
+    def _parse_response(
+        response: dict[str, Any],
+        request_id: str,
+        method: str,
+    ) -> Any:
+        if response.get("jsonrpc") != "2.0" or response.get("id") != request_id:
+            raise GatewayProtocolError(method, "invalid JSON-RPC response envelope")
+        has_result = "result" in response
+        has_error = "error" in response
+        if has_result == has_error:
+            raise GatewayProtocolError(
+                method, "response must contain exactly one of result or error"
+            )
+        if has_error:
+            error = response["error"]
+            if not isinstance(error, dict):
+                raise GatewayProtocolError(method, "invalid JSON-RPC error")
+            code = error.get("code")
+            message = error.get("message")
+            if (
+                isinstance(code, bool)
+                or not isinstance(code, int)
+                or not isinstance(message, str)
+            ):
+                raise GatewayProtocolError(method, "invalid JSON-RPC error")
+            raise GatewayRPCError(method, code, message, error.get("data"))
+        return response["result"]
+
+    @staticmethod
+    def _result_object(result: Any, method: str) -> dict[str, Any]:
+        if not isinstance(result, dict):
+            raise GatewayProtocolError(method, "result must be an object")
+        return result
+
+    def _late_session_context(
+        self,
+        request_id: str,
+        method: str,
+        params: dict[str, Any],
+    ) -> _LateSessionContext | None:
+        if method not in {"session.create", "session.resume"}:
+            return None
+        profile = params.get("profile")
+        if not isinstance(profile, str) or not profile:
+            return None
+        requested_stored_id: str | None = None
+        if method == "session.resume":
+            requested_stored_id = params.get("session_id")
+            if not isinstance(requested_stored_id, str) or not requested_stored_id:
+                return None
+        with self._cleanup_condition:
+            adoption_generation = self._adoption_generation
+        return _LateSessionContext(
+            request_id=request_id,
+            method=method,
+            profile=profile,
+            requested_stored_id=requested_stored_id,
+            adoption_generation=adoption_generation,
+        )
+
+    def _handle_late_session_response(
+        self,
+        context: _LateSessionContext,
+        tombstone_method: str,
+        response: dict[str, Any],
+    ) -> None:
+        if tombstone_method != context.method:
+            return
+        # session.resume may legitimately reuse a runtime that another request or
+        # device already owns. Its response has no core-authenticated "created"
+        # bit, so a timeout can never prove that the returned runtime is an orphan.
+        if context.method == "session.resume":
+            return
+        if (
+            response.get("jsonrpc") != "2.0"
+            or response.get("id") != context.request_id
+            or "method" in response
+            or ("result" in response) == ("error" in response)
+        ):
+            return
+        result = response.get("result")
+        if not isinstance(result, dict):
+            return
+        runtime_id = result.get("session_id")
+        if not isinstance(runtime_id, str) or not runtime_id:
+            return
+        info = result.get("info")
+        if not isinstance(info, dict):
+            return
+        profile_name = info.get("profile_name")
+        if profile_name != context.profile:
+            return
+        stored_id = result.get("stored_session_id")
+        if not isinstance(stored_id, str) or not stored_id:
+            return
+        running = result.get("running", False)
+        # A running runtime may already be serving another device. Cleanup is
+        # deliberately skipped even though that can leave a timed-out create
+        # behind; leaking an uncertain orphan is safer than killing active work.
+        if not isinstance(running, bool) or running:
+            return
+        self._schedule_late_cleanup(runtime_id, context.adoption_generation)
+
+    def _schedule_late_cleanup(
+        self, runtime_id: str, adoption_generation: int
+    ) -> None:
+        with self._cleanup_condition:
+            if (
+                self._cleanup_closed
+                or adoption_generation != self._adoption_generation
+                or runtime_id in self._cleanup_claimed
+                or runtime_id in self._cleanup_pending
+                or len(self._cleanup_claimed) >= self._max_cleanup_queue
+            ):
+                return
+            while (
+                len(self._cleanup_queue) >= self._max_cleanup_queue
+                and not self._cleanup_closed
+            ):
+                self._cleanup_condition.wait()
+            if (
+                self._cleanup_closed
+                or adoption_generation != self._adoption_generation
+                or runtime_id in self._cleanup_claimed
+                or runtime_id in self._cleanup_pending
+                or len(self._cleanup_claimed) >= self._max_cleanup_queue
+            ):
+                return
+            self._cleanup_pending.add(runtime_id)
+            self._cleanup_queue.append((runtime_id, adoption_generation))
+            self._cleanup_condition.notify_all()
+
+    def _adopt_runtime(self, runtime_id: str, method: str) -> None:
+        """Atomically adopt a runtime or reject one already claimed for cleanup."""
+
+        with self._cleanup_condition:
+            if self._cleanup_closed:
+                raise GatewayTransportClosed("Gateway adapter is shutting down")
+            if runtime_id in self._cleanup_claimed:
+                raise GatewayProtocolError(
+                    method, "runtime cleanup won the adoption race"
+                )
+            self._adoption_generation += 1
+            queued_runtime_ids = {
+                queued_runtime_id for queued_runtime_id, _generation in self._cleanup_queue
+            }
+            self._cleanup_queue.clear()
+            self._cleanup_pending.difference_update(queued_runtime_ids)
+            self._cleanup_condition.notify_all()
+
+    def _late_cleanup_worker(self) -> None:
+        while True:
+            with self._cleanup_condition:
+                while not self._cleanup_queue and not self._cleanup_closed:
+                    self._cleanup_condition.wait()
+                if self._cleanup_closed:
+                    return
+                runtime_id, adoption_generation = self._cleanup_queue.popleft()
+                if (
+                    adoption_generation != self._adoption_generation
+                    or runtime_id in self._cleanup_claimed
+                    or len(self._cleanup_claimed) >= self._max_cleanup_queue
+                ):
+                    self._cleanup_pending.discard(runtime_id)
+                    self._cleanup_condition.notify_all()
+                    continue
+                # Claimed IDs are terminal for this adapter's lifetime. A core
+                # dispatcher can remain blocked for an arbitrary duration, so
+                # expiring this proof would allow an ABA adoption of a runtime
+                # already handed to session.close. The hard capacity above
+                # bounds memory; once full, later orphans are leaked safely.
+                self._cleanup_claimed.add(runtime_id)
+                self._cleanup_active = runtime_id
+                self._cleanup_condition.notify_all()
+            try:
+                self._best_effort_close_late_session(runtime_id)
+            finally:
+                with self._cleanup_condition:
+                    self._cleanup_active = None
+                    self._cleanup_pending.discard(runtime_id)
+                    self._cleanup_condition.notify_all()
+
+    def _best_effort_close_late_session(self, runtime_id: str) -> None:
+        method = "session.close"
+        request_id = str(uuid.uuid4())
+        cleanup_transport = GroupGatewayTransport(
+            lambda _runtime_id, _event_type, _payload: None,
+            max_control_frames=1,
+            late_response_ttl=max(self._cleanup_timeout, 0.001),
+            max_late_responses=1,
+        )
+        waiter = cleanup_transport.register_waiter(request_id, method=method)
+        request = {
+            "jsonrpc": "2.0",
+            "id": request_id,
+            "method": method,
+            "params": {"session_id": runtime_id},
+        }
+        try:
+            inline_response = self._call_dispatcher(request, cleanup_transport)
+            if inline_response is not None:
+                if (
+                    not isinstance(inline_response, dict)
+                    or inline_response.get("jsonrpc") != "2.0"
+                    or inline_response.get("id") != request_id
+                    or "method" in inline_response
+                ):
+                    raise GatewayProtocolError(
+                        method, "cleanup dispatcher returned an invalid response"
+                    )
+                cleanup_transport.write(inline_response)
+            response = cleanup_transport.wait_for_response(
+                waiter,
+                timeout=self._cleanup_timeout,
+                method=method,
+            )
+            result = self._result_object(
+                self._parse_response(response, request_id, method), method
+            )
+            _response_bool(result, "closed", method)
+        except Exception:  # noqa: BLE001 - cleanup is deliberately best-effort
+            logger.warning(
+                "Best-effort close of a late YaoYao group session failed",
+                exc_info=True,
+            )
+        finally:
+            cleanup_transport.close()
+
+    @staticmethod
+    def _cwd(value: str) -> str:
+        if not isinstance(value, str):
+            raise GatewayValidationError("cwd must be a string")
+        if not value.strip():
+            return ""
+        path = Path(value)
+        if not path.is_absolute():
+            raise GatewayValidationError(
+                "cwd must be empty or an existing absolute directory"
+            )
+        try:
+            resolved = path.resolve(strict=True)
+            is_directory = resolved.is_dir()
+        except (OSError, RuntimeError, ValueError) as exc:
+            raise GatewayValidationError(
+                "cwd must be empty or an existing absolute directory"
+            ) from exc
+        if not is_directory:
+            raise GatewayValidationError(
+                "cwd must be empty or an existing absolute directory"
+            )
+        return value
+
+    def create_session(
+        self,
+        profile: str,
+        title: str,
+        cwd: str,
+        seed_messages: list[dict[str, Any]],
+        configuration: dict[str, Any] | None = None,
+    ) -> SessionIdentity:
+        method = "session.create"
+        profile = _nonempty_string(profile, "profile")
+        title = _nonempty_string(title, "title")
+        if not isinstance(seed_messages, list) or not all(
+            isinstance(message, dict) for message in seed_messages
+        ):
+            raise GatewayValidationError("seed_messages must be a list of objects")
+        parameters: dict[str, Any] = {
+            "profile": profile,
+            "title": title,
+            "cwd": self._cwd(cwd),
+            "messages": seed_messages,
+            "source": "ios_group",
+            "close_on_disconnect": False,
+        }
+        for key in ("model", "provider", "reasoning_effort", "fast"):
+            if configuration is not None and configuration.get(key) is not None:
+                parameters[key] = configuration[key]
+        result = self._result_object(
+            self.request(
+                method,
+                parameters,
+            ),
+            method,
+        )
+        runtime_id = _response_string(result, "session_id", method)
+        stored_id = _response_string(result, "stored_session_id", method)
+        running = _response_bool(result, "running", method, default=False)
+        info = result.get("info")
+        if not isinstance(info, dict):
+            raise GatewayProtocolError(method, "missing or invalid info.profile_name")
+        response_profile = info.get("profile_name")
+        if not isinstance(response_profile, str) or not response_profile:
+            raise GatewayProtocolError(method, "missing or invalid info.profile_name")
+        if response_profile != profile:
+            raise GatewayProtocolError(method, "profile does not match the request")
+        self._adopt_runtime(runtime_id, method)
+        return SessionIdentity(stored_id, runtime_id, running)
+
+    def resume_session(self, profile: str, stored_id: str) -> SessionIdentity:
+        method = "session.resume"
+        profile = _nonempty_string(profile, "profile")
+        stored_id = _nonempty_string(stored_id, "stored_id")
+        result = self._result_object(
+            self.request(
+                method,
+                {
+                    "profile": profile,
+                    "session_id": stored_id,
+                    "omit_messages": True,
+                    "source": "ios_group",
+                    "close_on_disconnect": False,
+                },
+            ),
+            method,
+        )
+        runtime_id = _response_string(result, "session_id", method)
+        session_key = result.get("session_key")
+        resumed = result.get("resumed")
+        if session_key is not None and (
+            not isinstance(session_key, str) or not session_key
+        ):
+            raise GatewayProtocolError(method, "missing or invalid session_key")
+        if resumed is not None and (not isinstance(resumed, str) or not resumed):
+            raise GatewayProtocolError(method, "missing or invalid resumed")
+        if session_key is not None and resumed is not None and session_key != resumed:
+            raise GatewayProtocolError(method, "ambiguous stored session identity")
+        current_stored_id = session_key or resumed
+        if current_stored_id is None:
+            raise GatewayProtocolError(method, "missing session_key or resumed")
+        running = _response_bool(result, "running", method)
+        info = result.get("info")
+        if not isinstance(info, dict):
+            raise GatewayProtocolError(method, "missing or invalid info.profile_name")
+        response_profile = info.get("profile_name")
+        if not isinstance(response_profile, str) or not response_profile:
+            raise GatewayProtocolError(method, "missing or invalid info.profile_name")
+        if response_profile != profile:
+            raise GatewayProtocolError(method, "profile does not match the request")
+        self._adopt_runtime(runtime_id, method)
+        return SessionIdentity(current_stored_id, runtime_id, running)
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None:
+        method = "prompt.submit"
+        result = self._result_object(
+            self.request(
+                method,
+                {
+                    "session_id": _nonempty_string(runtime_id, "runtime_id"),
+                    "text": _nonempty_string(text, "text"),
+                },
+            ),
+            method,
+        )
+        if result.get("status") != "streaming":
+            raise GatewayProtocolError(method, "expected streaming status")
+
+    def interrupt_session(self, runtime_id: str) -> None:
+        method = "session.interrupt"
+        result = self._result_object(
+            self.request(
+                method, {"session_id": _nonempty_string(runtime_id, "runtime_id")}
+            ),
+            method,
+        )
+        if result.get("status") != "interrupted":
+            raise GatewayProtocolError(method, "expected interrupted status")
+
+    def interrupt(self, runtime_id: str) -> None:
+        self.interrupt_session(runtime_id)
+
+    def close_session(self, runtime_id: str) -> bool:
+        method = "session.close"
+        result = self._result_object(
+            self.request(
+                method, {"session_id": _nonempty_string(runtime_id, "runtime_id")}
+            ),
+            method,
+        )
+        return _response_bool(result, "closed", method)
+
+    def respond_approval(self, runtime_id: str, choice: str) -> int:
+        method = "approval.respond"
+        runtime_id = _nonempty_string(runtime_id, "runtime_id")
+        choice = _nonempty_string(choice, "choice")
+        if choice not in _APPROVAL_CHOICES:
+            raise GatewayValidationError(
+                "choice must be once, session, always, or deny"
+            )
+        result = self._result_object(
+            self.request(
+                method,
+                {"session_id": runtime_id, "choice": choice, "all": False},
+            ),
+            method,
+        )
+        resolved = result.get("resolved")
+        if isinstance(resolved, bool) or not isinstance(resolved, int) or resolved < 0:
+            raise GatewayProtocolError(method, "missing or invalid resolved count")
+        return resolved
+
+    def respond_clarification(self, request_id: str, answer: str) -> str:
+        method = "clarify.respond"
+        result = self._result_object(
+            self.request(
+                method,
+                {
+                    "request_id": _nonempty_string(request_id, "request_id"),
+                    "answer": _nonempty_string(answer, "answer"),
+                },
+            ),
+            method,
+        )
+        status = result.get("status")
+        if status not in {"ok", "expired"}:
+            raise GatewayProtocolError(method, "expected ok or expired status")
+        return status
+
+    def shutdown(self) -> None:
+        with self._dispatch_condition:
+            if self._lifecycle_state == "closed":
+                return
+            if self._lifecycle_state == "closing":
+                if threading.current_thread() in {
+                    self.transport._worker,
+                    self._cleanup_worker,
+                }:
+                    return
+                while self._lifecycle_state != "closed":
+                    self._dispatch_condition.wait()
+                return
+            self._lifecycle_state = "closing"
+
+        with self._cleanup_condition:
+            if not self._cleanup_closed:
+                self._cleanup_closed = True
+                self._cleanup_queue.clear()
+                if self._cleanup_active is None:
+                    self._cleanup_pending.clear()
+                else:
+                    self._cleanup_pending.intersection_update(
+                        {self._cleanup_active}
+                    )
+                self._cleanup_condition.notify_all()
+        self.transport.close()
+
+        with self._dispatch_condition:
+            while self._dispatch_inflight:
+                self._dispatch_condition.wait()
+        if threading.current_thread() is not self._cleanup_worker:
+            self._cleanup_worker.join()
+        with self._cleanup_condition:
+            self._cleanup_claimed.clear()
+            self._cleanup_active = None
+        with self._dispatch_condition:
+            self._lifecycle_state = "closed"
+            self._dispatch_condition.notify_all()
+
+    def close(self, runtime_id: str) -> bool:
+        """Close one core session; ``shutdown`` owns transport lifecycle."""
+
+        return self.close_session(runtime_id)
+
+
+# The shorter name is the stable design-level spelling used by the orchestrator.
+GroupTransport = GroupGatewayTransport
+
+
+__all__ = [
+    "GatewayDispatchError",
+    "GatewayError",
+    "GatewayProtocolError",
+    "GatewayRPCError",
+    "GatewayTimeoutError",
+    "GatewayTransportClosed",
+    "GatewayValidationError",
+    "GroupGatewayAdapter",
+    "GroupGatewayTransport",
+    "GroupTransport",
+    "MAX_CONTROL_PAYLOAD_BYTES",
+    "MAX_DELTA_BUFFER_BYTES",
+    "MAX_SAFE_JSON_INTEGER",
+    "MAX_TERMINAL_TEXT_BYTES",
+    "SessionIdentity",
+]

+ 3470 - 0
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -0,0 +1,3470 @@
+"""Async scheduler for durable YaoYao group Agent runs.
+
+The store is the concurrency authority.  This module only owns process-local
+Gateway generations and moves every blocking SQLite/Gateway call off the
+Dashboard event loop.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import AsyncExitStack, asynccontextmanager
+from dataclasses import dataclass, field
+import json
+import logging
+import math
+import re
+import threading
+import uuid
+from typing import Any, Awaitable, Callable, Mapping, Protocol
+
+from .group_gateway import GroupGatewayAdapter, SessionIdentity
+from .group_protocol import MAX_MESSAGE_BYTES, MAX_TOOL_STATE_BYTES
+
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_EVENT_QUEUE_SIZE = 256
+DEFAULT_COMPLETION_TIMEOUT = 15 * 60.0
+STREAM_FLUSH_INTERVAL = 0.05
+_ERROR_TEXT_LIMIT = 4096
+_TOOL_VALUE_LIMIT = 16 * 1024
+_TOOL_TEXT_LIMIT = 4096
+_TOOL_FINDING_LIMIT = 32
+_TOOL_COUNT_LIMIT = 128
+_TOOL_TRUNCATION_ID = "__hermes_group_tool_state_truncated__"
+_TOOL_STATE_INTERNAL_LIMIT = min(MAX_TOOL_STATE_BYTES, 192 * 1024)
+_PERSISTED_EVENTS = frozenset(
+    {
+        "message.start",
+        "message.delta",
+        "message.complete",
+        "reasoning.delta",
+        "tool.start",
+        "tool.complete",
+        "tool.output_risk",
+        "approval.request",
+        "clarify.request",
+        "clarify.expire",
+        "session.info",
+        "error",
+    }
+)
+_TERMINAL_RUN_STATUSES = frozenset({"completed", "failed", "interrupted"})
+_APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
+_STORE_RETRY_DELAYS = (0.01, 0.02, 0.04)
+_CLAIM_RETRY_MAX_SECONDS = 1.0
+_WORK_DISABLED_RETRY_SECONDS = 1.0
+_NO_REPLY_TOKEN = "[[YAOYAO_NO_REPLY_V1]]"
+_NO_REPLY_RESERVED_PATTERN = re.compile(r"\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?")
+
+
+def _is_no_reply_content(value: object) -> bool:
+    return isinstance(value, str) and value.strip() == _NO_REPLY_TOKEN
+
+
+def _sanitize_no_reply_text(value: str) -> str:
+    return _NO_REPLY_RESERVED_PATTERN.sub("", value)
+
+
+def _log_failure_type(message: str, error: BaseException) -> None:
+    """Log stable context without persisting Gateway or Store exception text."""
+
+    logger.error("%s [errorType=%s]", message, type(error).__name__)
+
+
+class GroupOrchestratorError(RuntimeError):
+    """Raised for an invalid scheduler lifecycle or local runtime collision."""
+
+
+class _Store(Protocol):
+    def initialize(self) -> None: ...
+
+    def recover_after_restart(self) -> list[str]: ...
+
+    def claim_next_runnable_run(self) -> dict[str, object] | None: ...
+
+    def read_run_projection(self, run_id: str) -> dict[str, object]: ...
+
+    def prepare_run_session_configuration(
+        self, run_id: str, configuration: Mapping[str, object]
+    ) -> bool: ...
+
+    def bind_run_runtime(
+        self, run_id: str, runtime_session_id: str
+    ) -> dict[str, object]: ...
+
+    def commit_prompt_submission(
+        self, run_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def upsert_agent_message(
+        self, run_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def settle_run(self, run_id: str, **kwargs: object) -> dict[str, object]: ...
+
+    def transition_run(
+        self, run_id: str, status: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def get_run(self, run_id: str) -> dict[str, object]: ...
+
+    def get_message(self, message_id: str) -> dict[str, object]: ...
+
+    def get_room(self, room_id: str) -> dict[str, object]: ...
+
+    def get_interaction(self, interaction_id: str) -> dict[str, object]: ...
+
+    def list_pending_cascades(self, **kwargs: object) -> dict[str, object]: ...
+
+    def complete_cascade(self, source_run_id: str) -> dict[str, object]: ...
+
+    def interrupt_agent_with_runtime_targets(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
+    def archive_room_with_runtime_targets(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
+    def create_room(self, command: Mapping[str, object]) -> dict[str, object]: ...
+
+    def update_room(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
+    def add_agent(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]: ...
+
+    def update_agent_with_runtime_targets(
+        self,
+        room_id: str,
+        agent_id: str,
+        command: Mapping[str, object],
+    ) -> dict[str, object]: ...
+
+    def delete_agent_with_runtime_targets(
+        self,
+        room_id: str,
+        agent_id: str,
+        command: Mapping[str, object],
+    ) -> dict[str, object]: ...
+
+    def create_human_message(
+        self,
+        room_id: str,
+        *,
+        request_id: str,
+        client_message_id: str,
+        content: str,
+        mention_agent_ids: list[str],
+    ) -> dict[str, object]: ...
+
+    def create_gateway_interaction(
+        self, run_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def expire_interaction(self, interaction_id: str) -> dict[str, object]: ...
+
+    def begin_interaction_response(
+        self, room_id: str, interaction_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def finish_interaction_response(
+        self, request_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+    def fail_interaction_response(
+        self, request_id: str, **kwargs: object
+    ) -> dict[str, object]: ...
+
+
+class _Gateway(Protocol):
+    transport: Any
+
+    def create_session(
+        self,
+        profile: str,
+        title: str,
+        cwd: str,
+        seed_messages: list[dict[str, Any]],
+        configuration: dict[str, Any] | None = None,
+    ) -> SessionIdentity: ...
+
+    def resume_session(self, profile: str, stored_id: str) -> SessionIdentity: ...
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None: ...
+
+    def interrupt(self, runtime_id: str) -> None: ...
+
+    def close(self, runtime_id: str) -> bool: ...
+
+    def respond_approval(self, runtime_id: str, choice: str) -> int: ...
+
+    def respond_clarification(self, request_id: str, answer: str) -> str: ...
+
+    def shutdown(self) -> None: ...
+
+
+GatewayFactory = Callable[[Callable[[str, str, dict[str, object]], None]], _Gateway]
+
+
+@dataclass(frozen=True)
+class _GatewayEvent:
+    runtime_id: str
+    generation: int
+    event_type: str
+    payload: dict[str, object]
+
+
+@dataclass
+class _RuntimeState:
+    run_id: str
+    room_id: str
+    agent_id: str
+    runtime_id: str
+    generation: int
+    expected_stored_id: str | None
+    session_stored_id: str
+    expected_context_seq: int
+    through_seq: int
+    profile: str = ""
+    reply_mode: str = "mentioned"
+    automatic_published: bool = False
+    prompt_ready: asyncio.Event = field(default_factory=asyncio.Event)
+    finished: asyncio.Event = field(default_factory=asyncio.Event)
+    terminal_info_received: asyncio.Event = field(default_factory=asyncio.Event)
+    finalize_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+    mutation_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+    pending_events: asyncio.Queue[_GatewayEvent | None] = field(
+        default_factory=lambda: asyncio.Queue(maxsize=64)
+    )
+    event_task: asyncio.Task[None] | None = None
+    grace_task: asyncio.Task[None] | None = None
+    inflight_calls: set[asyncio.Task[object]] = field(default_factory=set)
+    mutation_tasks: set[asyncio.Task[dict[str, object]]] = field(default_factory=set)
+    content: str = ""
+    reasoning: str = ""
+    tool_state: list[dict[str, object]] = field(default_factory=list)
+    tool_indexes: dict[str, int] = field(default_factory=dict)
+    clarification_ids: dict[str, str] = field(default_factory=dict)
+    pending_interaction_ids: set[str] = field(default_factory=set)
+    stream_dirty: bool = False
+    last_stream_flush: float = 0.0
+    approval_ordinal: int = 0
+    complete_seen: bool = False
+    complete_index: int = 0
+    event_index: int = 0
+    terminal_status: str | None = None
+    terminal_error: str = ""
+    settle_error: BaseException | None = None
+    prompt_committed: bool = False
+    overflowed: bool = False
+    finalizing: bool = False
+
+
+@dataclass
+class _AgentLockState:
+    lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+    users: int = 0
+
+
+def _required_mapping(value: object, field_name: str) -> Mapping[str, object]:
+    if not isinstance(value, Mapping):
+        raise GroupOrchestratorError(f"{field_name} must be an object")
+    return value
+
+
+def _required_string(value: object, field_name: str) -> str:
+    if not isinstance(value, str) or not value:
+        raise GroupOrchestratorError(f"{field_name} must be a non-empty string")
+    return value
+
+
+def _required_string_allowing_empty(value: object, field_name: str) -> str:
+    if not isinstance(value, str):
+        raise GroupOrchestratorError(f"{field_name} must be a string")
+    return value
+
+
+def _optional_string(value: object, field_name: str) -> str | None:
+    if value is None:
+        return None
+    return _required_string(value, field_name)
+
+
+def _required_nonnegative_int(value: object, field_name: str) -> int:
+    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+        raise GroupOrchestratorError(f"{field_name} must be a non-negative integer")
+    return value
+
+
+def _strict_json_copy(value: object, field_name: str) -> object:
+    try:
+        encoded = json.dumps(
+            value,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+            allow_nan=False,
+        )
+        encoded.encode("utf-8")
+        return json.loads(encoded)
+    except (TypeError, ValueError, UnicodeEncodeError, json.JSONDecodeError) as error:
+        raise GroupOrchestratorError(f"{field_name} must be strict JSON") from error
+
+
+def build_run_prompt(
+    projection: Mapping[str, object],
+    room_agents: list[Mapping[str, object]] | None = None,
+) -> str:
+    """Build a deterministic prompt from the Store's bounded projection."""
+
+    run = _required_mapping(projection.get("run"), "projection.run")
+    room = _required_mapping(projection.get("room"), "projection.room")
+    agent = _required_mapping(projection.get("agent"), "projection.agent")
+    messages = projection.get("messages")
+    if not isinstance(messages, list) or not all(
+        isinstance(message, Mapping) for message in messages
+    ):
+        raise GroupOrchestratorError("projection.messages must be a list of objects")
+    envelope = {
+        "agent": {
+            "displayName": _required_string(
+                agent.get("displayName"), "projection.agent.displayName"
+            ),
+            "id": _required_string(agent.get("id"), "projection.agent.id"),
+            "description": _required_string_allowing_empty(
+                agent.get("description", ""), "projection.agent.description"
+            ),
+        },
+        "messages": _strict_json_copy(messages, "projection.messages"),
+        "omittedSummary": _strict_json_copy(
+            projection.get("omittedSummary"), "projection.omittedSummary"
+        ),
+        "room": {
+            "id": _required_string(room.get("id"), "projection.room.id"),
+            "name": _required_string(room.get("name"), "projection.room.name"),
+            "maxReplyRounds": room.get("maxReplyRounds", 3),
+        },
+        "run": {
+            "id": _required_string(run.get("id"), "projection.run.id"),
+            "rootMessageId": _required_string(
+                run.get("rootMessageId"), "projection.run.rootMessageId"
+            ),
+            "triggerMessageId": _required_string(
+                run.get("triggerMessageId"), "projection.run.triggerMessageId"
+            ),
+            "replyMode": _required_string(
+                run.get("replyMode", "mentioned"), "projection.run.replyMode"
+            ),
+        },
+        "throughSeq": _required_nonnegative_int(
+            projection.get("throughSeq"), "projection.throughSeq"
+        ),
+        "version": 1,
+    }
+    members: list[dict[str, str]] = []
+    for candidate in room_agents or []:
+        if candidate.get("enabled") is not True:
+            continue
+        members.append(
+            {
+                "displayName": _required_string(
+                    candidate.get("displayName"), "roomAgent.displayName"
+                ),
+                "id": _required_string(candidate.get("id"), "roomAgent.id"),
+            }
+        )
+    members.sort(key=lambda item: (item["displayName"].casefold(), item["id"]))
+    envelope["roomAgents"] = members
+    encoded = json.dumps(
+        envelope,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        allow_nan=False,
+    )
+    automatic_rule = ""
+    if envelope["run"]["replyMode"] == "automatic":
+        automatic_rule = (
+            f"若触发内容与自己的职责无关,禁止调用工具、禁止@成员,且完整答复只能是"
+            f"{_NO_REPLY_TOKEN};正常答复绝不能包含该标记。"
+        )
+    return (
+        "你正在 Hermes 多设备群聊中回复。只输出要发送到公共房间的答复;"
+        "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。\n"
+        f"{automatic_rule}\n"
+        f"runId={envelope['run']['id']} roomId={envelope['room']['id']} "
+        f"agentId={envelope['agent']['id']}\n"
+        f"GROUP_CONTEXT_JSON={encoded}"
+    )
+
+
+class GroupOrchestrator:
+    """Drive Store-claimed runs through one process-local Gateway adapter."""
+
+    def __init__(
+        self,
+        store: _Store,
+        *,
+        gateway_factory: GatewayFactory | None = None,
+        work_enabled: Callable[[], bool] | None = None,
+        event_queue_size: int = DEFAULT_EVENT_QUEUE_SIZE,
+        completion_timeout: float = DEFAULT_COMPLETION_TIMEOUT,
+    ) -> None:
+        if (
+            isinstance(event_queue_size, bool)
+            or not isinstance(event_queue_size, int)
+            or event_queue_size < 1
+        ):
+            raise ValueError("event_queue_size must be a positive integer")
+        if (
+            isinstance(completion_timeout, bool)
+            or not isinstance(completion_timeout, (int, float))
+            or not math.isfinite(completion_timeout)
+            or completion_timeout <= 0
+        ):
+            raise ValueError("completion_timeout must be positive")
+        self.store = store
+        self._gateway_factory: GatewayFactory = (
+            gateway_factory
+            if gateway_factory is not None
+            else lambda callback: GroupGatewayAdapter(on_event=callback)
+        )
+        if work_enabled is not None and not callable(work_enabled):
+            raise ValueError("work_enabled must be callable")
+        self._work_enabled = work_enabled if work_enabled is not None else lambda: True
+        self._event_queue_size = event_queue_size
+        self._completion_timeout = float(completion_timeout)
+        self._loop: asyncio.AbstractEventLoop | None = None
+        self._loop_thread_id: int | None = None
+        self._gateway: _Gateway | None = None
+        self._event_queue: asyncio.Queue[_GatewayEvent | None] | None = None
+        self._event_task: asyncio.Task[None] | None = None
+        self._scheduler_task: asyncio.Task[None] | None = None
+        self._run_tasks: set[asyncio.Task[None]] = set()
+        self._control_tasks: set[asyncio.Task[dict[str, object]]] = set()
+        self._wake_event: asyncio.Event | None = None
+        self._shutdown_event: asyncio.Event | None = None
+        self._shutdown_complete: asyncio.Event | None = None
+        self._shutdown_error: BaseException | None = None
+        self._durable_failures: list[GroupOrchestratorError] = []
+        self._run_failures: list[BaseException] = []
+        self._failure_lock = threading.Lock()
+        self._lifecycle_lock: asyncio.Lock | None = None
+        self._started = False
+        self._closing = False
+        self._scheduler_busy = False
+        self._mapping_lock = threading.RLock()
+        self._runtime_states: dict[str, tuple[int, _RuntimeState]] = {}
+        self._next_generation = 0
+        self._agent_locks: dict[str, _AgentLockState] = {}
+        self._submission_locks: dict[str, _AgentLockState] = {}
+        self._room_locks: dict[str, _AgentLockState] = {}
+
+    @property
+    def event_queue_capacity(self) -> int:
+        return self._event_queue_size
+
+    @property
+    def active_run_count(self) -> int:
+        return len(self._run_tasks)
+
+    async def start(self) -> None:
+        """Initialize/recover durable state before exposing any scheduler work."""
+
+        if self._lifecycle_lock is None:
+            self._lifecycle_lock = asyncio.Lock()
+        async with self._lifecycle_lock:
+            if self._started:
+                return
+            if self._closing:
+                raise GroupOrchestratorError("Orchestrator is shutting down")
+            self._loop = asyncio.get_running_loop()
+            self._loop_thread_id = threading.get_ident()
+            try:
+                await self._run_owned_start_worker(
+                    self.store.initialize,
+                    task_name="yaoyao-group-store-initialize",
+                )
+                await self._run_owned_start_worker(
+                    self.store.recover_after_restart,
+                    task_name="yaoyao-group-store-recover",
+                )
+                self._event_queue = asyncio.Queue(maxsize=self._event_queue_size)
+                self._wake_event = asyncio.Event()
+                self._shutdown_event = asyncio.Event()
+                self._shutdown_complete = asyncio.Event()
+                self._shutdown_error = None
+                self._durable_failures = []
+                self._run_failures = []
+                await self._run_owned_start_worker(
+                    self._gateway_factory,
+                    self._gateway_event_callback,
+                    task_name="yaoyao-group-gateway-create",
+                    adopt=lambda gateway: setattr(self, "_gateway", gateway),
+                )
+                self._event_task = asyncio.create_task(
+                    self._consume_events(), name="yaoyao-group-events"
+                )
+                await asyncio.sleep(0)
+                self._scheduler_task = asyncio.create_task(
+                    self._scheduler(), name="yaoyao-group-scheduler"
+                )
+                await asyncio.sleep(0)
+                self._started = True
+                self.wake()
+            except BaseException:
+                await self._rollback_failed_start()
+                raise
+
+    async def _run_owned_start_worker(
+        self,
+        callback: Callable[..., object],
+        *args: object,
+        task_name: str,
+        adopt: Callable[[object], None] | None = None,
+    ) -> object:
+        """Wait out cancellation so a startup worker cannot outlive ownership."""
+
+        worker = asyncio.get_running_loop().create_task(
+            asyncio.to_thread(callback, *args), name=task_name
+        )
+        cancellation: asyncio.CancelledError | None = None
+        while not worker.done():
+            try:
+                await asyncio.shield(worker)
+            except asyncio.CancelledError as error:
+                if cancellation is None:
+                    cancellation = error
+        try:
+            result = worker.result()
+        except BaseException:
+            if cancellation is not None:
+                raise cancellation
+            raise
+        if adopt is not None:
+            adopt(result)
+        if cancellation is not None:
+            raise cancellation
+        return result
+
+    async def _rollback_failed_start(self) -> None:
+        """Reverse a partial start without invoking normal settlement paths."""
+
+        for task in (self._scheduler_task, self._event_task):
+            if task is not None and not task.done():
+                task.cancel()
+        tasks = tuple(
+            task
+            for task in (self._scheduler_task, self._event_task)
+            if task is not None
+        )
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        gateway = self._gateway
+        if gateway is not None:
+            drain: Callable[[float | None], object] | None = None
+            try:
+                transport = getattr(gateway, "transport", None)
+                candidate = getattr(transport, "drain", None)
+                if callable(candidate):
+                    drain = candidate
+            except BaseException as error:  # noqa: BLE001 - shutdown still runs
+                _log_failure_type("Failed to inspect a partial Gateway startup", error)
+            if callable(drain):
+                try:
+                    await self._run_owned_start_worker(
+                        drain,
+                        None,
+                        task_name="yaoyao-group-gateway-rollback-drain",
+                    )
+                except BaseException as error:  # noqa: BLE001 - keep primary
+                    _log_failure_type(
+                        "Failed to drain a partial Gateway startup", error
+                    )
+            try:
+                await self._run_owned_start_worker(
+                    gateway.shutdown,
+                    task_name="yaoyao-group-gateway-rollback-shutdown",
+                )
+            except BaseException as error:  # noqa: BLE001 - preserve primary
+                _log_failure_type(
+                    "Failed to roll back YaoYao group Gateway startup", error
+                )
+        self._started = False
+        self._gateway = None
+        self._event_task = None
+        self._scheduler_task = None
+        self._event_queue = None
+        self._wake_event = None
+        self._shutdown_event = None
+        self._shutdown_complete = None
+        self._loop = None
+        self._loop_thread_id = None
+
+    def wake(self) -> None:
+        """Idempotently signal that queued durable work may now be runnable."""
+
+        loop = self._loop
+        wake_event = self._wake_event
+        if loop is None or wake_event is None or self._closing:
+            return
+        if threading.get_ident() == self._loop_thread_id:
+            wake_event.set()
+            return
+        try:
+            loop.call_soon_threadsafe(wake_event.set)
+        except RuntimeError:
+            return
+
+    async def drain_once(self, *, timeout: float = 5.0) -> None:
+        """Wake the scheduler and wait until its current durable work is idle."""
+
+        if not self._started:
+            raise GroupOrchestratorError("Orchestrator is not started")
+        if timeout <= 0:
+            raise ValueError("timeout must be positive")
+        self.wake()
+
+        async def wait_idle() -> None:
+            while True:
+                wake_event = self._wake_event
+                event_queue = self._event_queue
+                if (
+                    wake_event is not None
+                    and event_queue is not None
+                    and not wake_event.is_set()
+                    and not self._scheduler_busy
+                    and not self._run_tasks
+                    and event_queue.empty()
+                ):
+                    return
+                await asyncio.sleep(0.001)
+
+        await asyncio.wait_for(wait_idle(), timeout=timeout)
+
+    async def _tracked_control(
+        self,
+        operation: Callable[[], Awaitable[dict[str, object]]],
+        *,
+        task_name: str,
+        failure_message: str,
+    ) -> dict[str, object]:
+        """Shield one accepted mutation and expose it to orderly shutdown."""
+
+        if not self._started or self._closing:
+            raise GroupOrchestratorError("Orchestrator is not accepting controls")
+        task = asyncio.create_task(operation(), name=task_name)
+        self._control_tasks.add(task)
+
+        def finished(done: asyncio.Task[dict[str, object]]) -> None:
+            self._control_tasks.discard(done)
+            if done.cancelled():
+                return
+            try:
+                error = done.exception()
+            except asyncio.CancelledError:
+                return
+            if error is not None:
+                _log_failure_type(failure_message, error)
+
+        task.add_done_callback(finished)
+        return await asyncio.shield(task)
+
+    @staticmethod
+    def _public_command(value: Mapping[str, object]) -> dict[str, object]:
+        command = _strict_json_copy(value, "command")
+        if not isinstance(command, dict):
+            raise GroupOrchestratorError("command must be an object")
+        _required_string(command.get("requestId"), "command.requestId")
+        return command
+
+    async def create_room(self, *, command: Mapping[str, object]) -> dict[str, object]:
+        safe_command = self._public_command(command)
+        request_id = str(safe_command["requestId"])
+        return await self._tracked_control(
+            lambda: self._create_room(safe_command),
+            task_name=f"yaoyao-group-create-room-{request_id}",
+            failure_message="YaoYao group room creation failed",
+        )
+
+    async def _create_room(self, command: Mapping[str, object]) -> dict[str, object]:
+        result = await asyncio.to_thread(self.store.create_room, command)
+        if not isinstance(result, Mapping):
+            raise GroupOrchestratorError("Created room is invalid")
+        self.wake()
+        return dict(result)
+
+    async def update_room(
+        self, *, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        safe_command = self._public_command(command)
+        request_id = str(safe_command["requestId"])
+        return await self._tracked_control(
+            lambda: self._update_room(room_id, safe_command),
+            task_name=f"yaoyao-group-update-room-{request_id}",
+            failure_message="YaoYao group room update failed",
+        )
+
+    async def _update_room(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        async with self._room_serial(room_id):
+            result = await asyncio.to_thread(self.store.update_room, room_id, command)
+            if not isinstance(result, Mapping):
+                raise GroupOrchestratorError("Updated room is invalid")
+            self.wake()
+            return dict(result)
+
+    async def add_agent(
+        self, *, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        safe_command = self._public_command(command)
+        request_id = str(safe_command["requestId"])
+        return await self._tracked_control(
+            lambda: self._add_agent(room_id, safe_command),
+            task_name=f"yaoyao-group-add-agent-{request_id}",
+            failure_message="YaoYao group Agent creation failed",
+        )
+
+    async def _add_agent(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        async with self._room_serial(room_id):
+            result = await asyncio.to_thread(self.store.add_agent, room_id, command)
+            if not isinstance(result, Mapping):
+                raise GroupOrchestratorError("Created Agent is invalid")
+            self.wake()
+            return dict(result)
+
+    async def update_agent(
+        self,
+        *,
+        room_id: str,
+        agent_id: str,
+        command: Mapping[str, object],
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(agent_id, "agentId")
+        safe_command = self._public_command(command)
+        request_id = str(safe_command["requestId"])
+        return await self._tracked_control(
+            lambda: self._mutate_agent(
+                room_id=room_id,
+                agent_id=agent_id,
+                command=safe_command,
+                operation="update",
+            ),
+            task_name=f"yaoyao-group-update-agent-{request_id}",
+            failure_message="YaoYao group Agent update failed",
+        )
+
+    async def delete_agent(
+        self,
+        *,
+        room_id: str,
+        agent_id: str,
+        request_id: str,
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(agent_id, "agentId")
+        _required_string(request_id, "requestId")
+        return await self._tracked_control(
+            lambda: self._mutate_agent(
+                room_id=room_id,
+                agent_id=agent_id,
+                command={"requestId": request_id},
+                operation="delete",
+            ),
+            task_name=f"yaoyao-group-delete-agent-{request_id}",
+            failure_message="YaoYao group Agent deletion failed",
+        )
+
+    async def _mutate_agent(
+        self,
+        *,
+        room_id: str,
+        agent_id: str,
+        command: Mapping[str, object],
+        operation: str,
+    ) -> dict[str, object]:
+        if operation == "update":
+            store_method = self.store.update_agent_with_runtime_targets
+        elif operation == "delete":
+            store_method = self.store.delete_agent_with_runtime_targets
+        else:
+            raise GroupOrchestratorError("Unknown Agent mutation")
+        async with self._room_serial(room_id):
+            async with AsyncExitStack() as stack:
+                await stack.enter_async_context(self._submission_serial(agent_id))
+                with self._mapping_lock:
+                    states = sorted(
+                        (
+                            state
+                            for _generation, state in self._runtime_states.values()
+                            if state.room_id == room_id and state.agent_id == agent_id
+                        ),
+                        key=lambda state: (state.runtime_id, state.generation),
+                    )
+                for state in states:
+                    await stack.enter_async_context(state.mutation_lock)
+                current_states = self._current_owned_states(states)
+                envelope = await self._run_states_blocking(
+                    current_states,
+                    store_method,
+                    room_id,
+                    agent_id,
+                    command,
+                )
+                result, runtime_ids = self._validated_lifecycle_envelope(
+                    envelope, f"agent.{operation}"
+                )
+                if result.get("id") != agent_id or result.get("roomId") != room_id:
+                    raise GroupOrchestratorError("Mutated Agent identity changed")
+                if operation == "update":
+                    requested_enabled = command.get("enabled")
+                    if "enabled" in command and (
+                        not isinstance(requested_enabled, bool)
+                        or result.get("enabled") is not requested_enabled
+                    ):
+                        raise GroupOrchestratorError(
+                            "Updated Agent enabled state changed"
+                        )
+                    if requested_enabled is not False and runtime_ids:
+                        raise GroupOrchestratorError(
+                            "Non-disabling Agent update returned runtime targets"
+                        )
+                self.wake()
+                cleanup_errors = await self._cleanup_lifecycle_targets_locked(
+                    current_states,
+                    runtime_ids,
+                    room_id=room_id,
+                    agent_id=agent_id,
+                )
+                if cleanup_errors:
+                    raise GroupOrchestratorError(
+                        "Agent changed durably, but Gateway cleanup failed: "
+                        + "; ".join(cleanup_errors)
+                    )
+                return result
+
+    async def send_message(
+        self,
+        *,
+        room_id: str,
+        request_id: str,
+        client_message_id: str,
+        content: str,
+        mention_agent_ids: list[str],
+    ) -> dict[str, object]:
+        _required_string(room_id, "roomId")
+        _required_string(request_id, "requestId")
+        _required_string(client_message_id, "clientMessageId")
+        if not isinstance(content, str):
+            raise GroupOrchestratorError("content must be a string")
+        if not isinstance(mention_agent_ids, list):
+            raise GroupOrchestratorError("mentionAgentIds must be a list")
+        safe_mentions = [
+            _required_string(item, "mentionAgentIds") for item in mention_agent_ids
+        ]
+        return await self._tracked_control(
+            lambda: self._send_message(
+                room_id=room_id,
+                request_id=request_id,
+                client_message_id=client_message_id,
+                content=content,
+                mention_agent_ids=safe_mentions,
+            ),
+            task_name=f"yaoyao-group-send-{request_id}",
+            failure_message="YaoYao group message submission failed",
+        )
+
+    async def _send_message(
+        self,
+        *,
+        room_id: str,
+        request_id: str,
+        client_message_id: str,
+        content: str,
+        mention_agent_ids: list[str],
+    ) -> dict[str, object]:
+        async with self._room_serial(room_id):
+            async with AsyncExitStack() as stack:
+                for agent_id in sorted(set(mention_agent_ids)):
+                    await stack.enter_async_context(self._submission_serial(agent_id))
+                result = await asyncio.to_thread(
+                    self.store.create_human_message,
+                    room_id,
+                    request_id=request_id,
+                    client_message_id=client_message_id,
+                    content=content,
+                    mention_agent_ids=mention_agent_ids,
+                )
+                if not isinstance(result, Mapping):
+                    raise GroupOrchestratorError("Created message is invalid")
+            self.wake()
+            return dict(result)
+
+    async def interrupt_agent(
+        self,
+        *,
+        room_id: str,
+        agent_id: str,
+        request_id: str,
+    ) -> dict[str, object]:
+        """Durably interrupt one Agent, then clean only owned Gateway generations."""
+
+        _required_string(room_id, "roomId")
+        _required_string(agent_id, "agentId")
+        _required_string(request_id, "requestId")
+        if not self._started or self._closing:
+            raise GroupOrchestratorError("Orchestrator is not accepting controls")
+        task = asyncio.create_task(
+            self._interrupt_agent(
+                room_id=room_id,
+                agent_id=agent_id,
+                request_id=request_id,
+            ),
+            name=f"yaoyao-group-interrupt-{request_id}",
+        )
+        self._control_tasks.add(task)
+
+        def finished(done: asyncio.Task[dict[str, object]]) -> None:
+            self._control_tasks.discard(done)
+            if done.cancelled():
+                return
+            try:
+                error = done.exception()
+            except asyncio.CancelledError:
+                return
+            if error is not None:
+                _log_failure_type("YaoYao group Agent interrupt failed", error)
+
+        task.add_done_callback(finished)
+        return await asyncio.shield(task)
+
+    async def archive_room(
+        self,
+        *,
+        room_id: str,
+        request_id: str,
+    ) -> dict[str, object]:
+        """Archive one room durably before cleaning its owned runtimes."""
+
+        _required_string(room_id, "roomId")
+        _required_string(request_id, "requestId")
+        return await self._tracked_control(
+            lambda: self._archive_room(room_id=room_id, request_id=request_id),
+            task_name=f"yaoyao-group-archive-{request_id}",
+            failure_message="YaoYao group room archive failed",
+        )
+
+    async def _archive_room(
+        self,
+        *,
+        room_id: str,
+        request_id: str,
+    ) -> dict[str, object]:
+        async with self._room_serial(room_id):
+            room = await asyncio.to_thread(
+                self.store.get_room, room_id, include_archived=True
+            )
+            agents = room.get("agents") if isinstance(room, Mapping) else None
+            if not isinstance(agents, list):
+                raise GroupOrchestratorError("room.agents must be a list")
+            agent_ids = sorted(
+                {
+                    _required_string(
+                        agent.get("id") if isinstance(agent, Mapping) else None,
+                        "room.agent.id",
+                    )
+                    for agent in agents
+                }
+            )
+            async with AsyncExitStack() as stack:
+                for agent_id in agent_ids:
+                    await stack.enter_async_context(self._submission_serial(agent_id))
+                with self._mapping_lock:
+                    states = sorted(
+                        (
+                            state
+                            for _generation, state in self._runtime_states.values()
+                            if state.room_id == room_id
+                        ),
+                        key=lambda state: (
+                            state.agent_id,
+                            state.runtime_id,
+                            state.generation,
+                        ),
+                    )
+                for state in states:
+                    await stack.enter_async_context(state.mutation_lock)
+                current_states = self._current_owned_states(states)
+                envelope = await self._run_states_blocking(
+                    current_states,
+                    self.store.archive_room_with_runtime_targets,
+                    room_id,
+                    {"requestId": request_id},
+                )
+                result, runtime_ids = self._validated_lifecycle_envelope(
+                    envelope, "archive"
+                )
+                if result.get("id") != room_id or result.get("archived") is not True:
+                    raise GroupOrchestratorError("Archived room identity changed")
+                self.wake()
+                cleanup_errors = await self._cleanup_lifecycle_targets_locked(
+                    current_states,
+                    runtime_ids,
+                    room_id=room_id,
+                )
+                if cleanup_errors:
+                    raise GroupOrchestratorError(
+                        "Room was archived durably, but Gateway cleanup failed: "
+                        + "; ".join(cleanup_errors)
+                    )
+                return result
+
+    def _current_owned_states(self, states: list[_RuntimeState]) -> list[_RuntimeState]:
+        current_states: list[_RuntimeState] = []
+        with self._mapping_lock:
+            for state in states:
+                if self._runtime_states.get(state.runtime_id) == (
+                    state.generation,
+                    state,
+                ):
+                    current_states.append(state)
+        return current_states
+
+    @staticmethod
+    def _validated_lifecycle_envelope(
+        value: object, field_name: str
+    ) -> tuple[dict[str, object], tuple[str, ...]]:
+        envelope = _required_mapping(value, field_name)
+        result = _required_mapping(envelope.get("result"), f"{field_name}.result")
+        raw_runtime_ids = envelope.get("runtimeSessionIds")
+        if not isinstance(raw_runtime_ids, list) or len(raw_runtime_ids) > 8:
+            raise GroupOrchestratorError(
+                f"{field_name}.runtimeSessionIds must be a bounded list"
+            )
+        runtime_ids = tuple(
+            _required_string(item, f"{field_name}.runtimeSessionIds")
+            for item in raw_runtime_ids
+        )
+        if len(set(runtime_ids)) != len(runtime_ids):
+            raise GroupOrchestratorError(
+                f"{field_name}.runtimeSessionIds contains duplicates"
+            )
+        return dict(result), runtime_ids
+
+    async def _cleanup_lifecycle_targets_locked(
+        self,
+        states: list[_RuntimeState],
+        runtime_ids: tuple[str, ...],
+        *,
+        room_id: str,
+        agent_id: str | None = None,
+    ) -> list[str]:
+        owned = {
+            state.runtime_id: state
+            for state in states
+            if state.room_id == room_id
+            and (agent_id is None or state.agent_id == agent_id)
+        }
+        cleanup_errors: list[str] = []
+        replay_runtime_ids = set(runtime_ids)
+        ordered_runtime_ids = list(runtime_ids)
+        ordered_runtime_ids.extend(
+            runtime_id
+            for runtime_id in sorted(owned)
+            if runtime_id not in replay_runtime_ids
+        )
+        for runtime_id in ordered_runtime_ids:
+            state = owned.get(runtime_id)
+            if state is None:
+                continue
+            with self._mapping_lock:
+                current = self._runtime_states.get(runtime_id)
+            if current != (state.generation, state):
+                continue
+            try:
+                durable_run = await self._run_state_blocking(
+                    state, self.store.get_run, state.run_id
+                )
+            except Exception:  # noqa: BLE001 - never clean an unverified owner
+                cleanup_errors.append(f"verify {runtime_id}")
+                continue
+            if (
+                not isinstance(durable_run, Mapping)
+                or durable_run.get("status") not in _TERMINAL_RUN_STATUSES
+                or durable_run.get("runtimeSessionId") is not None
+            ):
+                # The idempotency replay may name a runtime id that has since
+                # been reused by a newer generation.  Never clean that owner.
+                continue
+            error = await self._close_durably_interrupted_state_locked(state)
+            if error:
+                cleanup_errors.append(error)
+        return cleanup_errors
+
+    async def _interrupt_agent(
+        self,
+        *,
+        room_id: str,
+        agent_id: str,
+        request_id: str,
+    ) -> dict[str, object]:
+        async with self._submission_serial(agent_id):
+            with self._mapping_lock:
+                states = sorted(
+                    (
+                        state
+                        for _generation, state in self._runtime_states.values()
+                        if state.room_id == room_id and state.agent_id == agent_id
+                    ),
+                    key=lambda state: (state.runtime_id, state.generation),
+                )
+            async with AsyncExitStack() as stack:
+                for state in states:
+                    await stack.enter_async_context(state.mutation_lock)
+                current_states: list[_RuntimeState] = []
+                with self._mapping_lock:
+                    for state in states:
+                        if self._runtime_states.get(state.runtime_id) == (
+                            state.generation,
+                            state,
+                        ):
+                            current_states.append(state)
+                if any(
+                    state.finalizing and state.settle_error is not None
+                    for state in current_states
+                ):
+                    raise GroupOrchestratorError("Agent runtime is already finalizing")
+                envelope = await self._run_states_blocking(
+                    current_states,
+                    self.store.interrupt_agent_with_runtime_targets,
+                    room_id,
+                    agent_id,
+                    {"requestId": request_id},
+                )
+                result, run_ids, runtime_ids = self._validated_interrupt_envelope(
+                    envelope,
+                    room_id=room_id,
+                    agent_id=agent_id,
+                )
+                cleanup_errors: list[str] = []
+                for runtime_id in runtime_ids:
+                    with self._mapping_lock:
+                        current = self._runtime_states.get(runtime_id)
+                    if current is None:
+                        continue
+                    generation, state = current
+                    if (
+                        state not in current_states
+                        or generation != state.generation
+                        or state.room_id != room_id
+                        or state.agent_id != agent_id
+                        or state.run_id not in run_ids
+                    ):
+                        # A replayed target must never affect a reused runtime id.
+                        continue
+                    error = await self._close_durably_interrupted_state_locked(state)
+                    if error:
+                        cleanup_errors.append(error)
+                if cleanup_errors:
+                    raise GroupOrchestratorError(
+                        "Agent was interrupted durably, but Gateway cleanup failed: "
+                        + "; ".join(cleanup_errors)
+                    )
+                return result
+
+    @staticmethod
+    def _validated_interrupt_envelope(
+        value: object,
+        *,
+        room_id: str,
+        agent_id: str,
+    ) -> tuple[dict[str, object], set[str], tuple[str, ...]]:
+        envelope = _required_mapping(value, "interrupt")
+        result_value = _required_mapping(envelope.get("result"), "interrupt.result")
+        if (
+            result_value.get("roomId") != room_id
+            or result_value.get("agentId") != agent_id
+        ):
+            raise GroupOrchestratorError("Interrupt scope changed")
+
+        def string_list(raw: object, field_name: str) -> tuple[str, ...]:
+            if not isinstance(raw, list) or len(raw) > 8:
+                raise GroupOrchestratorError(f"{field_name} must be a bounded list")
+            values = tuple(_required_string(item, field_name) for item in raw)
+            if len(set(values)) != len(values):
+                raise GroupOrchestratorError(f"{field_name} contains duplicates")
+            return values
+
+        run_ids = string_list(
+            result_value.get("interruptedRunIds"),
+            "interrupt.result.interruptedRunIds",
+        )
+        runtime_ids = string_list(
+            envelope.get("runtimeSessionIds"),
+            "interrupt.runtimeSessionIds",
+        )
+        return dict(result_value), set(run_ids), runtime_ids
+
+    async def _close_durably_interrupted_state_locked(
+        self, state: _RuntimeState
+    ) -> str | None:
+        async with state.finalize_lock:
+            with self._mapping_lock:
+                current = self._runtime_states.get(state.runtime_id)
+            if current != (state.generation, state):
+                return None
+            state.finalizing = True
+            failures: list[str] = []
+            gateway = self._required_gateway()
+            try:
+                await self._run_state_blocking(
+                    state, gateway.interrupt, state.runtime_id
+                )
+            except Exception:  # noqa: BLE001 - close is still required
+                failures.append(f"interrupt {state.runtime_id}")
+            closed = False
+            try:
+                closed = (
+                    await self._run_state_blocking(
+                        state, gateway.close, state.runtime_id
+                    )
+                    is True
+                )
+                if not closed:
+                    failures.append(f"close {state.runtime_id}")
+            except Exception:  # noqa: BLE001 - durable state already won
+                failures.append(f"close {state.runtime_id}")
+            if closed:
+                self._remove_runtime(state)
+            state.settle_error = None
+            state.finished.set()
+            state.terminal_info_received.set()
+            state.pending_interaction_ids.clear()
+            current_task = asyncio.current_task()
+            for helper in (state.event_task, state.grace_task):
+                if helper is None or helper is current_task:
+                    continue
+                if not helper.done():
+                    helper.cancel()
+                await asyncio.gather(helper, return_exceptions=True)
+            return ", ".join(failures) or None
+
+    async def respond_approval(
+        self,
+        *,
+        room_id: str,
+        interaction_id: str,
+        request_id: str,
+        choice: str,
+        permanent: bool,
+    ) -> dict[str, object]:
+        if not self._started or self._closing:
+            raise GroupOrchestratorError("Orchestrator is not accepting controls")
+        if choice not in _APPROVAL_CHOICES:
+            raise ValueError("choice must be once, session, always, or deny")
+        if not isinstance(permanent, bool) or ((choice == "always") != permanent):
+            raise ValueError("permanent must be true exactly for choice=always")
+        replay = await self._terminal_interaction_replay(
+            room_id=room_id,
+            interaction_id=interaction_id,
+            request_id=request_id,
+            kind="approval",
+            response={"choice": choice, "permanent": permanent},
+        )
+        if replay is not None:
+            return replay
+        state, interaction = await self._interaction_runtime(
+            room_id, interaction_id, "approval"
+        )
+        task = asyncio.create_task(
+            self._respond_approval_for_runtime(
+                state=state,
+                interaction=interaction,
+                room_id=room_id,
+                interaction_id=interaction_id,
+                request_id=request_id,
+                choice=choice,
+                permanent=permanent,
+            ),
+            name=f"yaoyao-group-approval-{request_id}",
+        )
+        self._track_mutation_task(state, task)
+        return await asyncio.shield(task)
+
+    async def _respond_approval_for_runtime(
+        self,
+        *,
+        state: _RuntimeState,
+        interaction: Mapping[str, object],
+        room_id: str,
+        interaction_id: str,
+        request_id: str,
+        choice: str,
+        permanent: bool,
+    ) -> dict[str, object]:
+        async with state.mutation_lock:
+            self._require_current_runtime(state)
+            claim = await self._run_state_blocking(
+                state,
+                self.store.begin_interaction_response,
+                room_id,
+                interaction_id,
+                request_id=request_id,
+                kind="approval",
+                response={"choice": choice, "permanent": permanent},
+            )
+            if not isinstance(claim, Mapping):
+                raise GroupOrchestratorError("Approval claim is invalid")
+            if claim.get("state") in {"replay", "failed"}:
+                response = claim.get("response")
+                return dict(response) if isinstance(response, Mapping) else dict(claim)
+            payload = interaction.get("payload")
+            choices = payload.get("choices") if isinstance(payload, Mapping) else None
+            if (
+                not isinstance(choices, list)
+                or choice not in choices
+                or not all(item in _APPROVAL_CHOICES for item in choices)
+            ):
+                await self._run_state_blocking(
+                    state,
+                    self.store.fail_interaction_response,
+                    request_id,
+                    reason="Approval choice is not allowed by the pending card",
+                    uncertain=False,
+                )
+                raise ValueError("choice is not allowed by the pending approval")
+            try:
+                resolved = await self._run_state_blocking(
+                    state,
+                    self._required_gateway().respond_approval,
+                    state.runtime_id,
+                    choice,
+                )
+            except Exception:
+                await self._fail_unknown_interaction_response_locked(
+                    state, request_id, "Gateway approval outcome is unknown"
+                )
+                raise GroupOrchestratorError("Gateway approval outcome is unknown")
+            if (
+                isinstance(resolved, bool)
+                or not isinstance(resolved, int)
+                or resolved not in {0, 1}
+            ):
+                await self._fail_unknown_interaction_response_locked(
+                    state, request_id, "Gateway approval result is invalid"
+                )
+                raise GroupOrchestratorError("Gateway approval result is invalid")
+            public = {
+                "accepted": resolved == 1,
+                "interactionId": interaction_id,
+            }
+            try:
+                result = await self._store_retry(
+                    state,
+                    self.store.finish_interaction_response,
+                    request_id,
+                    response=public,
+                    interaction_status=("resolved" if resolved == 1 else "cancelled"),
+                )
+            except Exception:
+                await self._finalize_state_locked(
+                    state,
+                    outcome="failed",
+                    error="Durable approval response could not be completed",
+                    interrupt=True,
+                )
+                raise
+            state.pending_interaction_ids.discard(interaction_id)
+            return dict(result)
+
+    async def respond_clarification(
+        self,
+        *,
+        room_id: str,
+        interaction_id: str,
+        request_id: str,
+        response: str,
+    ) -> dict[str, object]:
+        if not self._started or self._closing:
+            raise GroupOrchestratorError("Orchestrator is not accepting controls")
+        if not isinstance(response, str) or not response.strip():
+            raise ValueError("response must not be blank")
+        self._bounded_text(response, "clarification response")
+        replay = await self._terminal_interaction_replay(
+            room_id=room_id,
+            interaction_id=interaction_id,
+            request_id=request_id,
+            kind="clarification",
+            response={"response": response},
+        )
+        if replay is not None:
+            return replay
+        state, interaction = await self._interaction_runtime(
+            room_id, interaction_id, "clarification"
+        )
+        task = asyncio.create_task(
+            self._respond_clarification_for_runtime(
+                state=state,
+                interaction=interaction,
+                room_id=room_id,
+                interaction_id=interaction_id,
+                request_id=request_id,
+                response=response,
+            ),
+            name=f"yaoyao-group-clarification-{request_id}",
+        )
+        self._track_mutation_task(state, task)
+        return await asyncio.shield(task)
+
+    @staticmethod
+    def _track_mutation_task(
+        state: _RuntimeState, task: asyncio.Task[dict[str, object]]
+    ) -> None:
+        state.mutation_tasks.add(task)
+
+        def finished(done: asyncio.Task[dict[str, object]]) -> None:
+            state.mutation_tasks.discard(done)
+            if done.cancelled():
+                return
+            try:
+                done.exception()
+            except asyncio.CancelledError:
+                return
+
+        task.add_done_callback(finished)
+
+    async def _respond_clarification_for_runtime(
+        self,
+        *,
+        state: _RuntimeState,
+        interaction: Mapping[str, object],
+        room_id: str,
+        interaction_id: str,
+        request_id: str,
+        response: str,
+    ) -> dict[str, object]:
+        async with state.mutation_lock:
+            self._require_current_runtime(state)
+            claim = await self._run_state_blocking(
+                state,
+                self.store.begin_interaction_response,
+                room_id,
+                interaction_id,
+                request_id=request_id,
+                kind="clarification",
+                response={"response": response},
+            )
+            if not isinstance(claim, Mapping):
+                raise GroupOrchestratorError("Clarification claim is invalid")
+            if claim.get("state") in {"replay", "failed"}:
+                replay = claim.get("response")
+                return dict(replay) if isinstance(replay, Mapping) else dict(claim)
+            payload = interaction.get("payload")
+            gateway_request_id = (
+                payload.get("gatewayRequestId")
+                if isinstance(payload, Mapping)
+                else None
+            )
+            gateway_request_id = _required_string(
+                gateway_request_id, "interaction.payload.gatewayRequestId"
+            )
+            try:
+                status = await self._run_state_blocking(
+                    state,
+                    self._required_gateway().respond_clarification,
+                    gateway_request_id,
+                    response,
+                )
+            except Exception:
+                await self._fail_unknown_interaction_response_locked(
+                    state, request_id, "Gateway clarification outcome is unknown"
+                )
+                raise GroupOrchestratorError("Gateway clarification outcome is unknown")
+            if status not in {"ok", "expired"}:
+                await self._fail_unknown_interaction_response_locked(
+                    state, request_id, "Gateway clarification result is invalid"
+                )
+                raise GroupOrchestratorError("Gateway clarification result is invalid")
+            public = {
+                "accepted": status == "ok",
+                "interactionId": interaction_id,
+            }
+            try:
+                result = await self._store_retry(
+                    state,
+                    self.store.finish_interaction_response,
+                    request_id,
+                    response=public,
+                    interaction_status=("resolved" if status == "ok" else "cancelled"),
+                )
+            except Exception:
+                await self._finalize_state_locked(
+                    state,
+                    outcome="failed",
+                    error="Durable clarification response could not be completed",
+                    interrupt=True,
+                )
+                raise
+            state.pending_interaction_ids.discard(interaction_id)
+            return dict(result)
+
+    async def _terminal_interaction_replay(
+        self,
+        *,
+        room_id: str,
+        interaction_id: str,
+        request_id: str,
+        kind: str,
+        response: Mapping[str, object],
+    ) -> dict[str, object] | None:
+        interaction = await asyncio.to_thread(
+            self.store.get_interaction, interaction_id
+        )
+        if (
+            not isinstance(interaction, Mapping)
+            or interaction.get("roomId") != room_id
+            or interaction.get("kind") != kind
+        ):
+            raise GroupOrchestratorError("Interaction is not in this room")
+        if interaction.get("status") == "pending":
+            return None
+        result = await asyncio.to_thread(
+            self.store.begin_interaction_response,
+            room_id,
+            interaction_id,
+            request_id=request_id,
+            kind=kind,
+            response=response,
+        )
+        if not isinstance(result, Mapping):
+            raise GroupOrchestratorError("Interaction replay is invalid")
+        replay = result.get("response")
+        if result.get("state") == "replay" and isinstance(replay, Mapping):
+            return dict(replay)
+        if result.get("state") == "failed":
+            return dict(result)
+        raise GroupOrchestratorError("Interaction is already terminal")
+
+    async def _interaction_runtime(
+        self, room_id: str, interaction_id: str, kind: str
+    ) -> tuple[_RuntimeState, Mapping[str, object]]:
+        interaction = await asyncio.to_thread(
+            self.store.get_interaction, interaction_id
+        )
+        if (
+            not isinstance(interaction, Mapping)
+            or interaction.get("roomId") != room_id
+            or interaction.get("kind") != kind
+            or interaction.get("status") != "pending"
+        ):
+            raise GroupOrchestratorError("Interaction is not pending in this room")
+        run_id = _required_string(interaction.get("runId"), "interaction.runId")
+        run = await asyncio.to_thread(self.store.get_run, run_id)
+        runtime_id = _required_string(
+            run.get("runtimeSessionId") if isinstance(run, Mapping) else None,
+            "run.runtimeSessionId",
+        )
+        with self._mapping_lock:
+            current = self._runtime_states.get(runtime_id)
+        if current is None or current[1].run_id != run_id:
+            raise GroupOrchestratorError("Interaction runtime is not active")
+        return current[1], interaction
+
+    def _require_current_runtime(self, state: _RuntimeState) -> None:
+        with self._mapping_lock:
+            current = self._runtime_states.get(state.runtime_id)
+        if (
+            current != (state.generation, state)
+            or state.finished.is_set()
+            or state.finalizing
+        ):
+            raise GroupOrchestratorError("Interaction runtime generation changed")
+
+    async def _fail_unknown_interaction_response_locked(
+        self, state: _RuntimeState, request_id: str, reason: str
+    ) -> None:
+        try:
+            await self._store_retry(
+                state,
+                self.store.fail_interaction_response,
+                request_id,
+                reason=reason,
+                uncertain=True,
+            )
+        except Exception:
+            logger.error("Failed to persist unknown interaction outcome")
+        await self._finalize_state_locked(
+            state, outcome="failed", error=reason, interrupt=True
+        )
+
+    async def shutdown(self) -> None:
+        """Stop claims, settle every owned runtime, drain, then close Gateway."""
+
+        if self._lifecycle_lock is None:
+            self._lifecycle_lock = asyncio.Lock()
+        wait_for_owner: asyncio.Event | None = None
+        completed_shutdown = False
+        async with self._lifecycle_lock:
+            if not self._started:
+                self._closing = True
+                completed_shutdown = bool(
+                    self._shutdown_complete is not None
+                    and self._shutdown_complete.is_set()
+                )
+                if not completed_shutdown:
+                    return
+                scheduler = None
+            elif self._closing:
+                wait_for_owner = self._shutdown_complete
+                scheduler = None
+            else:
+                self._closing = True
+                if self._shutdown_event is not None:
+                    self._shutdown_event.set()
+                if self._wake_event is not None:
+                    self._wake_event.set()
+                scheduler = self._scheduler_task
+        if completed_shutdown:
+            if self._shutdown_error is not None:
+                raise self._shutdown_error
+            return
+        if wait_for_owner is not None:
+            await wait_for_owner.wait()
+            if self._shutdown_error is not None:
+                raise self._shutdown_error
+            return
+
+        first_error: BaseException | None = None
+
+        def remember(error: BaseException) -> None:
+            nonlocal first_error
+            if first_error is None:
+                first_error = error
+
+        if scheduler is not None:
+            try:
+                await scheduler
+            except BaseException as error:  # noqa: BLE001 - cleanup continues
+                remember(error)
+
+        while self._run_tasks:
+            tasks = tuple(self._run_tasks)
+            try:
+                results = await asyncio.gather(*tasks, return_exceptions=True)
+            except BaseException as error:  # noqa: BLE001 - cleanup continues
+                remember(error)
+            else:
+                self._remember_task_results(results, remember)
+
+        while self._control_tasks:
+            tasks = tuple(self._control_tasks)
+            try:
+                results = await asyncio.gather(*tasks, return_exceptions=True)
+            except BaseException as error:  # noqa: BLE001 - cleanup continues
+                remember(error)
+            else:
+                self._remember_task_results(results, remember)
+
+        with self._failure_lock:
+            run_failures = tuple(self._run_failures)
+            durable_failures = tuple(self._durable_failures)
+        for error in (*run_failures, *durable_failures):
+            remember(error)
+
+        with self._mapping_lock:
+            remaining_states = [
+                state for _generation, state in self._runtime_states.values()
+            ]
+        for state in remaining_states:
+            if state.settle_error is not None:
+                remember(state.settle_error)
+            current_task = asyncio.current_task()
+            for helper in (state.event_task, state.grace_task):
+                if helper is None or helper is current_task:
+                    continue
+                if not helper.done():
+                    helper.cancel()
+                try:
+                    results = await asyncio.gather(helper, return_exceptions=True)
+                except BaseException as error:  # noqa: BLE001 - cleanup continues
+                    remember(error)
+                else:
+                    self._remember_task_results(results, remember)
+            pending_calls = tuple(state.inflight_calls)
+            if pending_calls:
+                try:
+                    results = await asyncio.gather(
+                        *pending_calls, return_exceptions=True
+                    )
+                except BaseException as error:  # noqa: BLE001 - cleanup continues
+                    remember(error)
+                else:
+                    self._remember_task_results(results, remember)
+            self._remove_runtime(state)
+            try:
+                await asyncio.to_thread(
+                    self._required_gateway().close, state.runtime_id
+                )
+            except BaseException as error:  # noqa: BLE001 - cleanup continues
+                remember(error)
+            state.finished.set()
+
+        gateway = self._gateway
+        if gateway is not None:
+            drain: Callable[[float | None], object] | None = None
+            try:
+                transport = getattr(gateway, "transport", None)
+                candidate = getattr(transport, "drain", None)
+                if callable(candidate):
+                    drain = candidate
+            except BaseException as error:  # noqa: BLE001 - shutdown still runs
+                remember(error)
+            if drain is not None:
+                try:
+                    await asyncio.to_thread(drain, None)
+                except BaseException as error:  # noqa: BLE001 - cleanup continues
+                    remember(error)
+            try:
+                await asyncio.to_thread(gateway.shutdown)
+            except BaseException as error:  # noqa: BLE001 - cleanup continues
+                remember(error)
+
+        try:
+            event_queue = self._event_queue
+            event_task = self._event_task
+            if event_queue is not None:
+                try:
+                    await event_queue.join()
+                    await event_queue.put(None)
+                except BaseException as error:  # noqa: BLE001 - completion must signal
+                    remember(error)
+            if event_task is not None:
+                try:
+                    await event_task
+                except BaseException as error:  # noqa: BLE001 - completion must signal
+                    remember(error)
+        finally:
+            async with self._lifecycle_lock:
+                self._started = False
+                self._gateway = None
+                self._scheduler_task = None
+                self._event_task = None
+                self._shutdown_error = first_error
+                if self._shutdown_complete is not None:
+                    self._shutdown_complete.set()
+        if first_error is not None:
+            raise first_error
+
+    @staticmethod
+    def _remember_task_results(
+        results: list[object], remember: Callable[[BaseException], None]
+    ) -> None:
+        for result in results:
+            if isinstance(result, BaseException) and not isinstance(
+                result, asyncio.CancelledError
+            ):
+                remember(result)
+
+    async def _scheduler(self) -> None:
+        wake_event = self._wake_event
+        shutdown_event = self._shutdown_event
+        if wake_event is None or shutdown_event is None:
+            raise GroupOrchestratorError("Scheduler started without lifecycle state")
+        claim_failure_count = 0
+        while True:
+            await wake_event.wait()
+            wake_event.clear()
+            if shutdown_event.is_set():
+                return
+            self._scheduler_busy = True
+            try:
+                if not await self._work_is_enabled():
+                    if await self._wait_for_disabled_work(shutdown_event, wake_event):
+                        return
+                    continue
+                try:
+                    more_cascades = await self._drain_pending_cascades()
+                except Exception:  # noqa: BLE001 - durable ledger remains pending
+                    claim_failure_count += 1
+                    delay = min(
+                        _STORE_RETRY_DELAYS[0]
+                        * (2 ** min(claim_failure_count - 1, 10)),
+                        _CLAIM_RETRY_MAX_SECONDS,
+                    )
+                    logger.error("Pending YaoYao group cascade temporarily failed")
+                    try:
+                        await asyncio.wait_for(shutdown_event.wait(), timeout=delay)
+                    except asyncio.TimeoutError:
+                        wake_event.set()
+                        continue
+                    return
+                if more_cascades:
+                    wake_event.set()
+                    continue
+                while not shutdown_event.is_set():
+                    if not await self._work_is_enabled():
+                        if await self._wait_for_disabled_work(
+                            shutdown_event, wake_event
+                        ):
+                            return
+                        break
+                    try:
+                        claimed = await asyncio.to_thread(
+                            self.store.claim_next_runnable_run
+                        )
+                    except Exception:  # noqa: BLE001 - keep scheduler alive
+                        claim_failure_count += 1
+                        delay = min(
+                            _STORE_RETRY_DELAYS[0]
+                            * (2 ** min(claim_failure_count - 1, 10)),
+                            _CLAIM_RETRY_MAX_SECONDS,
+                        )
+                        logger.error("YaoYao group run claim temporarily failed")
+                        try:
+                            await asyncio.wait_for(shutdown_event.wait(), timeout=delay)
+                        except asyncio.TimeoutError:
+                            continue
+                        return
+                    claim_failure_count = 0
+                    if claimed is None:
+                        break
+                    task = asyncio.create_task(
+                        self._run_claimed(claimed),
+                        name=f"yaoyao-group-run-{claimed.get('id', 'invalid')}",
+                    )
+                    self._run_tasks.add(task)
+                    task.add_done_callback(self._run_finished)
+            finally:
+                self._scheduler_busy = False
+
+    async def _work_is_enabled(self) -> bool:
+        try:
+            return await asyncio.to_thread(self._work_enabled) is True
+        except Exception as error:  # noqa: BLE001 - fail closed on config errors
+            _log_failure_type("YaoYao group work gate temporarily failed", error)
+            return False
+
+    @staticmethod
+    async def _wait_for_disabled_work(
+        shutdown_event: asyncio.Event,
+        wake_event: asyncio.Event,
+    ) -> bool:
+        try:
+            await asyncio.wait_for(
+                shutdown_event.wait(), timeout=_WORK_DISABLED_RETRY_SECONDS
+            )
+        except asyncio.TimeoutError:
+            wake_event.set()
+            return False
+        return True
+
+    async def _drain_pending_cascades(self) -> bool:
+        page = await asyncio.to_thread(self.store.list_pending_cascades, limit=32)
+        if not isinstance(page, Mapping):
+            raise GroupOrchestratorError("Pending cascade page is invalid")
+        items = page.get("items")
+        if not isinstance(items, list) or not all(
+            isinstance(item, Mapping) for item in items
+        ):
+            raise GroupOrchestratorError("Pending cascade items are invalid")
+        created = False
+        for item in items:
+            source_run_id = _required_string(
+                item.get("sourceRunId"), "cascade.sourceRunId"
+            )
+            result = await asyncio.to_thread(self.store.complete_cascade, source_run_id)
+            if not isinstance(result, Mapping):
+                raise GroupOrchestratorError("Cascade completion is invalid")
+            run_count = result.get("runCount")
+            if isinstance(run_count, bool) or not isinstance(run_count, int):
+                raise GroupOrchestratorError("Cascade run count is invalid")
+            created = created or run_count > 0
+        if created:
+            self.wake()
+        return page.get("nextCursor") is not None
+
+    def _run_finished(self, task: asyncio.Task[None]) -> None:
+        self._run_tasks.discard(task)
+        try:
+            error = task.exception()
+        except asyncio.CancelledError:
+            error = None
+        if error is not None:
+            with self._failure_lock:
+                self._run_failures.append(error)
+            _log_failure_type("YaoYao group run task failed", error)
+        self.wake()
+
+    async def _run_claimed(self, claimed: Mapping[str, object]) -> None:
+        run_id_value = claimed.get("id")
+        if not isinstance(run_id_value, str) or not run_id_value:
+            logger.error("Claimed YaoYao group run has no usable identity")
+            return
+        run_id = run_id_value
+        try:
+            claimed_agent_id = _required_string(claimed.get("agentId"), "run.agentId")
+            async with self._agent_serial(claimed_agent_id):
+                try:
+                    await self._run_claimed_for_agent(claimed, run_id)
+                except Exception:  # noqa: BLE001 - never strand a durable claim
+                    logger.exception("YaoYao group claimed run preparation failed")
+                    await self._fail_claim_without_projection(run_id)
+        except Exception:  # noqa: BLE001 - malformed claim still has run ID
+            logger.exception("YaoYao group claimed run identity is invalid")
+            await self._fail_claim_without_projection(run_id)
+
+    @asynccontextmanager
+    async def _agent_serial(self, agent_id: str):
+        async with self._keyed_serial(self._agent_locks, agent_id):
+            yield
+
+    @asynccontextmanager
+    async def _submission_serial(self, agent_id: str):
+        async with self._keyed_serial(self._submission_locks, agent_id):
+            yield
+
+    @asynccontextmanager
+    async def _room_serial(self, room_id: str):
+        async with self._keyed_serial(self._room_locks, room_id):
+            yield
+
+    @staticmethod
+    @asynccontextmanager
+    async def _keyed_serial(locks: dict[str, _AgentLockState], key: str):
+        entry = locks.get(key)
+        if entry is None:
+            entry = _AgentLockState()
+            locks[key] = entry
+        entry.users += 1
+        try:
+            async with entry.lock:
+                yield
+        finally:
+            entry.users -= 1
+            if entry.users == 0 and locks.get(key) is entry:
+                locks.pop(key, None)
+
+    async def _run_claimed_for_agent(
+        self, claimed: Mapping[str, object], run_id: str
+    ) -> None:
+        try:
+            projection = await asyncio.to_thread(self.store.read_run_projection, run_id)
+        except Exception:  # noqa: BLE001 - durable claim must be terminal
+            logger.exception("Failed to read YaoYao group run projection")
+            await self._fail_claim_without_projection(run_id)
+            return
+
+        run = _required_mapping(projection.get("run"), "projection.run")
+        room = _required_mapping(projection.get("room"), "projection.room")
+        agent = _required_mapping(projection.get("agent"), "projection.agent")
+        room_id = _required_string(room.get("id"), "projection.room.id")
+        agent_id = _required_string(agent.get("id"), "projection.agent.id")
+        if _required_string(run.get("id"), "projection.run.id") != run_id:
+            raise GroupOrchestratorError("projection run identity changed")
+        if agent_id != _required_string(claimed.get("agentId"), "run.agentId"):
+            raise GroupOrchestratorError("projection Agent identity changed")
+        configuration = {
+            "model": agent.get("model"),
+            "provider": agent.get("provider"),
+            "reasoning_effort": agent.get("reasoningEffort"),
+            "fast": agent.get("fastMode"),
+        }
+        try:
+            rotated = await asyncio.to_thread(
+                self.store.prepare_run_session_configuration,
+                run_id,
+                configuration,
+            )
+            if rotated:
+                projection = await asyncio.to_thread(
+                    self.store.read_run_projection, run_id
+                )
+                run = _required_mapping(projection.get("run"), "projection.run")
+                room = _required_mapping(projection.get("room"), "projection.room")
+                agent = _required_mapping(projection.get("agent"), "projection.agent")
+        except Exception:
+            logger.exception("Failed to prepare YaoYao group Agent configuration")
+            await self._fail_claim_without_projection(run_id)
+            return
+        profile = _required_string(agent.get("profile"), "projection.agent.profile")
+        reply_mode = _required_string(run.get("replyMode"), "projection.run.replyMode")
+        if reply_mode not in {"mentioned", "automatic"}:
+            raise GroupOrchestratorError("projection.run.replyMode is invalid")
+        display_name = _required_string(
+            agent.get("displayName"), "projection.agent.displayName"
+        )
+        room_name = _required_string(room.get("name"), "projection.room.name")
+        cwd_value = room.get("cwd", "")
+        if not isinstance(cwd_value, str):
+            raise GroupOrchestratorError("projection.room.cwd must be a string")
+        expected_stored_id = _optional_string(
+            agent.get("storedSessionId"), "projection.agent.storedSessionId"
+        )
+        expected_context_seq = _required_nonnegative_int(
+            agent.get("lastContextMessageSeq"),
+            "projection.agent.lastContextMessageSeq",
+        )
+        through_seq = _required_nonnegative_int(
+            projection.get("throughSeq"), "projection.throughSeq"
+        )
+        room_detail = await asyncio.to_thread(self.store.get_room, room_id)
+        room_agents = room_detail.get("agents")
+        if not isinstance(room_agents, list) or not all(
+            isinstance(item, Mapping) for item in room_agents
+        ):
+            raise GroupOrchestratorError("room.agents must be a list of objects")
+        prompt = build_run_prompt(projection, room_agents)
+        gateway = self._required_gateway()
+        try:
+            if expected_stored_id is None:
+                identity = await asyncio.to_thread(
+                    gateway.create_session,
+                    profile,
+                    f"[群聊] {room_name} · {display_name}",
+                    cwd_value,
+                    [],
+                    configuration,
+                )
+            else:
+                identity = await asyncio.to_thread(
+                    gateway.resume_session, profile, expected_stored_id
+                )
+        except Exception:  # noqa: BLE001 - never create a fallback session
+            logger.error("YaoYao group session create/resume failed")
+            await self._settle_unbound_failed(
+                run_id,
+                expected_stored_id=expected_stored_id,
+                stored_session_id=expected_stored_id,
+                error="Agent session setup failed",
+            )
+            return
+
+        identity = self._validated_identity(identity)
+        if identity.running:
+            # A busy resume may belong to another consumer.  Persist rotation,
+            # but never adopt, submit to, interrupt, or close that runtime.
+            await self._settle_unbound_failed(
+                run_id,
+                expected_stored_id=expected_stored_id,
+                stored_session_id=identity.stored_id,
+                error="Agent stored session is already running",
+            )
+            return
+
+        bound = False
+        for delay in (*_STORE_RETRY_DELAYS, None):
+            try:
+                await asyncio.to_thread(
+                    self.store.bind_run_runtime, run_id, identity.runtime_id
+                )
+                bound = True
+                break
+            except Exception:  # noqa: BLE001 - exact bind replay is idempotent
+                if delay is None:
+                    try:
+                        durable = await asyncio.to_thread(self.store.get_run, run_id)
+                    except Exception:  # noqa: BLE001 - identity remains unknown
+                        durable = None
+                    bound = (
+                        isinstance(durable, Mapping)
+                        and durable.get("status") in {"running", "awaiting_input"}
+                        and durable.get("runtimeSessionId") == identity.runtime_id
+                    )
+                    break
+                await asyncio.sleep(delay)
+        if not bound:
+            logger.error("Failed to bind YaoYao group runtime")
+            await self._close_unbound_runtime(identity.runtime_id)
+            await self._settle_unbound_failed(
+                run_id,
+                expected_stored_id=expected_stored_id,
+                stored_session_id=identity.stored_id,
+                error="Agent runtime binding failed",
+            )
+            return
+
+        async with self._room_serial(room_id):
+            async with self._submission_serial(agent_id):
+                durable = None
+                for delay in (*_STORE_RETRY_DELAYS, None):
+                    try:
+                        durable = await asyncio.to_thread(self.store.get_run, run_id)
+                        break
+                    except Exception:  # noqa: BLE001 - unknown owner cannot submit
+                        if delay is None:
+                            break
+                        await asyncio.sleep(delay)
+                if (
+                    not isinstance(durable, Mapping)
+                    or durable.get("status") not in {"running", "awaiting_input"}
+                    or durable.get("runtimeSessionId") != identity.runtime_id
+                ):
+                    logger.error("Bound YaoYao group runtime lost durable ownership")
+                    await self._close_unbound_runtime(identity.runtime_id)
+                    if not (
+                        isinstance(durable, Mapping)
+                        and durable.get("status") in _TERMINAL_RUN_STATUSES
+                    ):
+                        await self._settle_unbound_failed(
+                            run_id,
+                            expected_stored_id=expected_stored_id,
+                            stored_session_id=identity.stored_id,
+                            error="Agent runtime ownership check failed",
+                        )
+                    return
+
+                state = self._install_runtime(
+                    run_id=run_id,
+                    room_id=room_id,
+                    agent_id=agent_id,
+                    identity=identity,
+                    expected_stored_id=expected_stored_id,
+                    expected_context_seq=expected_context_seq,
+                    through_seq=through_seq,
+                    profile=profile,
+                    reply_mode=reply_mode,
+                )
+                if self._closing:
+                    state.prompt_ready.set()
+                    await self._finalize_state(
+                        state,
+                        outcome="interrupted",
+                        error="Group orchestrator stopped",
+                        interrupt=True,
+                    )
+                    return
+
+                try:
+                    await self._run_state_blocking(
+                        state, gateway.submit_prompt, identity.runtime_id, prompt
+                    )
+                except Exception:  # noqa: BLE001 - ACK is intentionally unknown
+                    logger.error("YaoYao group prompt submission failed")
+                    state.prompt_ready.set()
+                    await self._finalize_state(
+                        state,
+                        outcome="failed",
+                        error="Prompt submission result is unknown",
+                        interrupt=True,
+                    )
+                    return
+
+                try:
+                    for delay in (*_STORE_RETRY_DELAYS, None):
+                        try:
+                            await self._run_state_blocking(
+                                state,
+                                self.store.commit_prompt_submission,
+                                run_id,
+                                expected_stored_session_id=expected_stored_id,
+                                stored_session_id=identity.stored_id,
+                                runtime_session_id=identity.runtime_id,
+                                expected_context_seq=expected_context_seq,
+                                through_seq=through_seq,
+                            )
+                            state.prompt_committed = True
+                            break
+                        except Exception:
+                            if delay is None:
+                                raise
+                            await asyncio.sleep(delay)
+                except Exception:  # noqa: BLE001 - prompts are never retried
+                    logger.error("Failed to commit YaoYao group prompt ACK")
+                    state.prompt_ready.set()
+                    await self._finalize_state(
+                        state,
+                        outcome="failed",
+                        error="Prompt acknowledgement could not be committed",
+                        interrupt=True,
+                    )
+                    return
+                finally:
+                    state.prompt_ready.set()
+
+        if state.overflowed:
+            await self._finalize_state(
+                state,
+                outcome="failed",
+                error="Runtime control event buffer exceeded capacity",
+                interrupt=True,
+            )
+            return
+
+        shutdown_event = self._shutdown_event
+        if shutdown_event is None:
+            raise GroupOrchestratorError("Run started without shutdown state")
+        finished_wait = asyncio.create_task(state.finished.wait())
+        shutdown_wait = asyncio.create_task(shutdown_event.wait())
+        try:
+            done, pending = await asyncio.wait(
+                {finished_wait, shutdown_wait},
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+            for waiter in pending:
+                waiter.cancel()
+            if finished_wait in done and state.finished.is_set():
+                return
+            if shutdown_wait in done and shutdown_event.is_set():
+                outcome = "interrupted"
+                error = "Group orchestrator stopped"
+            await self._finalize_state(
+                state, outcome=outcome, error=error, interrupt=True
+            )
+        finally:
+            for waiter in (finished_wait, shutdown_wait):
+                if not waiter.done():
+                    waiter.cancel()
+
+    async def _fail_claim_without_projection(self, run_id: str) -> None:
+        for delay in (*_STORE_RETRY_DELAYS, None):
+            try:
+                await asyncio.to_thread(
+                    self.store.transition_run,
+                    run_id,
+                    "failed",
+                    error="Agent context projection failed",
+                )
+                return
+            except Exception:  # noqa: BLE001 - reconcile before retrying
+                if await self._run_is_durably_terminal(run_id):
+                    return
+                if delay is None:
+                    logger.exception("Failed to settle malformed YaoYao group claim")
+                    self._record_durable_failure(
+                        "Durable claimed run settlement failed"
+                    )
+                    return
+                await asyncio.sleep(delay)
+
+    async def _settle_unbound_failed(
+        self,
+        run_id: str,
+        *,
+        expected_stored_id: str | None,
+        stored_session_id: str | None,
+        error: str,
+    ) -> None:
+        for delay in (*_STORE_RETRY_DELAYS, None):
+            try:
+                await asyncio.to_thread(
+                    self.store.settle_run,
+                    run_id,
+                    runtime_session_id=None,
+                    expected_stored_session_id=expected_stored_id,
+                    stored_session_id=stored_session_id,
+                    outcome="failed",
+                    error=self._bounded_error(error),
+                )
+                return
+            except Exception:  # noqa: BLE001 - reconcile before retrying
+                if await self._run_is_durably_terminal(run_id):
+                    return
+                if delay is None:
+                    logger.exception("Failed to settle unbound YaoYao group run")
+                    self._record_durable_failure(
+                        "Durable unbound run settlement failed"
+                    )
+                    return
+                await asyncio.sleep(delay)
+
+    def _record_durable_failure(self, message: str) -> None:
+        failure = GroupOrchestratorError(message)
+        with self._failure_lock:
+            self._durable_failures.append(failure)
+
+    async def _run_is_durably_terminal(self, run_id: str) -> bool:
+        try:
+            durable = await asyncio.to_thread(self.store.get_run, run_id)
+        except Exception:  # noqa: BLE001 - missing is safe only for local cleanup
+            return False
+        return (
+            isinstance(durable, Mapping)
+            and durable.get("status") in _TERMINAL_RUN_STATUSES
+        )
+
+    async def _close_unbound_runtime(self, runtime_id: str) -> None:
+        try:
+            await asyncio.to_thread(self._required_gateway().close, runtime_id)
+        except Exception:  # noqa: BLE001 - best effort after failed durable bind
+            logger.exception("Failed to close unbound YaoYao group runtime")
+
+    @staticmethod
+    def _validated_identity(identity: object) -> SessionIdentity:
+        stored_id = _required_string(
+            getattr(identity, "stored_id", None), "session.stored_id"
+        )
+        runtime_id = _required_string(
+            getattr(identity, "runtime_id", None), "session.runtime_id"
+        )
+        running = getattr(identity, "running", None)
+        if not isinstance(running, bool):
+            raise GroupOrchestratorError("session.running must be a boolean")
+        return SessionIdentity(stored_id, runtime_id, running)
+
+    def _install_runtime(
+        self,
+        *,
+        run_id: str,
+        room_id: str,
+        agent_id: str,
+        identity: SessionIdentity,
+        expected_stored_id: str | None,
+        expected_context_seq: int,
+        through_seq: int,
+        profile: str,
+        reply_mode: str,
+    ) -> _RuntimeState:
+        with self._mapping_lock:
+            if identity.runtime_id in self._runtime_states:
+                raise GroupOrchestratorError(
+                    "Gateway runtime is already mapped to an active generation"
+                )
+            self._next_generation += 1
+            state = _RuntimeState(
+                run_id=run_id,
+                room_id=room_id,
+                agent_id=agent_id,
+                runtime_id=identity.runtime_id,
+                generation=self._next_generation,
+                expected_stored_id=expected_stored_id,
+                session_stored_id=identity.stored_id,
+                expected_context_seq=expected_context_seq,
+                through_seq=through_seq,
+                profile=profile,
+                reply_mode=reply_mode,
+            )
+            self._runtime_states[identity.runtime_id] = (
+                state.generation,
+                state,
+            )
+            state.event_task = asyncio.create_task(
+                self._consume_runtime_events(state),
+                name=f"yaoyao-group-runtime-{state.generation}",
+            )
+            return state
+
+    def _remove_runtime(self, state: _RuntimeState) -> None:
+        with self._mapping_lock:
+            current = self._runtime_states.get(state.runtime_id)
+            if current == (state.generation, state):
+                self._runtime_states.pop(state.runtime_id, None)
+
+    def _gateway_event_callback(
+        self,
+        runtime_id: str,
+        event_type: str,
+        payload: dict[str, object],
+    ) -> None:
+        """Bridge the Gateway callback thread into one bounded asyncio queue."""
+
+        if event_type not in _PERSISTED_EVENTS:
+            return
+
+        with self._mapping_lock:
+            current = self._runtime_states.get(runtime_id)
+        if current is None:
+            logger.debug("Ignored event for an unmapped group runtime")
+            return
+        generation, _state = current
+        loop = self._loop
+        queue = self._event_queue
+        if loop is None or queue is None or loop.is_closed():
+            return
+        try:
+            safe_payload = _strict_json_copy(payload, "gateway.payload")
+        except GroupOrchestratorError:
+            logger.warning("Ignored malformed group Gateway payload")
+            return
+        if not isinstance(safe_payload, dict):
+            return
+        envelope = _GatewayEvent(
+            runtime_id=runtime_id,
+            generation=generation,
+            event_type=event_type,
+            payload=safe_payload,
+        )
+        if threading.get_ident() == self._loop_thread_id:
+            # The production transport uses a dedicated worker.  Supporting a
+            # loop-local test callback is still safe while the queue has room.
+            try:
+                queue.put_nowait(envelope)
+            except asyncio.QueueFull:
+                logger.error("Loop-local group Gateway callback exceeded capacity")
+            return
+        try:
+            future = asyncio.run_coroutine_threadsafe(queue.put(envelope), loop)
+            future.result()
+        except (RuntimeError, asyncio.CancelledError):
+            return
+
+    async def _consume_events(self) -> None:
+        queue = self._event_queue
+        if queue is None:
+            raise GroupOrchestratorError("Event consumer has no queue")
+        while True:
+            event = await queue.get()
+            try:
+                if event is None:
+                    return
+                await self._handle_event(event)
+            except Exception:  # noqa: BLE001 - isolate all Gateway callbacks
+                logger.exception("Failed to consume YaoYao group Gateway event")
+            finally:
+                queue.task_done()
+
+    async def _handle_event(self, event: _GatewayEvent) -> None:
+        if event.event_type not in _PERSISTED_EVENTS:
+            return
+        with self._mapping_lock:
+            current = self._runtime_states.get(event.runtime_id)
+        if current is None or current[0] != event.generation:
+            logger.debug("Ignored stale group Gateway generation")
+            return
+        state = current[1]
+        if state.finished.is_set() or state.finalizing:
+            return
+        try:
+            state.pending_events.put_nowait(event)
+        except asyncio.QueueFull:
+            # The transport already applies global backpressure. A per-runtime
+            # overflow is an invariant breach: fail that run without blocking
+            # unrelated sessions in the one global router.
+            state.overflowed = True
+            state.prompt_ready.set()
+
+    async def _consume_runtime_events(self, state: _RuntimeState) -> None:
+        """Serialize one runtime without head-of-line blocking other sessions."""
+
+        await state.prompt_ready.wait()
+        while not state.finished.is_set():
+            if state.finalizing:
+                return
+            if state.overflowed and state.prompt_committed:
+                await self._finalize_state(
+                    state,
+                    outcome="failed",
+                    error="Runtime control event buffer exceeded capacity",
+                    interrupt=True,
+                )
+                return
+            timeout: float | None = None
+            if state.stream_dirty and not (
+                state.reply_mode == "automatic" and not state.automatic_published
+            ):
+                timeout = max(
+                    0.0,
+                    STREAM_FLUSH_INTERVAL
+                    - (asyncio.get_running_loop().time() - state.last_stream_flush),
+                )
+            try:
+                if timeout is None:
+                    event = await state.pending_events.get()
+                else:
+                    event = await asyncio.wait_for(
+                        state.pending_events.get(), timeout=timeout
+                    )
+            except asyncio.TimeoutError:
+                async with state.mutation_lock:
+                    if state.finished.is_set() or state.finalizing:
+                        return
+                    with self._mapping_lock:
+                        current = self._runtime_states.get(state.runtime_id)
+                    if current != (state.generation, state):
+                        continue
+                    try:
+                        await self._flush_streaming(state)
+                    except Exception:  # noqa: BLE001 - fail this runtime durably
+                        logger.error("Failed to persist YaoYao group streaming message")
+                        await self._finalize_state_locked(
+                            state,
+                            outcome="failed",
+                            error="Streaming Agent message could not be persisted",
+                            interrupt=True,
+                        )
+                continue
+            try:
+                if event is None:
+                    return
+                if not state.prompt_committed:
+                    continue
+                with self._mapping_lock:
+                    current = self._runtime_states.get(event.runtime_id)
+                if current != (event.generation, state):
+                    continue
+                state.event_index += 1
+                async with state.mutation_lock:
+                    try:
+                        await self._apply_runtime_event(state, event)
+                    except Exception:  # noqa: BLE001 - fail this runtime only
+                        logger.error("Failed to process YaoYao group runtime event")
+                        await self._finalize_state_locked(
+                            state,
+                            outcome="failed",
+                            error="Gateway control event could not be persisted",
+                            interrupt=True,
+                        )
+            except Exception:  # noqa: BLE001 - isolate this runtime only
+                logger.error("Failed to lock YaoYao group runtime event")
+                await self._finalize_state(
+                    state,
+                    outcome="failed",
+                    error="Gateway control event could not be persisted",
+                    interrupt=True,
+                )
+            finally:
+                state.pending_events.task_done()
+
+    async def _apply_runtime_event(
+        self, state: _RuntimeState, event: _GatewayEvent
+    ) -> None:
+        event_type = event.event_type
+        payload = event.payload
+        if state.finalizing:
+            return
+        self._require_safe_control_payload(event_type, payload)
+        if state.complete_seen and event_type != "session.info":
+            return
+        if event_type == "message.start":
+            if state.reply_mode == "mentioned" or state.automatic_published:
+                await self._persist_message_snapshot(state, status="streaming")
+            return
+        if event_type in {"message.delta", "reasoning.delta"}:
+            text = payload.get("text")
+            if not isinstance(text, str):
+                raise GroupOrchestratorError("Gateway delta text is invalid")
+            if event_type == "message.delta":
+                state.content = self._append_bounded_text(
+                    state.content, text, "content"
+                )
+            else:
+                state.reasoning = self._append_bounded_text(
+                    state.reasoning, text, "reasoning"
+                )
+            state.stream_dirty = True
+            now = asyncio.get_running_loop().time()
+            if state.last_stream_flush == 0.0 or (
+                now - state.last_stream_flush >= STREAM_FLUSH_INTERVAL
+            ):
+                await self._flush_streaming(state)
+            return
+        if event_type.startswith("tool."):
+            await self._publish_automatic_state(state)
+            await self._flush_streaming(state)
+            self._merge_tool_event(state, event_type, payload)
+            await self._persist_message_snapshot(state, status="streaming")
+            return
+        if event_type == "approval.request":
+            await self._publish_automatic_state(state)
+            await self._flush_streaming(state)
+            await self._persist_approval(state, payload)
+            return
+        if event_type == "clarify.request":
+            await self._publish_automatic_state(state)
+            await self._flush_streaming(state)
+            await self._persist_clarification(state, payload)
+            return
+        if event_type == "clarify.expire":
+            await self._publish_automatic_state(state)
+            await self._flush_streaming(state)
+            await self._expire_clarification(state, payload)
+            return
+        if event_type == "message.complete":
+            if state.complete_seen:
+                return
+            await self._flush_streaming(state)
+            await self._expire_pending_interactions(state)
+            status = payload.get("status")
+            outcomes = {
+                "complete": "completed",
+                "error": "failed",
+                "interrupted": "interrupted",
+            }
+            outcome = outcomes.get(status) if isinstance(status, str) else None
+            if outcome is None:
+                outcome = "failed"
+                state.terminal_error = "Invalid message.complete status"
+            else:
+                raw_error = payload.get("error")
+                if outcome != "completed" and isinstance(raw_error, str):
+                    state.terminal_error = self._bounded_error(raw_error)
+            for tool in state.tool_state:
+                if tool.get("status") == "running":
+                    tool["status"] = "completed" if outcome == "completed" else "failed"
+            final_text = payload.get("text", payload.get("content"))
+            if isinstance(final_text, str):
+                state.content = self._bounded_text(final_text, "content")
+            final_reasoning = payload.get("reasoning")
+            if isinstance(final_reasoning, str):
+                state.reasoning = self._bounded_text(final_reasoning, "reasoning")
+            automatic_unpublished = (
+                state.reply_mode == "automatic" and not state.automatic_published
+            )
+            if state.reply_mode == "automatic":
+                state.content = _sanitize_no_reply_text(state.content)
+                state.reasoning = _sanitize_no_reply_text(state.reasoning)
+            suppress_automatic = automatic_unpublished and (
+                outcome != "completed" or not state.content.strip()
+            )
+            if suppress_automatic:
+                state.content = ""
+                state.reasoning = ""
+                state.tool_state = []
+            elif automatic_unpublished:
+                state.automatic_published = True
+            # GroupStore only accepts completed as a direct running-run
+            # terminal upsert. Failure/interruption must first persist the
+            # partial body as streaming; settle_run atomically maps both run
+            # and response message to the final failed/interrupted status.
+            message_status = "completed" if outcome == "completed" else "streaming"
+            try:
+                await self._persist_message_snapshot(
+                    state,
+                    status=message_status,
+                    error=state.terminal_error,
+                    publish=not suppress_automatic,
+                )
+                state.stream_dirty = False
+            except Exception:
+                await self._finalize_state_locked(
+                    state,
+                    outcome="failed",
+                    error="Terminal Agent message could not be persisted",
+                    interrupt=True,
+                )
+                return
+            state.terminal_status = outcome
+            state.complete_seen = True
+            state.complete_index = state.event_index
+            state.grace_task = asyncio.create_task(
+                self._await_terminal_info(state),
+                name=f"yaoyao-group-grace-{state.generation}",
+            )
+            return
+        if event_type == "session.info":
+            await self._flush_streaming(state)
+            if (
+                payload.get("running") is not False
+                or not state.complete_seen
+                or state.event_index <= state.complete_index
+            ):
+                return
+            profile_name = payload.get("profile_name")
+            if profile_name != state.profile:
+                await self._finalize_state_locked(
+                    state,
+                    outcome="failed",
+                    error="Final session profile is missing",
+                    stored_session_id=state.session_stored_id,
+                    interrupt=True,
+                )
+                return
+            rotated = payload.get("stored_session_id")
+            if not isinstance(rotated, str) or not rotated:
+                await self._finalize_state_locked(
+                    state,
+                    outcome="failed",
+                    error="Final session identity is missing",
+                    stored_session_id=state.session_stored_id,
+                    interrupt=True,
+                )
+                return
+            state.terminal_info_received.set()
+            await self._finalize_state_locked(
+                state,
+                outcome=state.terminal_status or "failed",
+                error=state.terminal_error,
+                stored_session_id=rotated,
+                interrupt=False,
+            )
+            return
+        if event_type == "error":
+            await self._flush_streaming(state)
+            await self._finalize_state_locked(
+                state,
+                outcome="failed",
+                error="Agent Gateway reported a failure",
+                interrupt=True,
+            )
+
+    @staticmethod
+    def _require_safe_control_payload(
+        event_type: str, payload: Mapping[str, object]
+    ) -> None:
+        truncated = payload.get("truncatedFields")
+        invalid = payload.get("invalidFields")
+        truncated_fields = (
+            {item for item in truncated if isinstance(item, str)}
+            if isinstance(truncated, list)
+            else set()
+        )
+        invalid_fields = (
+            {item for item in invalid if isinstance(item, str)}
+            if isinstance(invalid, list)
+            else set()
+        )
+        if payload.get("transportInvalidPayload") is True and not invalid_fields:
+            raise GroupOrchestratorError("Gateway control payload is invalid")
+        critical: set[str]
+        if event_type == "message.complete":
+            critical = {"status", "text", "reasoning"}
+        elif event_type == "session.info":
+            critical = {"running", "stored_session_id", "profile_name"}
+        elif event_type in {"approval.request", "clarify.request"}:
+            if payload.get("transportTruncated") is True:
+                raise GroupOrchestratorError(
+                    "Gateway interaction request was truncated"
+                )
+            critical = {
+                "request_id",
+                "question",
+                "command",
+                "choices",
+                "tool",
+                "name",
+            }
+        elif event_type == "clarify.expire":
+            critical = {"request_id"}
+        elif event_type.startswith("tool."):
+            critical = {"tool_id", "id"}
+            if event_type == "tool.output_risk":
+                critical |= {"risk", "redacted"}
+        elif event_type == "error":
+            critical = {"message", "error", "reason"}
+        else:
+            critical = set()
+        if critical & (truncated_fields | invalid_fields):
+            raise GroupOrchestratorError(
+                "Gateway control payload lost a critical field"
+            )
+        if payload.get("transportInvalidPayload") is True:
+            # Known-invalid non-critical diagnostics never justify trusting an
+            # approval/tool/terminal control frame as authoritative.
+            if event_type != "session.info":
+                raise GroupOrchestratorError("Gateway control payload is invalid")
+        if (
+            payload.get("transportTruncated") is True
+            and not truncated_fields
+            and event_type
+            in {
+                "message.complete",
+                "session.info",
+                "approval.request",
+                "clarify.request",
+                "clarify.expire",
+            }
+        ):
+            raise GroupOrchestratorError("Gateway control payload was replaced")
+
+    async def _flush_streaming(self, state: _RuntimeState) -> None:
+        if not state.stream_dirty:
+            return
+        if state.reply_mode == "automatic" and not state.automatic_published:
+            return
+        await self._persist_message_snapshot(state, status="streaming")
+        state.stream_dirty = False
+        state.last_stream_flush = asyncio.get_running_loop().time()
+
+    async def _persist_message_snapshot(
+        self,
+        state: _RuntimeState,
+        *,
+        status: str,
+        error: str = "",
+        publish: bool | None = None,
+    ) -> None:
+        should_publish = (
+            state.reply_mode == "mentioned" or state.automatic_published
+            if publish is None
+            else publish
+        )
+        content = (
+            _sanitize_no_reply_text(state.content)
+            if state.reply_mode == "automatic"
+            else state.content
+        )
+        reasoning = (
+            _sanitize_no_reply_text(state.reasoning)
+            if state.reply_mode == "automatic"
+            else state.reasoning
+        )
+        persisted = False
+        for delay in (*_STORE_RETRY_DELAYS, None):
+            try:
+                await self._run_state_blocking(
+                    state,
+                    self.store.upsert_agent_message,
+                    state.run_id,
+                    content=content,
+                    reasoning=reasoning,
+                    tool_state=state.tool_state,
+                    status=status,
+                    error=error,
+                    publish=should_publish,
+                )
+                persisted = True
+                break
+            except Exception:  # noqa: BLE001 - reconcile a lost Store reply
+                try:
+                    persisted = bool(
+                        await self._run_state_blocking(
+                            state,
+                            self._terminal_message_is_durable,
+                            state,
+                            content=content,
+                            reasoning=reasoning,
+                            tool_state=state.tool_state,
+                            status=status,
+                            error=error,
+                        )
+                    )
+                except Exception:  # noqa: BLE001 - retry the exact snapshot
+                    persisted = False
+                if persisted:
+                    break
+                if delay is None:
+                    break
+                await asyncio.sleep(delay)
+        if not persisted:
+            raise GroupOrchestratorError("Agent message snapshot is not durable")
+
+    async def _publish_automatic_state(self, state: _RuntimeState) -> None:
+        if state.reply_mode != "automatic" or state.automatic_published:
+            return
+        state.automatic_published = True
+        await self._persist_message_snapshot(state, status="streaming", publish=True)
+        state.stream_dirty = False
+        state.last_stream_flush = asyncio.get_running_loop().time()
+
+    @staticmethod
+    def _bounded_text(value: str, field_name: str) -> str:
+        try:
+            size = len(value.encode("utf-8"))
+        except UnicodeEncodeError as error:
+            raise GroupOrchestratorError(
+                f"Gateway {field_name} is not valid UTF-8"
+            ) from error
+        if size > MAX_MESSAGE_BYTES:
+            raise GroupOrchestratorError(
+                f"Gateway {field_name} exceeds the group message limit"
+            )
+        return value
+
+    @classmethod
+    def _append_bounded_text(cls, existing: str, delta: str, field_name: str) -> str:
+        return cls._bounded_text(existing + delta, field_name)
+
+    def _merge_tool_event(
+        self,
+        state: _RuntimeState,
+        event_type: str,
+        payload: Mapping[str, object],
+    ) -> None:
+        if payload.get("transportInvalidPayload") is True:
+            raise GroupOrchestratorError("Gateway tool payload is invalid")
+        tool_id = payload.get("tool_id", payload.get("id"))
+        tool_id = _required_string(tool_id, "tool.tool_id")
+        if tool_id == _TOOL_TRUNCATION_ID:
+            raise GroupOrchestratorError("Gateway tool id is reserved")
+        if len(tool_id.encode("utf-8")) > _TOOL_TEXT_LIMIT:
+            raise GroupOrchestratorError("Gateway tool id exceeds the limit")
+        name_value = payload.get("name")
+        name = (
+            self._bounded_tool_text(name_value)
+            if isinstance(name_value, str) and name_value
+            else None
+        )
+        existing = self._tool_state_for_id(state, tool_id)
+        tool: dict[str, object] = (
+            dict(existing)
+            if existing is not None
+            else {"id": tool_id, "name": name or "unknown", "status": "running"}
+        )
+        if name is not None:
+            tool["name"] = name
+        if "args" in payload:
+            tool["arguments"] = self._bounded_tool_value(payload["args"], "tool.args")
+        elif "args_text" in payload:
+            args_text = payload["args_text"]
+            if not isinstance(args_text, str):
+                raise GroupOrchestratorError("Gateway tool args_text is invalid")
+            tool["arguments"] = self._bounded_tool_text(args_text)
+        if event_type == "tool.output_risk":
+            output_risk, risk_preview = self._project_output_risk(payload)
+            tool["outputRisk"] = output_risk
+            tool["preview"] = risk_preview
+        preview = next(
+            (
+                value
+                for key in ("summary", "context", "result_text", "args_text")
+                if isinstance((value := payload.get(key)), str) and value
+            ),
+            None,
+        )
+        if preview is not None and event_type != "tool.output_risk":
+            tool["preview"] = self._bounded_tool_text(preview)
+        if "result" in payload:
+            tool["result"] = self._bounded_tool_value(payload["result"], "tool.result")
+        error = payload.get("error")
+        if isinstance(error, str) and error:
+            tool["error"] = self._bounded_tool_text(error)
+        duration = payload.get("duration_s")
+        if (
+            isinstance(duration, (int, float))
+            and not isinstance(duration, bool)
+            and math.isfinite(duration)
+            and duration >= 0
+        ):
+            tool["durationSeconds"] = float(duration)
+        if event_type == "tool.complete":
+            raw_status = payload.get("status")
+            tool["status"] = (
+                "failed"
+                if tool.get("error")
+                or raw_status in {"error", "failed", "cancelled", "canceled"}
+                else "completed"
+            )
+        elif existing is None:
+            tool["status"] = "running"
+        self._upsert_tool_state(state, tool)
+
+    @staticmethod
+    def _bounded_tool_text(value: str, limit: int = _TOOL_TEXT_LIMIT) -> str:
+        try:
+            encoded = value.encode("utf-8")
+        except UnicodeEncodeError as error:
+            raise GroupOrchestratorError(
+                "Gateway tool text is not valid UTF-8"
+            ) from error
+        if len(encoded) <= limit:
+            return value
+        suffix = "..."
+        prefix = encoded[: limit - len(suffix)]
+        while prefix:
+            try:
+                return prefix.decode("utf-8") + suffix
+            except UnicodeDecodeError:
+                prefix = prefix[:-1]
+        return suffix
+
+    @classmethod
+    def _bounded_tool_value(cls, value: object, field_name: str) -> object:
+        copied = _strict_json_copy(value, field_name)
+        encoded = json.dumps(
+            copied,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+            allow_nan=False,
+        ).encode("utf-8")
+        if len(encoded) <= _TOOL_VALUE_LIMIT:
+            return copied
+        return {"originalBytes": len(encoded), "truncated": True}
+
+    @classmethod
+    def _project_output_risk(
+        cls, payload: Mapping[str, object]
+    ) -> tuple[dict[str, object], str]:
+        risk_value = payload.get("risk")
+        findings_value = payload.get("findings")
+        redacted = payload.get("redacted")
+        if not isinstance(risk_value, str) or not risk_value:
+            raise GroupOrchestratorError("Gateway tool output risk is invalid")
+        if not isinstance(findings_value, list) or not all(
+            isinstance(item, str) for item in findings_value
+        ):
+            raise GroupOrchestratorError("Gateway tool output findings are invalid")
+        if not isinstance(redacted, bool):
+            raise GroupOrchestratorError("Gateway tool output redaction is invalid")
+        risk = cls._bounded_tool_text(risk_value, 256)
+        findings = [
+            cls._bounded_tool_text(item, 512)
+            for item in findings_value[:_TOOL_FINDING_LIMIT]
+        ]
+        truncated = (
+            len(findings_value) > len(findings)
+            or risk != risk_value
+            or any(
+                projected != original
+                for projected, original in zip(findings, findings_value)
+            )
+        )
+        transport_truncated = payload.get("truncatedFields")
+        if isinstance(transport_truncated, list) and "findings" in transport_truncated:
+            truncated = True
+        output_risk: dict[str, object] = {
+            "risk": risk,
+            "findings": findings,
+            "redacted": redacted,
+        }
+        if truncated:
+            output_risk["truncated"] = True
+        preview = f"Output risk: {risk}"
+        if findings:
+            preview += "; findings: " + ", ".join(findings)
+        preview += f"; redacted: {'true' if redacted else 'false'}"
+        return output_risk, cls._bounded_tool_text(preview)
+
+    @staticmethod
+    def _tool_state_for_id(
+        state: _RuntimeState, tool_id: str
+    ) -> dict[str, object] | None:
+        index = state.tool_indexes.get(tool_id)
+        return None if index is None else state.tool_state[index]
+
+    @classmethod
+    def _upsert_tool_state(cls, state: _RuntimeState, tool: dict[str, object]) -> None:
+        tool_id = str(tool["id"])
+        index = state.tool_indexes.get(tool_id)
+        candidate = [dict(item) for item in state.tool_state]
+        if index is None:
+            candidate.append(dict(tool))
+        else:
+            candidate[index] = dict(tool)
+        candidate = cls._fit_tool_state(candidate, newest_tool_id=tool_id)
+        state.tool_state = candidate
+        state.tool_indexes = {
+            str(item["id"]): offset for offset, item in enumerate(candidate)
+        }
+
+    @classmethod
+    def _fit_tool_state(
+        cls,
+        candidate: list[dict[str, object]],
+        *,
+        newest_tool_id: str,
+    ) -> list[dict[str, object]]:
+        def encoded_size(items: list[dict[str, object]]) -> int:
+            try:
+                return len(
+                    json.dumps(
+                        items,
+                        ensure_ascii=False,
+                        sort_keys=True,
+                        separators=(",", ":"),
+                        allow_nan=False,
+                    ).encode("utf-8")
+                )
+            except (TypeError, ValueError, UnicodeEncodeError) as error:
+                raise GroupOrchestratorError("Gateway tool state is invalid") from error
+
+        if (
+            len(candidate) <= _TOOL_COUNT_LIMIT
+            and encoded_size(candidate) <= _TOOL_STATE_INTERNAL_LIMIT
+        ):
+            return candidate
+
+        newest = next(
+            (item for item in candidate if item.get("id") == newest_tool_id), None
+        )
+        if newest is None:
+            raise GroupOrchestratorError("Gateway tool state merge lost its tool")
+        retained = [
+            item
+            for item in candidate
+            if item.get("id") not in {newest_tool_id, _TOOL_TRUNCATION_ID}
+        ]
+        marker = {
+            "id": _TOOL_TRUNCATION_ID,
+            "name": "hermes",
+            "status": "completed",
+            "preview": "Earlier tool state omitted by the group size limit",
+        }
+        fitted = [*retained, marker, newest]
+        while (
+            len(fitted) > _TOOL_COUNT_LIMIT
+            or encoded_size(fitted) > _TOOL_STATE_INTERNAL_LIMIT
+        ):
+            if not retained:
+                break
+            retained.pop(0)
+            fitted = [*retained, marker, newest]
+        if (
+            len(fitted) > _TOOL_COUNT_LIMIT
+            or encoded_size(fitted) > _TOOL_STATE_INTERNAL_LIMIT
+        ):
+            compact_newest: dict[str, object] = {
+                "id": newest_tool_id,
+                "name": cls._bounded_tool_text(
+                    str(newest.get("name") or "unknown"), 256
+                ),
+                "status": str(newest.get("status") or "running"),
+                "preview": cls._bounded_tool_text(
+                    str(newest.get("preview") or "Tool state truncated"), 4096
+                ),
+            }
+            output_risk = newest.get("outputRisk")
+            if isinstance(output_risk, Mapping):
+                compact_newest["outputRisk"] = {
+                    "risk": cls._bounded_tool_text(
+                        str(output_risk.get("risk") or "unknown"), 256
+                    ),
+                    "findings": [
+                        cls._bounded_tool_text(item, 512)
+                        for item in output_risk.get("findings", [])[
+                            :_TOOL_FINDING_LIMIT
+                        ]
+                        if isinstance(item, str)
+                    ],
+                    "redacted": output_risk.get("redacted") is True,
+                    "truncated": True,
+                }
+            fitted = [marker, compact_newest]
+        if encoded_size(fitted) > MAX_TOOL_STATE_BYTES:
+            raise GroupOrchestratorError("Gateway tool state exceeds the limit")
+        return fitted
+
+    async def _store_retry(
+        self,
+        state: _RuntimeState,
+        function: Callable[..., object],
+        *args: object,
+        **kwargs: object,
+    ) -> object:
+        last_error: Exception | None = None
+        for delay in (*_STORE_RETRY_DELAYS, None):
+            try:
+                return await self._run_state_blocking(state, function, *args, **kwargs)
+            except Exception as error:  # noqa: BLE001 - exact Store APIs replay
+                last_error = error
+                if delay is None:
+                    break
+                await asyncio.sleep(delay)
+        if last_error is None:
+            raise GroupOrchestratorError("Store operation failed")
+        raise last_error
+
+    async def _persist_approval(
+        self, state: _RuntimeState, payload: Mapping[str, object]
+    ) -> None:
+        if payload.get("transportInvalidPayload") is True:
+            raise GroupOrchestratorError("Gateway approval payload is invalid")
+        raw_choices = payload.get("choices")
+        if raw_choices is None:
+            choices = ["deny"]
+        elif (
+            not isinstance(raw_choices, list)
+            or not raw_choices
+            or not all(
+                isinstance(choice, str) and choice in _APPROVAL_CHOICES
+                for choice in raw_choices
+            )
+        ):
+            raise GroupOrchestratorError("Gateway approval choices are invalid")
+        else:
+            choices = list(dict.fromkeys(raw_choices))
+        safe_payload = dict(payload)
+        safe_payload["choices"] = choices
+        state.approval_ordinal += 1
+        interaction_id = "approval-" + str(
+            uuid.uuid5(
+                uuid.NAMESPACE_URL,
+                f"{state.run_id}:{state.generation}:approval:{state.approval_ordinal}",
+            )
+        )
+        interaction = await self._store_retry(
+            state,
+            self.store.create_gateway_interaction,
+            state.run_id,
+            kind="approval",
+            gateway_interaction_id=interaction_id,
+            payload=safe_payload,
+        )
+        if not isinstance(interaction, Mapping):
+            raise GroupOrchestratorError("Stored approval is invalid")
+        local_id = _required_string(interaction.get("id"), "interaction.id")
+        state.pending_interaction_ids.add(local_id)
+
+    async def _persist_clarification(
+        self, state: _RuntimeState, payload: Mapping[str, object]
+    ) -> None:
+        if payload.get("transportInvalidPayload") is True:
+            raise GroupOrchestratorError("Gateway clarification payload is invalid")
+        request_id = _required_string(
+            payload.get("request_id"), "clarification.request_id"
+        )
+        interaction = await self._store_retry(
+            state,
+            self.store.create_gateway_interaction,
+            state.run_id,
+            kind="clarification",
+            gateway_interaction_id=request_id,
+            payload=dict(payload),
+        )
+        if not isinstance(interaction, Mapping):
+            raise GroupOrchestratorError("Stored clarification is invalid")
+        local_id = _required_string(interaction.get("id"), "interaction.id")
+        state.clarification_ids[request_id] = local_id
+        state.pending_interaction_ids.add(local_id)
+
+    async def _expire_clarification(
+        self, state: _RuntimeState, payload: Mapping[str, object]
+    ) -> None:
+        request_id = _required_string(
+            payload.get("request_id"), "clarification.request_id"
+        )
+        local_id = state.clarification_ids.get(request_id)
+        if local_id is None:
+            return
+        await self._store_retry(state, self.store.expire_interaction, local_id)
+        state.pending_interaction_ids.discard(local_id)
+
+    async def _expire_pending_interactions(self, state: _RuntimeState) -> None:
+        for interaction_id in tuple(sorted(state.pending_interaction_ids)):
+            await self._store_retry(
+                state, self.store.expire_interaction, interaction_id
+            )
+            state.pending_interaction_ids.discard(interaction_id)
+
+    def _terminal_message_is_durable(
+        self,
+        state: _RuntimeState,
+        *,
+        content: str,
+        reasoning: str,
+        tool_state: list[dict[str, object]],
+        status: str,
+        error: str,
+    ) -> bool:
+        """Reconcile a lost Store reply without accepting another run's write."""
+
+        run = self.store.get_run(state.run_id)
+        if not isinstance(run, Mapping):
+            return False
+        response_message_id = run.get("responseMessageId")
+        if not isinstance(response_message_id, str) or not response_message_id:
+            return False
+        if (
+            run.get("id") != state.run_id
+            or run.get("roomId") != state.room_id
+            or run.get("agentId") != state.agent_id
+            or run.get("runtimeSessionId") != state.runtime_id
+            or run.get("status") not in {"running", "awaiting_input"}
+        ):
+            return False
+        message = self.store.get_message(response_message_id)
+        if not isinstance(message, Mapping):
+            return False
+        return (
+            message.get("id") == response_message_id
+            and message.get("roomId") == state.room_id
+            and message.get("senderKind") == "agent"
+            and message.get("senderId") == state.agent_id
+            and message.get("content") == content
+            and message.get("reasoning") == reasoning
+            and message.get("toolState") == tool_state
+            and message.get("status") == status
+            and message.get("error") == error
+        )
+
+    async def _run_state_blocking(
+        self,
+        state: _RuntimeState,
+        function: Callable[..., object],
+        *args: object,
+        **kwargs: object,
+    ) -> object:
+        """Track a blocking call so shutdown never abandons its worker thread."""
+
+        return await self._run_states_blocking([state], function, *args, **kwargs)
+
+    @staticmethod
+    async def _run_states_blocking(
+        states: list[_RuntimeState],
+        function: Callable[..., object],
+        *args: object,
+        **kwargs: object,
+    ) -> object:
+        """Shield one worker call and expose it to every affected runtime."""
+
+        task: asyncio.Task[object] = asyncio.create_task(
+            asyncio.to_thread(function, *args, **kwargs)
+        )
+        unique_states = tuple({id(state): state for state in states}.values())
+        for state in unique_states:
+            state.inflight_calls.add(task)
+
+        def finished(done: asyncio.Task[object]) -> None:
+            for state in unique_states:
+                state.inflight_calls.discard(done)
+
+        task.add_done_callback(finished)
+        return await asyncio.shield(task)
+
+    async def _await_terminal_info(self, state: _RuntimeState) -> None:
+        """Apply the grace timeout only after a terminal message.complete."""
+
+        terminal_wait = asyncio.create_task(state.terminal_info_received.wait())
+        finished_wait = asyncio.create_task(state.finished.wait())
+        try:
+            done, pending = await asyncio.wait(
+                {terminal_wait, finished_wait},
+                timeout=self._completion_timeout,
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+            for waiter in pending:
+                waiter.cancel()
+            if terminal_wait in done or finished_wait in done:
+                return
+            await self._finalize_state(
+                state,
+                outcome=(
+                    state.terminal_status
+                    if state.terminal_status in {"failed", "interrupted"}
+                    else "interrupted"
+                ),
+                error=state.terminal_error or "Final session state timed out",
+                interrupt=True,
+            )
+        finally:
+            for waiter in (terminal_wait, finished_wait):
+                if not waiter.done():
+                    waiter.cancel()
+
+    async def _finalize_state(
+        self,
+        state: _RuntimeState,
+        *,
+        outcome: str,
+        error: str,
+        interrupt: bool,
+        stored_session_id: str | None = None,
+    ) -> None:
+        async with state.mutation_lock:
+            await self._finalize_state_locked(
+                state,
+                outcome=outcome,
+                error=error,
+                interrupt=interrupt,
+                stored_session_id=stored_session_id,
+            )
+
+    async def _finalize_state_locked(
+        self,
+        state: _RuntimeState,
+        *,
+        outcome: str,
+        error: str,
+        interrupt: bool,
+        stored_session_id: str | None = None,
+    ) -> None:
+        async with state.finalize_lock:
+            if state.finished.is_set():
+                return
+            state.finalizing = True
+            current_task = asyncio.current_task()
+            pending_calls = tuple(state.inflight_calls)
+            if pending_calls:
+                await asyncio.gather(*pending_calls, return_exceptions=True)
+            gateway = self._required_gateway()
+            if interrupt:
+                try:
+                    await self._run_state_blocking(
+                        state, gateway.interrupt, state.runtime_id
+                    )
+                except Exception:  # noqa: BLE001 - settle remains mandatory
+                    logger.exception("Failed to interrupt YaoYao group runtime")
+            expected_stored = (
+                state.session_stored_id
+                if state.prompt_committed
+                else state.expected_stored_id
+            )
+            rotated_stored = stored_session_id or state.session_stored_id
+            settled = False
+            for delay in (*_STORE_RETRY_DELAYS, None):
+                try:
+                    await self._run_state_blocking(
+                        state,
+                        self.store.settle_run,
+                        state.run_id,
+                        runtime_session_id=state.runtime_id,
+                        expected_stored_session_id=expected_stored,
+                        stored_session_id=rotated_stored,
+                        outcome=outcome,
+                        error=self._bounded_error(error),
+                    )
+                    settled = True
+                    break
+                except Exception:  # noqa: BLE001 - reconcile before retrying
+                    if await self._run_is_durably_terminal(state.run_id):
+                        settled = True
+                        break
+                    if delay is None:
+                        logger.exception("Failed to settle YaoYao group runtime")
+                        break
+                    await asyncio.sleep(delay)
+            if not settled:
+                state.settle_error = GroupOrchestratorError(
+                    "Durable runtime settlement failed"
+                )
+                return
+
+            state.settle_error = None
+            self._remove_runtime(state)
+            try:
+                await self._run_state_blocking(state, gateway.close, state.runtime_id)
+            except Exception:  # noqa: BLE001 - durable state is already terminal
+                logger.exception("Failed to close settled YaoYao group runtime")
+            if outcome == "completed":
+                try:
+                    cascade = await self._run_state_blocking(
+                        state, self.store.complete_cascade, state.run_id
+                    )
+                except Exception:  # noqa: BLE001 - source is already durably complete
+                    logger.error("Pending YaoYao group cascade remains durable")
+                    self.wake()
+                else:
+                    if (
+                        isinstance(cascade, Mapping)
+                        and isinstance(cascade.get("runCount"), int)
+                        and not isinstance(cascade.get("runCount"), bool)
+                        and cascade["runCount"] > 0
+                    ):
+                        self.wake()
+            state.finished.set()
+            state.terminal_info_received.set()
+            if state.event_task is not None and state.event_task is not current_task:
+                if not state.event_task.done():
+                    state.event_task.cancel()
+                await asyncio.gather(state.event_task, return_exceptions=True)
+            if state.grace_task is not None and state.grace_task is not current_task:
+                if not state.grace_task.done():
+                    state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+
+    def _required_gateway(self) -> _Gateway:
+        if self._gateway is None:
+            raise GroupOrchestratorError("Gateway adapter is not started")
+        return self._gateway
+
+    @staticmethod
+    def _bounded_error(value: str) -> str:
+        encoded = value.encode("utf-8", errors="replace")
+        if len(encoded) <= _ERROR_TEXT_LIMIT:
+            return value
+        shortened = encoded[: _ERROR_TEXT_LIMIT - 3]
+        while shortened:
+            try:
+                return shortened.decode("utf-8") + "..."
+            except UnicodeDecodeError:
+                shortened = shortened[:-1]
+        return "..."
+
+
+__all__ = [
+    "DEFAULT_COMPLETION_TIMEOUT",
+    "DEFAULT_EVENT_QUEUE_SIZE",
+    "GroupOrchestrator",
+    "GroupOrchestratorError",
+    "build_run_prompt",
+]

+ 1135 - 0
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -0,0 +1,1135 @@
+"""Authenticated REST adapter for the authoritative Yaoyao Group store."""
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+import hashlib
+import importlib
+import inspect
+import logging
+import sqlite3
+import sys
+import threading
+from importlib.machinery import ModuleSpec
+from pathlib import Path
+from types import ModuleType
+from typing import Any, Callable
+from uuid import UUID
+
+from fastapi import APIRouter, Path as APIPath, Query, Request, WebSocket
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from fastapi.routing import APIRoute
+
+
+logger = logging.getLogger(__name__)
+_PLUGIN_NAME = "yaoyao"
+_agent_name_resolver: Callable[[str], str] = lambda _profile: ""
+
+_DASHBOARD_DIR = Path(__file__).resolve().parent
+_LOCAL_PACKAGE = (
+    "_hermes_yaoyao_group_"
+    + hashlib.sha256(str(_DASHBOARD_DIR).encode("utf-8")).hexdigest()[:24]
+)
+
+
+def _log_runtime_failure(message: str, error: BaseException) -> None:
+    """Log lifecycle context and type without attacker-controlled exception text."""
+
+    logger.error("%s [errorType=%s]", message, type(error).__name__)
+
+
+def _plugin_runtime_enabled() -> bool:
+    """Mirror the core HTTP plugin gate for the group WebSocket channel."""
+
+    try:
+        from hermes_cli import web_server
+        from hermes_cli.plugins_cmd import _get_disabled_set, _get_enabled_set
+
+        enabled_set = _get_enabled_set()
+        disabled_set = _get_disabled_set()
+        plugin = next(
+            (
+                candidate
+                for candidate in web_server._get_dashboard_plugins()
+                if candidate.get("name") == _PLUGIN_NAME
+            ),
+            None,
+        )
+    except Exception:
+        return False
+
+    if plugin is None:
+        return False
+    source = plugin.get("source")
+    if source == "bundled":
+        return _PLUGIN_NAME not in disabled_set
+    if source == "user":
+        return _PLUGIN_NAME in enabled_set and _PLUGIN_NAME not in disabled_set
+    return False
+
+
+def _ensure_local_package() -> ModuleType:
+    """Return the stable private package assigned to this resolved directory."""
+    package = ModuleType(_LOCAL_PACKAGE)
+    package.__package__ = _LOCAL_PACKAGE
+    package.__path__ = [str(_DASHBOARD_DIR)]
+    package_spec = ModuleSpec(_LOCAL_PACKAGE, loader=None, is_package=True)
+    package_spec.submodule_search_locations = [str(_DASHBOARD_DIR)]
+    package.__spec__ = package_spec
+    shared = sys.modules.setdefault(_LOCAL_PACKAGE, package)
+    paths = getattr(shared, "__path__", None)
+    if (
+        not isinstance(shared, ModuleType)
+        or not paths
+        or Path(paths[0]).resolve() != _DASHBOARD_DIR
+    ):
+        raise ImportError("Yaoyao Group private package identity is invalid")
+    return shared
+
+
+_package = _ensure_local_package()
+_protocol = importlib.import_module(f"{_LOCAL_PACKAGE}.group_protocol")
+_store_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_store")
+_stream_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_stream")
+
+
+PROTOCOL_VERSION = _protocol.PROTOCOL_VERSION
+EVENT_TYPES = _protocol.EVENT_TYPES
+MAX_MESSAGE_PAGE_SIZE = _protocol.MAX_MESSAGE_PAGE_SIZE
+limits_payload = _protocol.limits_payload
+normalize_interaction_id = _protocol.normalize_interaction_id
+
+AddAgentRequest = _protocol.AddAgentRequest
+ApprovalRequest = _protocol.ApprovalRequest
+ClarificationRequest = _protocol.ClarificationRequest
+CreateRoomRequest = _protocol.CreateRoomRequest
+RequestIDRequest = _protocol.RequestIDRequest
+SendMessageRequest = _protocol.SendMessageRequest
+UpdateAgentRequest = _protocol.UpdateAgentRequest
+UpdateRoomRequest = _protocol.UpdateRoomRequest
+
+GroupStore = _store_module.GroupStore
+GroupStoreError = _store_module.GroupStoreError
+GroupNotFoundError = _store_module.GroupNotFoundError
+GroupConflictError = _store_module.GroupConflictError
+IdempotencyConflict = _store_module.IdempotencyConflict
+
+
+class GroupAPIError(RuntimeError):
+    """A stable public REST failure independent of internal exception text."""
+
+    def __init__(
+        self,
+        code: str,
+        message: str,
+        status_code: int,
+        details: dict[str, object] | None = None,
+    ) -> None:
+        super().__init__(message)
+        self.code = code
+        self.message = message
+        self.status_code = status_code
+        self.details = details or {}
+
+
+class RuntimeUnavailableError(GroupAPIError):
+    def __init__(self) -> None:
+        super().__init__(
+            "runtime_unavailable",
+            "Group runtime is unavailable",
+            503,
+        )
+
+
+class GroupInvalidRequest(GroupAPIError):
+    """A trusted Store or protocol input validator rejected the request."""
+
+    def __init__(self, message: str) -> None:
+        super().__init__("invalid_request", message, 400)
+
+
+class GroupStorageAPIError(GroupAPIError):
+    """A raw SQLite failure hidden behind the public storage boundary."""
+
+    def __init__(self) -> None:
+        super().__init__("storage_error", "Group storage failed", 500)
+
+
+class GroupInternalAPIError(GroupAPIError):
+    """An unexpected implementation failure safe for public serialization."""
+
+    def __init__(self) -> None:
+        super().__init__("internal_error", "Internal server error", 500)
+
+
+def _error_response(error: GroupAPIError) -> JSONResponse:
+    return JSONResponse(
+        status_code=error.status_code,
+        content={
+            "detail": {
+                "code": error.code,
+                "message": error.message,
+                "details": error.details,
+            }
+        },
+    )
+
+
+def _validation_details(error: RequestValidationError) -> dict[str, object]:
+    errors = [
+        {
+            "location": list(item.get("loc", ())),
+            "message": item.get("msg", "Invalid value"),
+            "type": item.get("type", "validation_error"),
+        }
+        for item in error.errors()
+    ]
+    return {"errors": errors}
+
+
+def _public_error(error: Exception) -> GroupAPIError:
+    if isinstance(error, GroupAPIError):
+        return error
+    if isinstance(error, RequestValidationError):
+        return GroupAPIError(
+            "validation_error",
+            "Request validation failed",
+            422,
+            _validation_details(error),
+        )
+    if isinstance(error, IdempotencyConflict):
+        return GroupAPIError("idempotency_conflict", str(error), 409)
+    if isinstance(error, GroupNotFoundError):
+        return GroupAPIError("not_found", str(error), 404)
+    if isinstance(error, GroupConflictError):
+        return GroupAPIError("conflict", str(error), 409)
+    if isinstance(error, GroupStoreError):
+        return GroupStorageAPIError()
+    if isinstance(error, sqlite3.Error):
+        return GroupStorageAPIError()
+    return GroupInternalAPIError()
+
+
+class GroupAPIRoute(APIRoute):
+    """Apply the plugin error envelope to validation and endpoint failures."""
+
+    def get_route_handler(self) -> Callable[[Request], Any]:
+        original = super().get_route_handler()
+
+        async def wrapped(request: Request):
+            try:
+                return await original(request)
+            except Exception as error:
+                public = _public_error(error)
+                return _error_response(public)
+
+        return wrapped
+
+
+class _UnavailableRuntime:
+    async def wake(self) -> None:
+        """A missing orchestrator is harmless until Task 8 binds one."""
+
+    async def create_room(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def update_room(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def archive_room(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def add_agent(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def update_agent(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def delete_agent(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def send_message(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def interrupt_agent(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def respond_approval(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+    async def respond_clarification(self, **_: object) -> dict[str, object]:
+        raise _package._yaoyao_group_runtime_unavailable_error()
+
+
+def _default_orchestrator_factory(store: object) -> object:
+    """Construct the runtime lazily so importing the plugin has no side effects."""
+
+    module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_orchestrator")
+    return module.GroupOrchestrator(store, work_enabled=_plugin_runtime_enabled)
+
+
+class _RuntimeManager:
+    """Own exactly one orchestrator for one nested Router lifespan generation."""
+
+    def __init__(self) -> None:
+        self._lock: asyncio.Lock | None = None
+        self._leases = 0
+        self._orchestrator: object | None = None
+        self._accepting = False
+        self._starting = False
+        self._releasing = False
+        self._generation = 0
+        self._inflight = 0
+        self._inflight_zero: asyncio.Event | None = None
+        self._start_done: asyncio.Event | None = None
+        self._release_done: asyncio.Event | None = None
+        self._release_task: asyncio.Task[None] | None = None
+
+    def _current_lock(self) -> asyncio.Lock:
+        if self._lock is None:
+            self._lock = asyncio.Lock()
+        return self._lock
+
+    async def acquire(self) -> None:
+        lock = self._current_lock()
+        while True:
+            async with lock:
+                if self._releasing:
+                    wait_for = self._release_done
+                elif self._starting:
+                    wait_for = self._start_done
+                elif self._leases:
+                    self._leases += 1
+                    return
+                else:
+                    wait_for = None
+                    self._generation += 1
+                    generation = self._generation
+                    self._leases = 1
+                    self._starting = True
+                    self._accepting = False
+                    self._inflight = 0
+                    self._inflight_zero = asyncio.Event()
+                    self._inflight_zero.set()
+                    self._start_done = asyncio.Event()
+                    if _package._yaoyao_group_runtime_override:
+                        self._starting = False
+                        self._accepting = True
+                        self._start_done.set()
+                        return
+            if wait_for is not None:
+                await wait_for.wait()
+                continue
+            break
+
+        orchestrator: object | None = None
+        try:
+            store = _store_instance()
+            factory = _package._yaoyao_group_orchestrator_factory
+
+            def adopt(created: object) -> None:
+                nonlocal orchestrator
+                orchestrator = created
+
+            await self._run_owned_start_worker(factory, store, adopt=adopt)
+            start = getattr(orchestrator, "start")
+            await self._run_owned_start(start(), cancel_worker=True)
+        except asyncio.CancelledError as start_cancellation:
+            cleanup_cancellation: asyncio.CancelledError | None = None
+            try:
+                await self._cleanup_failed_start(orchestrator)
+            except asyncio.CancelledError as error:
+                cleanup_cancellation = error
+            finally:
+                async with lock:
+                    if self._generation == generation and self._starting:
+                        self._leases = 0
+                        self._starting = False
+                        self._reset_generation()
+                        if self._start_done is not None:
+                            self._start_done.set()
+                            self._start_done = None
+            raise cleanup_cancellation or start_cancellation
+        except BaseException as error:  # noqa: BLE001 - isolate plugin startup
+            cleanup_cancellation = None
+            try:
+                await self._cleanup_failed_start(orchestrator)
+            except asyncio.CancelledError as cancelled:
+                cleanup_cancellation = cancelled
+            async with lock:
+                _log_runtime_failure("YaoYao group runtime startup failed", error)
+                if self._generation == generation and self._starting:
+                    if cleanup_cancellation is not None:
+                        self._leases = 0
+                        self._reset_generation()
+                    else:
+                        self._starting = False
+                    if self._start_done is not None:
+                        self._start_done.set()
+                        self._start_done = None
+            if cleanup_cancellation is not None:
+                raise cleanup_cancellation
+            return
+
+        async with lock:
+            if self._generation != generation or not self._starting:
+                await self._cleanup_failed_start(orchestrator)
+                return
+            self._orchestrator = orchestrator
+            with _package._yaoyao_group_store_lock:
+                if _package._yaoyao_group_store is store:
+                    _package._yaoyao_group_store_ready = True
+            _publish_runtime(orchestrator)
+            self._starting = False
+            self._accepting = True
+            if self._start_done is not None:
+                self._start_done.set()
+                self._start_done = None
+
+    async def release(self) -> None:
+        lock = self._current_lock()
+        cancellation: asyncio.CancelledError | None = None
+        while True:
+            try:
+                await lock.acquire()
+                break
+            except asyncio.CancelledError as error:
+                if cancellation is None:
+                    cancellation = error
+                if not self._discard_current_cancellation():
+                    raise
+        release_task: asyncio.Task[None] | None = None
+        try:
+            if self._releasing:
+                release_task = self._release_task
+            elif self._leases:
+                self._leases -= 1
+                if self._leases == 0:
+                    self._releasing = True
+                    self._accepting = False
+                    self._release_done = asyncio.Event()
+                    generation = self._generation
+                    orchestrator = self._orchestrator
+                    release_done = self._release_done
+                    if not _package._yaoyao_group_runtime_override:
+                        _publish_runtime(_UnavailableRuntime())
+                    release_task = asyncio.create_task(
+                        self._release_generation(
+                            generation=generation,
+                            orchestrator=orchestrator,
+                            inflight_zero=self._inflight_zero,
+                            release_done=release_done,
+                        ),
+                        name=f"yaoyao-group-release-{generation}",
+                    )
+                    self._release_task = release_task
+        finally:
+            lock.release()
+        if release_task is not None:
+            try:
+                await self._wait_for_owned_task(release_task)
+            except BaseException:
+                if cancellation is not None:
+                    raise cancellation
+                raise
+        if cancellation is not None:
+            raise cancellation
+
+    async def _release_generation(
+        self,
+        *,
+        generation: int,
+        orchestrator: object | None,
+        inflight_zero: asyncio.Event | None,
+        release_done: asyncio.Event,
+    ) -> None:
+        try:
+            await self._wait_for_inflight_release(inflight_zero)
+            if orchestrator is not None:
+                await self._shutdown_owned_runtime(orchestrator, generation)
+        except BaseException as error:  # noqa: BLE001 - host shutdown must continue
+            _log_runtime_failure("YaoYao group runtime shutdown failed", error)
+        finally:
+            await self._finish_owned_release(
+                generation=generation,
+                orchestrator=orchestrator,
+                release_done=release_done,
+            )
+
+    @staticmethod
+    async def _wait_for_owned_task(task: asyncio.Task[object]) -> object:
+        """Return only after a manager-owned task, preserving caller cancellation."""
+
+        cancellation: asyncio.CancelledError | None = None
+        while not task.done():
+            try:
+                await asyncio.shield(task)
+            except asyncio.CancelledError as error:
+                if task.done():
+                    break
+                if cancellation is None:
+                    cancellation = error
+        try:
+            result = task.result()
+        except BaseException:
+            if cancellation is not None:
+                raise cancellation
+            raise
+        if cancellation is not None:
+            raise cancellation
+        return result
+
+    @staticmethod
+    def _discard_current_cancellation() -> bool:
+        """Clear cancellation counts only for cleanup tasks that intentionally persist."""
+
+        task = asyncio.current_task()
+        cancelling = getattr(task, "cancelling", None)
+        uncancel = getattr(task, "uncancel", None)
+        if not callable(cancelling) or not callable(uncancel):
+            return False
+        had_cancellation = bool(cancelling())
+        while cancelling():
+            uncancel()
+        return had_cancellation
+
+    async def _wait_for_inflight_release(
+        self, inflight_zero: asyncio.Event | None
+    ) -> None:
+        if inflight_zero is None:
+            return
+        while not inflight_zero.is_set():
+            try:
+                await inflight_zero.wait()
+            except asyncio.CancelledError:
+                if not self._discard_current_cancellation():
+                    raise
+
+    async def _shutdown_owned_runtime(
+        self, orchestrator: object, generation: int
+    ) -> None:
+        """Shield shutdown from release cancellation, including loop teardown."""
+
+        shutdown_task = asyncio.create_task(
+            self._run_owned_shutdown(orchestrator),
+            name=f"yaoyao-group-runtime-shutdown-{generation}",
+        )
+        while not shutdown_task.done():
+            try:
+                await asyncio.shield(shutdown_task)
+            except asyncio.CancelledError:
+                self._discard_current_cancellation()
+        shutdown_task.result()
+
+    async def _run_owned_shutdown(self, orchestrator: object) -> None:
+        while True:
+            try:
+                await getattr(orchestrator, "shutdown")()
+                return
+            except asyncio.CancelledError:
+                if not self._discard_current_cancellation():
+                    raise
+
+    async def _finish_owned_release(
+        self,
+        *,
+        generation: int,
+        orchestrator: object | None,
+        release_done: asyncio.Event,
+    ) -> None:
+        lock = self._current_lock()
+        while True:
+            try:
+                await lock.acquire()
+                break
+            except asyncio.CancelledError:
+                if not self._discard_current_cancellation():
+                    raise
+        try:
+            if (
+                self._generation == generation
+                and self._orchestrator is orchestrator
+                and self._releasing
+            ):
+                self._orchestrator = None
+                self._releasing = False
+                self._inflight = 0
+                self._inflight_zero = None
+                self._release_task = None
+                self._reset_generation()
+            if self._release_done is release_done:
+                self._release_done = None
+        finally:
+            release_done.set()
+            lock.release()
+
+    async def dispatch(self, method: str, **kwargs: object) -> object:
+        """Lease one published runtime through the complete request callback."""
+
+        runtime = await self._begin_operation(require_runtime=True)
+
+        async def invoke() -> object:
+            callback = getattr(runtime, method)
+            is_async = inspect.iscoroutinefunction(
+                callback
+            ) or inspect.iscoroutinefunction(getattr(callback, "__call__", None))
+            if is_async:
+                result = callback(**kwargs)
+            else:
+                result = await asyncio.to_thread(callback, **kwargs)
+            if inspect.isawaitable(result):
+                return await result
+            return result
+
+        return await self._run_owned_operation(
+            invoke(), name=f"yaoyao-group-dispatch-{method}"
+        )
+
+    async def dispatch_store(
+        self,
+        method: str,
+        *args: object,
+        **kwargs: object,
+    ) -> object:
+        """Lease the complete blocking Store read through its worker return."""
+
+        await self._begin_operation(require_runtime=False)
+        return await self._run_owned_operation(
+            asyncio.to_thread(_invoke_store, method, *args, **kwargs),
+            name=f"yaoyao-group-store-{method}",
+        )
+
+    async def _begin_operation(self, *, require_runtime: bool) -> object | None:
+        lock = self._current_lock()
+        async with lock:
+            override = bool(_package._yaoyao_group_runtime_override)
+            if self._starting or self._releasing or not self._accepting:
+                raise _package._yaoyao_group_runtime_unavailable_error()
+            runtime = _package._yaoyao_group_runtime if override else self._orchestrator
+            if require_runtime and (
+                runtime is None or isinstance(runtime, _UnavailableRuntime)
+            ):
+                raise _package._yaoyao_group_runtime_unavailable_error()
+            self._inflight += 1
+            if self._inflight_zero is None:
+                self._inflight_zero = asyncio.Event()
+            self._inflight_zero.clear()
+            return runtime
+
+    async def _run_owned_operation(self, operation: object, *, name: str) -> object:
+        if not inspect.isawaitable(operation):
+            raise TypeError("leased operation must be awaitable")
+        owned_task = asyncio.create_task(operation, name=name)
+
+        def finished(done: asyncio.Task[object]) -> None:
+            try:
+                if not done.cancelled():
+                    done.exception()
+            except asyncio.CancelledError:
+                pass
+            self._inflight -= 1
+            if self._inflight == 0 and self._inflight_zero is not None:
+                self._inflight_zero.set()
+
+        owned_task.add_done_callback(finished)
+        return await asyncio.shield(owned_task)
+
+    async def stream_store(
+        self, websocket: WebSocket, stream: Callable[..., object]
+    ) -> object:
+        """Lease a complete stream; closing invalidates its Store provider."""
+
+        await self._begin_operation(require_runtime=False)
+        generation = self._generation
+
+        async def invoke() -> object:
+            try:
+                result = stream(
+                    websocket, lambda: self.store_for_generation(generation)
+                )
+                if inspect.isawaitable(result):
+                    return await result
+                return result
+            except RuntimeUnavailableError:
+                await websocket.close(code=1013, reason="Group runtime is unavailable")
+                return None
+
+        return await self._run_owned_operation(
+            invoke(), name=f"yaoyao-group-stream-{generation}"
+        )
+
+    def store_for_generation(self, generation: int) -> object:
+        """Resolve Store only while a WebSocket still belongs to this generation."""
+
+        if (
+            generation != self._generation
+            or self._starting
+            or self._releasing
+            or not self._accepting
+        ):
+            raise _package._yaoyao_group_runtime_unavailable_error()
+        return _initialized_store()
+
+    @staticmethod
+    async def _cleanup_failed_start(orchestrator: object | None) -> None:
+        if orchestrator is None:
+            return
+        try:
+            await _RuntimeManager._run_owned_start(getattr(orchestrator, "shutdown")())
+        except asyncio.CancelledError:
+            raise
+        except BaseException as error:  # noqa: BLE001 - preserve startup isolation
+            _log_runtime_failure("YaoYao group partial runtime cleanup failed", error)
+
+    @staticmethod
+    async def _run_owned_start(
+        operation: object, *, cancel_worker: bool = False
+    ) -> object:
+        """Wait out caller cancellation before relinquishing startup ownership."""
+
+        if not inspect.isawaitable(operation):
+            raise TypeError("runtime start must be awaitable")
+        worker = asyncio.create_task(operation, name="yaoyao-group-runtime-start")
+        cancellation: asyncio.CancelledError | None = None
+        while not worker.done():
+            try:
+                await asyncio.shield(worker)
+            except asyncio.CancelledError as error:
+                if cancellation is None:
+                    cancellation = error
+                    if cancel_worker:
+                        worker.cancel()
+        try:
+            result = worker.result()
+        except BaseException:
+            if cancellation is not None:
+                raise cancellation
+            raise
+        if cancellation is not None:
+            raise cancellation
+        return result
+
+    @staticmethod
+    async def _run_owned_start_worker(
+        callback: Callable[..., object],
+        *args: object,
+        adopt: Callable[[object], None],
+    ) -> object:
+        worker = asyncio.create_task(
+            asyncio.to_thread(callback, *args),
+            name="yaoyao-group-runtime-create",
+        )
+        cancellation: asyncio.CancelledError | None = None
+        while not worker.done():
+            try:
+                await asyncio.shield(worker)
+            except asyncio.CancelledError as error:
+                if cancellation is None:
+                    cancellation = error
+        try:
+            result = worker.result()
+        except BaseException:
+            if cancellation is not None:
+                raise cancellation
+            raise
+        adopt(result)
+        if cancellation is not None:
+            raise cancellation
+        return result
+
+    def _reset_generation(self) -> None:
+        self._accepting = False
+        self._starting = False
+        if not _package._yaoyao_group_store_override:
+            with _package._yaoyao_group_store_lock:
+                _package._yaoyao_group_store = None
+                _package._yaoyao_group_store_ready = False
+
+
+@asynccontextmanager
+async def _router_lifespan(_: object):
+    manager = _package._yaoyao_group_runtime_manager
+    await manager.acquire()
+    try:
+        yield
+    finally:
+        await manager.release()
+
+
+_bootstrap_lock = vars(_package).setdefault(
+    "_yaoyao_group_bootstrap_lock", threading.RLock()
+)
+with _bootstrap_lock:
+    if not hasattr(_package, "_yaoyao_group_store_lock"):
+        _package._yaoyao_group_runtime_unavailable_error = RuntimeUnavailableError
+        _package._yaoyao_group_store_lock = threading.Lock()
+        _package._yaoyao_group_store = None
+        _package._yaoyao_group_store_ready = False
+        _package._yaoyao_group_store_override = False
+        _package._yaoyao_group_runtime = _UnavailableRuntime()
+        _package._yaoyao_group_runtime_override = False
+        _package._yaoyao_group_orchestrator_factory = _default_orchestrator_factory
+        _package._yaoyao_group_runtime_manager = _RuntimeManager()
+
+
+def set_store_for_testing(store: object | None) -> None:
+    """Inject an isolated store; initialization remains lazy and off-loop."""
+    with _package._yaoyao_group_store_lock:
+        _package._yaoyao_group_store = store
+        _package._yaoyao_group_store_ready = False
+        _package._yaoyao_group_store_override = store is not None
+
+
+def set_agent_name_resolver(resolver: Callable[[str], str]) -> None:
+    """Use the plugin's existing per-profile agentName setting."""
+    global _agent_name_resolver
+    _agent_name_resolver = resolver
+
+
+def set_runtime_facade(runtime: object | None) -> None:
+    """Bind the narrow Task-8 runtime surface, or restore the safe default."""
+    _package._yaoyao_group_runtime_override = runtime is not None
+    _publish_runtime(_UnavailableRuntime() if runtime is None else runtime)
+    manager = _package._yaoyao_group_runtime_manager
+    if runtime is None:
+        manager._accepting = False
+    elif not manager._starting and not manager._releasing:
+        manager._accepting = True
+
+
+def set_orchestrator_factory_for_testing(
+    factory: Callable[[object], object] | None,
+) -> None:
+    """Inject construction without importing the real Gateway runtime."""
+
+    _package._yaoyao_group_orchestrator_factory = (
+        _default_orchestrator_factory if factory is None else factory
+    )
+
+
+def _publish_runtime(runtime: object) -> None:
+    _package._yaoyao_group_runtime = runtime
+
+
+def _store_instance() -> object:
+    with _package._yaoyao_group_store_lock:
+        if _package._yaoyao_group_store is None:
+            _package._yaoyao_group_store = GroupStore.from_environment(
+                agent_name_resolver=_agent_name_resolver
+            )
+        return _package._yaoyao_group_store
+
+
+def _initialized_store() -> object:
+    with _package._yaoyao_group_store_lock:
+        if _package._yaoyao_group_store is None:
+            _package._yaoyao_group_store = GroupStore.from_environment(
+                agent_name_resolver=_agent_name_resolver
+            )
+        store = _package._yaoyao_group_store
+        if not _package._yaoyao_group_store_ready:
+            store.initialize()
+            _package._yaoyao_group_store_ready = True
+        return store
+
+
+def _invoke_store(method: str, *args: object, **kwargs: object) -> object:
+    """Run one trusted Store operation and classify only its input failures."""
+    try:
+        store = _initialized_store()
+    except sqlite3.Error as error:
+        raise GroupStorageAPIError() from error
+    try:
+        return getattr(store, method)(*args, **kwargs)
+    except ValueError as error:
+        raise GroupInvalidRequest(str(error)) from error
+    except sqlite3.Error as error:
+        raise GroupStorageAPIError() from error
+
+
+async def _store_call(method: str, *args: object, **kwargs: object) -> object:
+    return await _package._yaoyao_group_runtime_manager.dispatch_store(
+        method, *args, **kwargs
+    )
+
+
+async def _runtime_call(method: str, **kwargs: object) -> object:
+    return await _package._yaoyao_group_runtime_manager.dispatch(method, **kwargs)
+
+
+def _command(model: object, *, exclude_unset: bool = False) -> dict[str, object]:
+    return model.model_dump(
+        mode="json",
+        by_alias=True,
+        exclude_unset=exclude_unset,
+    )
+
+
+def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
+    command = _command(request)
+    if "max_reply_rounds" not in request.model_fields_set:
+        command.pop("maxReplyRounds", None)
+    agents = command.get("agents")
+    if isinstance(agents, list):
+        for seed, item in zip(request.agents, agents):
+            if (
+                isinstance(item, dict)
+                and "reply_without_mention" not in seed.model_fields_set
+            ):
+                item.pop("replyWithoutMention", None)
+            if isinstance(item, dict):
+                for field, alias in (
+                    ("model", "model"),
+                    ("provider", "provider"),
+                    ("reasoning_effort", "reasoningEffort"),
+                    ("fast_mode", "fastMode"),
+                ):
+                    if field not in seed.model_fields_set:
+                        item.pop(alias, None)
+    return command
+
+
+def _add_agent_command(request: AddAgentRequest) -> dict[str, object]:
+    command = _command(request)
+    if "reply_without_mention" not in request.model_fields_set:
+        command.pop("replyWithoutMention", None)
+    for field, alias in (
+        ("model", "model"),
+        ("provider", "provider"),
+        ("reasoning_effort", "reasoningEffort"),
+        ("fast_mode", "fastMode"),
+    ):
+        if field not in request.model_fields_set:
+            command.pop(alias, None)
+    return command
+
+
+def _validated_interaction_id(value: str) -> str:
+    try:
+        return normalize_interaction_id(value)
+    except ValueError as error:
+        raise GroupInvalidRequest(str(error)) from error
+
+
+async def capabilities() -> dict[str, object]:
+    journal_epoch = await _store_call("journal_epoch")
+    latest_cursor = await _store_call("latest_cursor")
+    return {
+        "protocolVersion": PROTOCOL_VERSION,
+        "journalEpoch": journal_epoch,
+        "latestCursor": latest_cursor,
+        "limits": limits_payload(),
+        "eventTypes": sorted(EVENT_TYPES),
+    }
+
+
+async def list_rooms(
+    limit: int = Query(default=50, ge=1, le=100),
+    cursor: str | None = Query(default=None, min_length=1, max_length=4096),
+) -> dict[str, object]:
+    page = await _store_call("list_rooms", limit=limit, cursor=cursor)
+    return {"items": page.items, "nextCursor": page.next_cursor}
+
+
+async def create_room(request: CreateRoomRequest) -> dict[str, object]:
+    return await _runtime_call("create_room", command=_create_room_command(request))
+
+
+async def get_room(room_id: UUID) -> dict[str, object]:
+    return await _store_call("room_snapshot", str(room_id))
+
+
+async def update_room(room_id: UUID, request: UpdateRoomRequest) -> dict[str, object]:
+    return await _runtime_call(
+        "update_room",
+        room_id=str(room_id),
+        command=_command(request, exclude_unset=True),
+    )
+
+
+async def archive_room(room_id: UUID, request: RequestIDRequest) -> dict[str, object]:
+    return await _runtime_call(
+        "archive_room",
+        room_id=str(room_id),
+        request_id=str(request.request_id),
+    )
+
+
+async def add_agent(room_id: UUID, request: AddAgentRequest) -> dict[str, object]:
+    return await _runtime_call(
+        "add_agent", room_id=str(room_id), command=_add_agent_command(request)
+    )
+
+
+async def update_agent(
+    room_id: UUID,
+    agent_id: UUID,
+    request: UpdateAgentRequest,
+) -> dict[str, object]:
+    return await _runtime_call(
+        "update_agent",
+        room_id=str(room_id),
+        agent_id=str(agent_id),
+        command=_command(request, exclude_unset=True),
+    )
+
+
+async def delete_agent(
+    room_id: UUID,
+    agent_id: UUID,
+    request: RequestIDRequest,
+) -> dict[str, object]:
+    return await _runtime_call(
+        "delete_agent",
+        room_id=str(room_id),
+        agent_id=str(agent_id),
+        request_id=str(request.request_id),
+    )
+
+
+async def interrupt_agent(
+    room_id: UUID,
+    agent_id: UUID,
+    request: RequestIDRequest,
+) -> object:
+    return await _runtime_call(
+        "interrupt_agent",
+        room_id=str(room_id),
+        agent_id=str(agent_id),
+        request_id=str(request.request_id),
+    )
+
+
+async def list_messages(
+    room_id: UUID,
+    before_seq: int | None = Query(default=None, alias="beforeSeq", ge=1),
+    after_seq: int | None = Query(default=None, alias="afterSeq", ge=1),
+    limit: int = Query(default=MAX_MESSAGE_PAGE_SIZE, ge=1, le=MAX_MESSAGE_PAGE_SIZE),
+) -> dict[str, object]:
+    items = await _store_call(
+        "list_messages",
+        str(room_id),
+        before_seq=before_seq,
+        after_seq=after_seq,
+        limit=limit,
+    )
+    return {"items": items}
+
+
+async def send_message(room_id: UUID, request: SendMessageRequest) -> dict[str, object]:
+    return await _runtime_call(
+        "send_message",
+        room_id=str(room_id),
+        request_id=str(request.request_id),
+        client_message_id=str(request.client_message_id),
+        content=request.content,
+        mention_agent_ids=[str(agent_id) for agent_id in request.mention_agent_ids],
+    )
+
+
+async def respond_approval(
+    room_id: UUID,
+    request: ApprovalRequest,
+    interaction_id: str = APIPath(min_length=1, max_length=200),
+) -> object:
+    canonical_interaction_id = _validated_interaction_id(interaction_id)
+    return await _runtime_call(
+        "respond_approval",
+        room_id=str(room_id),
+        interaction_id=canonical_interaction_id,
+        request_id=str(request.request_id),
+        choice=request.choice,
+        permanent=request.permanent,
+    )
+
+
+async def respond_clarification(
+    room_id: UUID,
+    request: ClarificationRequest,
+    interaction_id: str = APIPath(min_length=1, max_length=200),
+) -> object:
+    canonical_interaction_id = _validated_interaction_id(interaction_id)
+    return await _runtime_call(
+        "respond_clarification",
+        room_id=str(room_id),
+        interaction_id=canonical_interaction_id,
+        request_id=str(request.request_id),
+        response=request.response,
+    )
+
+
+async def stream_events(websocket: WebSocket) -> None:
+    """Delegate the read-only event channel to the authenticated streamer."""
+    allowed, close_code = _stream_module.websocket_upgrade_allowed(websocket)
+    if not allowed:
+        await websocket.close(code=close_code)
+        return
+    if not await asyncio.to_thread(_plugin_runtime_enabled):
+        await websocket.close(code=_stream_module.PLUGIN_DISABLED_CLOSE_CODE)
+        return
+
+    async def stream_authorized(
+        websocket: WebSocket, store_provider: Callable[[], object]
+    ) -> None:
+        await _stream_module.stream_authorized_group_events(
+            websocket,
+            store_provider,
+            availability_provider=_plugin_runtime_enabled,
+        )
+
+    try:
+        manager = _package._yaoyao_group_runtime_manager
+        await manager.stream_store(websocket, stream_authorized)
+    except RuntimeUnavailableError:
+        await websocket.close(code=1013, reason="Group runtime is unavailable")
+
+
+def _build_router() -> APIRouter:
+    """Build the immutable route table once per resolved plugin directory."""
+    built = APIRouter(
+        prefix="/v1",
+        tags=["yaoyao-group"],
+        route_class=GroupAPIRoute,
+        lifespan=_router_lifespan,
+    )
+    built.add_api_route("/capabilities", capabilities, methods=["GET"])
+    built.add_api_route("/rooms", list_rooms, methods=["GET"])
+    built.add_api_route("/rooms", create_room, methods=["POST"])
+    built.add_api_route("/rooms/{room_id}", get_room, methods=["GET"])
+    built.add_api_route("/rooms/{room_id}", update_room, methods=["PATCH"])
+    built.add_api_route("/rooms/{room_id}", archive_room, methods=["DELETE"])
+    built.add_api_route("/rooms/{room_id}/agents", add_agent, methods=["POST"])
+    built.add_api_route(
+        "/rooms/{room_id}/agents/{agent_id}", update_agent, methods=["PATCH"]
+    )
+    built.add_api_route(
+        "/rooms/{room_id}/agents/{agent_id}", delete_agent, methods=["DELETE"]
+    )
+    built.add_api_route(
+        "/rooms/{room_id}/agents/{agent_id}/interrupt",
+        interrupt_agent,
+        methods=["POST"],
+    )
+    built.add_api_route("/rooms/{room_id}/messages", list_messages, methods=["GET"])
+    built.add_api_route("/rooms/{room_id}/messages", send_message, methods=["POST"])
+    built.add_api_route(
+        "/rooms/{room_id}/interactions/{interaction_id}/approval",
+        respond_approval,
+        methods=["POST"],
+    )
+    built.add_api_route(
+        "/rooms/{room_id}/interactions/{interaction_id}/clarification",
+        respond_clarification,
+        methods=["POST"],
+    )
+    built.add_api_websocket_route("/events", stream_events)
+    return built
+
+
+with _bootstrap_lock:
+    router = getattr(_package, "_yaoyao_group_router", None)
+    if router is None:
+        router = _build_router()
+        _package._yaoyao_group_router = router

+ 421 - 0
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -0,0 +1,421 @@
+"""Shared protocol limits and input normalization for YaoYao Group Chat."""
+
+from __future__ import annotations
+
+import unicodedata
+from pathlib import Path
+from typing import Any, Literal
+from uuid import UUID
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    StrictBool,
+    StrictInt,
+    field_validator,
+    model_validator,
+)
+
+
+PROTOCOL_VERSION = 3
+MAX_AGENTS_PER_ROOM = 8
+MAX_MESSAGE_BYTES = 64 * 1024
+MAX_TOOL_STATE_BYTES = 256 * 1024
+MAX_INTERACTION_PAYLOAD_BYTES = 64 * 1024
+MAX_MESSAGE_PAGE_SIZE = 100
+MAX_EVENT_BATCH_SIZE = 200
+MAX_EVENT_FRAME_BYTES = 2 * 1024 * 1024
+MAX_AGENT_DEPTH = 2
+DEFAULT_MAX_REPLY_ROUNDS = 3
+UNLIMITED_REPLY_ROUNDS = -1
+MAX_FINITE_REPLY_ROUNDS = 100
+MAX_AGENT_DISPLAY_NAME_LENGTH = 100
+MAX_ROOM_CONCURRENCY = 3
+MAX_PLUGIN_CONCURRENCY = 4
+INITIAL_CONTEXT_MESSAGE_LIMIT = 50
+CONTEXT_CHARACTER_BUDGET = 32_000
+REASONING_EFFORTS = frozenset({
+    "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
+})
+
+# These literals address the room as a whole and must never become a member
+# display-name or alias target.  Keep the stable wire spelling for existing
+# clients while accepting the localized spelling shown by the iOS UI.
+ALL_MENTION_ALIASES = ("所有人", "all")
+_ALL_MENTION_ALIAS_KEYS = frozenset(
+    alias.casefold() for alias in ALL_MENTION_ALIASES
+)
+
+EVENT_TYPES = frozenset({
+    "room.created",
+    "room.updated",
+    "room.deleted",
+    "agent.created",
+    "agent.updated",
+    "agent.deleted",
+    "agent.status",
+    "message.upsert",
+    "interaction.requested",
+    "interaction.resolved",
+    "run.updated",
+})
+
+
+def limits_payload() -> dict[str, int]:
+    """Return the public, lower-camel protocol limits advertised to clients."""
+    return {
+        "maxAgentsPerRoom": MAX_AGENTS_PER_ROOM,
+        "maxMessageBytes": MAX_MESSAGE_BYTES,
+        "maxToolStateBytes": MAX_TOOL_STATE_BYTES,
+        "maxInteractionPayloadBytes": MAX_INTERACTION_PAYLOAD_BYTES,
+        "maxMessagePageSize": MAX_MESSAGE_PAGE_SIZE,
+        "maxEventBatchSize": MAX_EVENT_BATCH_SIZE,
+        "maxEventFrameBytes": MAX_EVENT_FRAME_BYTES,
+        "maxAgentDepth": MAX_AGENT_DEPTH,
+        "defaultMaxReplyRounds": DEFAULT_MAX_REPLY_ROUNDS,
+        "unlimitedReplyRoundsValue": UNLIMITED_REPLY_ROUNDS,
+        "maxAgentDisplayNameLength": MAX_AGENT_DISPLAY_NAME_LENGTH,
+        "maxRoomConcurrency": MAX_ROOM_CONCURRENCY,
+        "maxPluginConcurrency": MAX_PLUGIN_CONCURRENCY,
+    }
+
+
+class GroupModel(BaseModel):
+    """Public request base with lower-camel aliases and forbidden extras."""
+
+    model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+
+class AgentSeed(GroupModel):
+    profile: str = Field(min_length=1, max_length=100)
+    display_name: str | None = Field(
+        default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
+    )
+    description: str = Field(default="", max_length=500)
+    reply_without_mention: StrictBool = Field(
+        default=False, alias="replyWithoutMention"
+    )
+    model: str | None = Field(default=None, max_length=4096)
+    provider: str | None = Field(default=None, max_length=4096)
+    reasoning_effort: str | None = Field(
+        default=None, alias="reasoningEffort", max_length=32
+    )
+    fast_mode: StrictBool | None = Field(default=None, alias="fastMode")
+
+    @field_validator("model", "provider", "reasoning_effort")
+    @classmethod
+    def normalize_optional_configuration(cls, value: str | None) -> str | None:
+        return normalize_optional_agent_configuration(value)
+
+    @field_validator("reasoning_effort")
+    @classmethod
+    def validate_reasoning_effort(cls, value: str | None) -> str | None:
+        return validate_reasoning_override(value)
+
+    @model_validator(mode="after")
+    def validate_model_selection(self) -> "AgentSeed":
+        validate_model_provider_pair(self.model, self.provider)
+        return self
+
+    @field_validator("display_name")
+    @classmethod
+    def reject_reserved_display_name(cls, value: str | None) -> str | None:
+        return validate_optional_display_name(value)
+
+
+class CreateRoomRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    name: str = Field(min_length=1, max_length=100)
+    cwd: str = Field(default="", max_length=4096)
+    max_reply_rounds: StrictInt = Field(
+        default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
+    )
+    agents: list[AgentSeed] = Field(min_length=1, max_length=MAX_AGENTS_PER_ROOM)
+
+    @field_validator("max_reply_rounds")
+    @classmethod
+    def validate_max_reply_rounds(cls, value: int) -> int:
+        return normalize_max_reply_rounds(value)
+
+
+class UpdateRoomRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    cwd: str | None = Field(default=None, max_length=4096)
+    max_reply_rounds: StrictInt | None = Field(default=None, alias="maxReplyRounds")
+
+    @field_validator("name", "cwd", "max_reply_rounds", mode="before")
+    @classmethod
+    def reject_explicit_null(cls, value: Any) -> Any:
+        if value is None:
+            raise ValueError("field must not be null")
+        return value
+
+    @model_validator(mode="after")
+    def require_change(self) -> "UpdateRoomRequest":
+        if not ({"name", "cwd", "max_reply_rounds"} & self.model_fields_set):
+            raise ValueError("Room update requires name, cwd, or maxReplyRounds")
+        return self
+
+    @field_validator("max_reply_rounds")
+    @classmethod
+    def validate_max_reply_rounds(cls, value: int | None) -> int | None:
+        return None if value is None else normalize_max_reply_rounds(value)
+
+
+class AddAgentRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    profile: str = Field(min_length=1, max_length=100)
+    display_name: str | None = Field(
+        default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
+    )
+    description: str = Field(default="", max_length=500)
+    reply_without_mention: StrictBool = Field(
+        default=False, alias="replyWithoutMention"
+    )
+    model: str | None = Field(default=None, max_length=4096)
+    provider: str | None = Field(default=None, max_length=4096)
+    reasoning_effort: str | None = Field(
+        default=None, alias="reasoningEffort", max_length=32
+    )
+    fast_mode: StrictBool | None = Field(default=None, alias="fastMode")
+
+    @field_validator("model", "provider", "reasoning_effort")
+    @classmethod
+    def normalize_optional_configuration(cls, value: str | None) -> str | None:
+        return normalize_optional_agent_configuration(value)
+
+    @field_validator("reasoning_effort")
+    @classmethod
+    def validate_reasoning_effort(cls, value: str | None) -> str | None:
+        return validate_reasoning_override(value)
+
+    @model_validator(mode="after")
+    def validate_model_selection(self) -> "AddAgentRequest":
+        validate_model_provider_pair(self.model, self.provider)
+        return self
+
+    @field_validator("display_name")
+    @classmethod
+    def reject_reserved_display_name(cls, value: str | None) -> str | None:
+        return validate_optional_display_name(value)
+
+
+class UpdateAgentRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    display_name: str | None = Field(
+        default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
+    )
+    description: str | None = Field(default=None, max_length=500)
+    enabled: StrictBool | None = None
+    reply_without_mention: StrictBool | None = Field(
+        default=None, alias="replyWithoutMention"
+    )
+    model: str | None = Field(default=None, max_length=4096)
+    provider: str | None = Field(default=None, max_length=4096)
+    reasoning_effort: str | None = Field(
+        default=None, alias="reasoningEffort", max_length=32
+    )
+    fast_mode: StrictBool | None = Field(default=None, alias="fastMode")
+
+    @field_validator(
+        "display_name", "description", "enabled", "reply_without_mention", mode="before"
+    )
+    @classmethod
+    def reject_explicit_null(cls, value: Any) -> Any:
+        if value is None:
+            raise ValueError("field must not be null")
+        return value
+
+    @field_validator("display_name")
+    @classmethod
+    def reject_reserved_display_name(cls, value: str | None) -> str | None:
+        return validate_optional_display_name(value)
+
+    @field_validator("model", "provider", "reasoning_effort")
+    @classmethod
+    def normalize_optional_configuration(cls, value: str | None) -> str | None:
+        return normalize_optional_agent_configuration(value)
+
+    @field_validator("reasoning_effort")
+    @classmethod
+    def validate_reasoning_effort(cls, value: str | None) -> str | None:
+        return validate_reasoning_override(value)
+
+    @model_validator(mode="after")
+    def require_change(self) -> "UpdateAgentRequest":
+        if not (
+            {
+                "display_name", "description", "enabled", "reply_without_mention",
+                "model", "provider", "reasoning_effort", "fast_mode",
+            }
+            & self.model_fields_set
+        ):
+            raise ValueError(
+                "Agent update requires a mutable agent field"
+            )
+        model_fields = {"model", "provider"} & self.model_fields_set
+        if model_fields and model_fields != {"model", "provider"}:
+            raise ValueError("model and provider must be updated together")
+        if model_fields:
+            validate_model_provider_pair(self.model, self.provider)
+        return self
+
+
+def normalize_optional_agent_configuration(value: str | None) -> str | None:
+    if value is None:
+        return None
+    normalized = value.strip()
+    if not normalized:
+        raise ValueError("configuration value must not be blank")
+    return normalized
+
+
+def validate_reasoning_override(value: str | None) -> str | None:
+    if value is not None and value not in REASONING_EFFORTS:
+        raise ValueError("reasoningEffort is invalid")
+    return value
+
+
+def validate_model_provider_pair(model: str | None, provider: str | None) -> None:
+    if (model is None) != (provider is None):
+        raise ValueError("model and provider must both be set or both be null")
+
+
+class RequestIDRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+
+
+class SendMessageRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    client_message_id: UUID = Field(alias="clientMessageId")
+    content: str
+    mention_agent_ids: list[UUID] = Field(
+        alias="mentionAgentIds", max_length=MAX_AGENTS_PER_ROOM
+    )
+
+    @field_validator("content")
+    @classmethod
+    def validate_content_bytes(cls, value: str) -> str:
+        try:
+            encoded = value.encode("utf-8")
+        except UnicodeEncodeError as error:
+            raise ValueError("content must be valid UTF-8") from error
+        if len(encoded) > MAX_MESSAGE_BYTES:
+            raise ValueError("content exceeds maximum size")
+        if not value.strip():
+            raise ValueError("content must not be blank")
+        return value
+
+
+class ApprovalRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    choice: Literal["once", "session", "always", "deny"]
+    permanent: StrictBool = False
+
+    @model_validator(mode="after")
+    def validate_choice(self) -> "ApprovalRequest":
+        if (self.choice == "always") != self.permanent:
+            raise ValueError("permanent must be true exactly for choice=always")
+        return self
+
+
+class ClarificationRequest(GroupModel):
+    request_id: UUID = Field(alias="requestId")
+    response: str
+
+    @field_validator("response")
+    @classmethod
+    def validate_response_bytes(cls, value: str) -> str:
+        try:
+            encoded = value.encode("utf-8")
+        except UnicodeEncodeError as error:
+            raise ValueError("response must be valid UTF-8") from error
+        if not value.strip():
+            raise ValueError("response must not be blank")
+        if len(encoded) > MAX_MESSAGE_BYTES:
+            raise ValueError("response exceeds maximum size")
+        return value
+
+
+def normalize_room_name(value: str) -> str:
+    """Return a trimmed room name within the protocol's character limit."""
+    normalized = value.strip()
+    if not 1 <= len(normalized) <= 100:
+        raise ValueError("Room name must contain 1 to 100 characters")
+    return normalized
+
+
+def normalize_display_name(value: str) -> tuple[str, str]:
+    """Return a trimmed display name and its case-insensitive uniqueness key."""
+    normalized = value.strip()
+    if not 1 <= len(normalized) <= MAX_AGENT_DISPLAY_NAME_LENGTH:
+        raise ValueError(
+            f"Display name must contain 1 to {MAX_AGENT_DISPLAY_NAME_LENGTH} characters"
+        )
+    uniqueness_key = normalized.casefold()
+    if uniqueness_key in _ALL_MENTION_ALIAS_KEYS:
+        raise ValueError("Display name is reserved")
+    return normalized, uniqueness_key
+
+
+def is_reserved_mention_alias(value: str) -> bool:
+    """Return whether a member-facing name would claim a room-wide Mention."""
+    return value.strip().casefold() in _ALL_MENTION_ALIAS_KEYS
+
+
+def validate_optional_display_name(value: str | None) -> str | None:
+    """Reject only non-empty reserved names; blank still means use the fallback."""
+    if value is not None and value.strip() and is_reserved_mention_alias(value):
+        raise ValueError("Display name is reserved")
+    return value
+
+
+def normalize_max_reply_rounds(value: object) -> int:
+    """Validate a finite reply-round ceiling or the explicit unlimited marker."""
+    if isinstance(value, bool) or not isinstance(value, int):
+        raise ValueError("maxReplyRounds must be an integer")
+    if value == UNLIMITED_REPLY_ROUNDS:
+        return value
+    if not 1 <= value <= MAX_FINITE_REPLY_ROUNDS:
+        raise ValueError(
+            f"maxReplyRounds must be -1 or 1 to {MAX_FINITE_REPLY_ROUNDS}"
+        )
+    return value
+
+
+def normalize_interaction_id(value: object) -> str:
+    """Canonicalize an opaque Gateway interaction identity for URL use."""
+    if not isinstance(value, str):
+        raise ValueError("interactionId must be a string")
+    normalized = value.strip()
+    if (
+        not normalized
+        or len(normalized) > 200
+        or any(unicodedata.category(character) == "Cc" for character in normalized)
+    ):
+        raise ValueError("interactionId is invalid")
+    return normalized
+
+
+def normalize_room_cwd(value: str) -> str:
+    """Validate and canonicalize an optional room working directory."""
+    if not isinstance(value, str):
+        raise ValueError("Room cwd must be a string")
+    if value and any(unicodedata.category(character) == "Cc" for character in value):
+        raise ValueError("Room cwd must not contain control characters")
+    normalized = value.strip()
+    if not normalized:
+        return ""
+    path = Path(normalized)
+    if not path.is_absolute():
+        raise ValueError("Room cwd must be an absolute path")
+    try:
+        resolved = path.resolve()
+        is_directory = resolved.is_dir()
+    except (OSError, RuntimeError) as error:
+        raise ValueError("Room cwd could not be resolved") from error
+    if not is_directory:
+        raise ValueError("Room cwd must exist and be a directory")
+    return str(resolved)

+ 4934 - 0
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -0,0 +1,4934 @@
+"""Durable SQLite storage primitives for the YaoYao Group Chat plugin."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import hashlib
+import json
+import math
+import os
+import re
+import sqlite3
+import time
+import unicodedata
+import uuid
+from contextlib import contextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Iterator, Mapping
+
+from .group_protocol import (
+    ALL_MENTION_ALIASES,
+    CONTEXT_CHARACTER_BUDGET,
+    DEFAULT_MAX_REPLY_ROUNDS,
+    INITIAL_CONTEXT_MESSAGE_LIMIT,
+    MAX_AGENTS_PER_ROOM,
+    MAX_EVENT_BATCH_SIZE,
+    MAX_FINITE_REPLY_ROUNDS,
+    MAX_INTERACTION_PAYLOAD_BYTES,
+    MAX_MESSAGE_BYTES,
+    MAX_MESSAGE_PAGE_SIZE,
+    MAX_PLUGIN_CONCURRENCY,
+    MAX_ROOM_CONCURRENCY,
+    MAX_TOOL_STATE_BYTES,
+    is_reserved_mention_alias,
+    normalize_display_name,
+    normalize_interaction_id,
+    normalize_max_reply_rounds,
+    normalize_room_cwd,
+    normalize_room_name,
+)
+
+
+SCHEMA_VERSION = 3
+_WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
+EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
+MIN_RETAINED_EVENTS = 100_000
+UNSET = object()
+
+RUN_TRANSITIONS = {
+    "queued": frozenset({"running", "interrupted", "failed"}),
+    "running": frozenset({"awaiting_input", "completed", "failed", "interrupted"}),
+    "awaiting_input": frozenset({"running", "failed", "interrupted"}),
+    "completed": frozenset(),
+    "failed": frozenset(),
+    "interrupted": frozenset(),
+}
+_MESSAGE_STATUSES = frozenset(
+    {"queued", "streaming", "completed", "failed", "interrupted"}
+)
+_AGENT_MESSAGE_STATUSES_BY_RUN = {
+    "queued": frozenset({"queued"}),
+    "running": frozenset({"queued", "streaming", "completed"}),
+    "awaiting_input": frozenset({"queued", "streaming"}),
+    "completed": frozenset(),
+    "failed": frozenset(),
+    "interrupted": frozenset(),
+}
+_AGENT_MESSAGE_TRANSITIONS = {
+    "queued": frozenset({"queued", "streaming", "completed"}),
+    "streaming": frozenset({"streaming", "completed"}),
+    "completed": frozenset(),
+    "failed": frozenset(),
+    "interrupted": frozenset(),
+}
+_MESSAGE_STATUSES_BY_RUN = {
+    "queued": frozenset({"queued"}),
+    "running": frozenset({"queued", "streaming", "completed"}),
+    "awaiting_input": frozenset({"queued", "streaming"}),
+    "completed": frozenset({"completed"}),
+    "failed": frozenset({"failed", "completed"}),
+    "interrupted": frozenset({"interrupted", "completed"}),
+}
+_INTERACTION_KINDS = frozenset({"approval", "clarification"})
+_INTERACTION_TERMINAL_STATUSES = frozenset({"resolved", "cancelled"})
+_CASCADE_PENDING_OPERATION = "internal.cascade.pending"
+_CASCADE_COMPLETED_OPERATION = "internal.cascade.completed"
+_CASCADE_DISCARDED_OPERATION = "internal.cascade.discarded"
+_CASCADE_OPERATIONS = frozenset(
+    {
+        _CASCADE_PENDING_OPERATION,
+        _CASCADE_COMPLETED_OPERATION,
+        _CASCADE_DISCARDED_OPERATION,
+    }
+)
+_CASCADE_PARSE_VERSION = 2
+_MAX_CASCADE_PAGE_SIZE = 32
+_MAX_CASCADE_PLAN_BYTES = 16 * 1024
+
+_SCHEMA_STATEMENTS = (
+    """CREATE TABLE IF NOT EXISTS group_meta (
+        key TEXT PRIMARY KEY,
+        value TEXT NOT NULL
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_rooms (
+        id TEXT PRIMARY KEY,
+        name TEXT NOT NULL,
+        cwd TEXT NOT NULL DEFAULT '',
+        max_reply_rounds INTEGER NOT NULL DEFAULT 3,
+        created_at REAL NOT NULL,
+        updated_at REAL NOT NULL,
+        archived INTEGER NOT NULL DEFAULT 0
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_agents (
+        id TEXT PRIMARY KEY,
+        room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
+        profile TEXT NOT NULL,
+        display_name TEXT NOT NULL,
+        display_name_key TEXT NOT NULL,
+        description TEXT NOT NULL DEFAULT '',
+        stored_session_id TEXT,
+        last_context_message_seq INTEGER NOT NULL DEFAULT 0,
+        enabled INTEGER NOT NULL DEFAULT 1,
+        reply_without_mention INTEGER NOT NULL DEFAULT 0,
+        model_override TEXT,
+        provider_override TEXT,
+        reasoning_effort_override TEXT,
+        fast_mode_override INTEGER,
+        session_config_json TEXT,
+        created_at REAL NOT NULL,
+        updated_at REAL NOT NULL,
+        UNIQUE(room_id, profile),
+        UNIQUE(room_id, display_name_key)
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_messages (
+        seq INTEGER PRIMARY KEY AUTOINCREMENT,
+        id TEXT NOT NULL UNIQUE,
+        room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
+        sender_kind TEXT NOT NULL,
+        sender_id TEXT NOT NULL,
+        sender_name TEXT NOT NULL,
+        root_message_id TEXT NOT NULL,
+        reply_to_message_id TEXT,
+        client_message_id TEXT UNIQUE,
+        content TEXT NOT NULL DEFAULT '',
+        reasoning TEXT NOT NULL DEFAULT '',
+        tool_state_json TEXT NOT NULL DEFAULT '[]',
+        status TEXT NOT NULL,
+        error TEXT NOT NULL DEFAULT '',
+        visible INTEGER NOT NULL DEFAULT 1,
+        created_at REAL NOT NULL,
+        updated_at REAL NOT NULL
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_agent_runs (
+        id TEXT PRIMARY KEY,
+        room_id TEXT NOT NULL,
+        agent_id TEXT NOT NULL,
+        trigger_message_id TEXT NOT NULL,
+        response_message_id TEXT NOT NULL,
+        root_message_id TEXT NOT NULL,
+        depth INTEGER NOT NULL,
+        reply_mode TEXT NOT NULL DEFAULT 'mentioned',
+        status TEXT NOT NULL,
+        runtime_session_id TEXT,
+        error TEXT NOT NULL DEFAULT '',
+        created_at REAL NOT NULL,
+        updated_at REAL NOT NULL
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_interactions (
+        id TEXT PRIMARY KEY,
+        room_id TEXT NOT NULL,
+        agent_id TEXT NOT NULL,
+        run_id TEXT NOT NULL,
+        kind TEXT NOT NULL,
+        payload_json TEXT NOT NULL,
+        status TEXT NOT NULL,
+        created_at REAL NOT NULL,
+        resolved_at REAL
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_events (
+        cursor INTEGER PRIMARY KEY AUTOINCREMENT,
+        epoch TEXT NOT NULL,
+        room_id TEXT,
+        event_type TEXT NOT NULL,
+        payload_json TEXT NOT NULL,
+        created_at REAL NOT NULL
+    )""",
+    """CREATE TABLE IF NOT EXISTS group_idempotency (
+        request_id TEXT PRIMARY KEY,
+        operation TEXT NOT NULL,
+        request_hash TEXT NOT NULL,
+        response_json TEXT NOT NULL,
+        created_at REAL NOT NULL
+    )""",
+    "CREATE INDEX IF NOT EXISTS idx_group_rooms_ordering ON group_rooms(archived, updated_at DESC, id DESC)",
+    "CREATE INDEX IF NOT EXISTS idx_group_messages_room_seq ON group_messages(room_id, seq)",
+    "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room ON group_agent_runs(room_id)",
+    "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_agent_status ON group_agent_runs(agent_id, status)",
+    "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_room_status ON group_agent_runs(room_id, status)",
+    "CREATE INDEX IF NOT EXISTS idx_group_agent_runs_status_order ON group_agent_runs(status, created_at, id)",
+    """CREATE UNIQUE INDEX IF NOT EXISTS idx_group_agent_runs_active_runtime
+    ON group_agent_runs(runtime_session_id)
+    WHERE runtime_session_id IS NOT NULL
+      AND status IN ('running', 'awaiting_input')""",
+    "CREATE INDEX IF NOT EXISTS idx_group_interactions_room_agent ON group_interactions(room_id, agent_id)",
+    "CREATE INDEX IF NOT EXISTS idx_group_interactions_run_status_kind ON group_interactions(run_id, status, kind)",
+    "CREATE INDEX IF NOT EXISTS idx_group_events_room_created_at ON group_events(room_id, created_at)",
+    "CREATE INDEX IF NOT EXISTS idx_group_events_created_at ON group_events(created_at)",
+    "CREATE INDEX IF NOT EXISTS idx_group_idempotency_operation ON group_idempotency(operation)",
+)
+
+_EXPECTED_TABLES = frozenset(
+    {
+        "group_meta",
+        "group_rooms",
+        "group_agents",
+        "group_messages",
+        "group_agent_runs",
+        "group_interactions",
+        "group_events",
+        "group_idempotency",
+    }
+)
+
+
+class GroupStoreError(RuntimeError):
+    """Raised when the group-chat database is uninitialized or corrupt."""
+
+
+class GroupNotFoundError(GroupStoreError):
+    """Raised when a room or agent is absent, or unavailable because it is archived."""
+
+
+class GroupConflictError(GroupStoreError):
+    """Raised when a requested room-member invariant would be violated."""
+
+
+class IdempotencyConflict(GroupStoreError):
+    """Raised when a request ID is reused for a different operation or payload."""
+
+
+@dataclass(frozen=True)
+class CursorPage:
+    """A page of room summaries and the opaque cursor for its successor."""
+
+    items: list[dict[str, object]]
+    next_cursor: str | None
+
+
+class GroupStore:
+    """The versioned SQLite authority for public group-chat state."""
+
+    def __init__(
+        self,
+        path: Path,
+        *,
+        agent_name_resolver: Callable[[str], str] | None = None,
+    ):
+        self.path = Path(path)
+        self._agent_name_resolver = agent_name_resolver or (lambda _profile: "")
+
+    @classmethod
+    def from_environment(
+        cls, *, agent_name_resolver: Callable[[str], str] | None = None
+    ) -> "GroupStore":
+        hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
+        return cls(
+            hermes_home / "plugins" / "yaoyao" / "data" / "group-chat.db",
+            agent_name_resolver=agent_name_resolver,
+        )
+
+    def connect(self) -> sqlite3.Connection:
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+        connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
+        connection.row_factory = sqlite3.Row
+        connection.execute("PRAGMA foreign_keys = ON")
+        connection.execute("PRAGMA busy_timeout = 5000")
+        return connection
+
+    @contextmanager
+    def connection(self) -> Iterator[sqlite3.Connection]:
+        """Yield a raw SQLite connection and always close it afterwards."""
+        connection = self.connect()
+        try:
+            yield connection
+        finally:
+            connection.close()
+
+    def initialize(self) -> None:
+        """Create or validate the versioned schema without changing its epoch."""
+        for attempt, delay in enumerate(_WAL_RETRY_DELAYS):
+            try:
+                self._initialize_once()
+                return
+            except sqlite3.OperationalError as error:
+                if (
+                    not self._is_busy_or_locked(error)
+                    or attempt == len(_WAL_RETRY_DELAYS) - 1
+                ):
+                    raise
+                time.sleep(delay)
+
+    def _initialize_once(self) -> None:
+        with self.connection() as connection:
+            connection.execute("PRAGMA journal_mode = WAL")
+            connection.execute("BEGIN IMMEDIATE")
+            try:
+                table_names = self._user_table_names(connection)
+                if not table_names:
+                    self._create_fresh_schema(connection)
+                else:
+                    self._validate_existing_schema(connection, table_names)
+                self._ensure_indexes(connection)
+                self._sync_legacy_fallback_agent_names(connection)
+            except Exception:
+                connection.execute("ROLLBACK")
+                raise
+            else:
+                connection.execute("COMMIT")
+
+    def _sync_legacy_fallback_agent_names(
+        self, connection: sqlite3.Connection
+    ) -> None:
+        """Adopt configured names for active legacy members without overriding edits."""
+        rows = connection.execute(
+            """SELECT group_agents.* FROM group_agents
+            JOIN group_rooms ON group_rooms.id = group_agents.room_id
+            WHERE group_agents.display_name = group_agents.profile
+              AND group_rooms.archived = 0
+            ORDER BY group_rooms.created_at ASC, group_rooms.id ASC,
+                     group_agents.created_at ASC, group_agents.id ASC"""
+        ).fetchall()
+        for row in rows:
+            configured = self._resolved_agent_name(row["profile"])
+            if not configured:
+                continue
+            try:
+                display_name, display_name_key = normalize_display_name(configured)
+            except ValueError:
+                continue
+            if (
+                display_name == row["display_name"]
+                and display_name_key == row["display_name_key"]
+            ):
+                continue
+            conflict = connection.execute(
+                """SELECT 1 FROM group_agents
+                WHERE room_id = ? AND display_name_key = ? AND id != ?
+                LIMIT 1""",
+                (row["room_id"], display_name_key, row["id"]),
+            ).fetchone()
+            if conflict is not None:
+                continue
+
+            now = self._now()
+            connection.execute(
+                """UPDATE group_agents
+                SET display_name = ?, display_name_key = ?, updated_at = ?
+                WHERE id = ? AND room_id = ?""",
+                (display_name, display_name_key, now, row["id"], row["room_id"]),
+            )
+            connection.execute(
+                "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
+                (now, row["room_id"]),
+            )
+            agent = self._agent_detail(connection, row["room_id"], row["id"])
+            self._append_event(
+                connection,
+                row["room_id"],
+                "agent.updated",
+                agent,
+                created_at=now,
+            )
+            self._append_room_updated_summary(
+                connection, row["room_id"], created_at=now
+            )
+
+    def schema_version(self) -> int:
+        value = self._metadata_value("schema_version")
+        return self._validated_schema_version(value)
+
+    def journal_epoch(self) -> str:
+        value = self._metadata_value("journal_epoch")
+        try:
+            canonical = str(uuid.UUID(value))
+        except (ValueError, AttributeError) as error:
+            raise GroupStoreError("GroupStore journal_epoch is corrupt") from error
+        if value != canonical:
+            raise GroupStoreError("GroupStore journal_epoch is corrupt")
+        return canonical
+
+    def _create_fresh_schema(self, connection: sqlite3.Connection) -> None:
+        for statement in _SCHEMA_STATEMENTS:
+            connection.execute(statement)
+        connection.execute(
+            "INSERT INTO group_meta(key, value) VALUES ('schema_version', ?)",
+            (str(SCHEMA_VERSION),),
+        )
+        connection.execute(
+            "INSERT INTO group_meta(key, value) VALUES ('journal_epoch', ?)",
+            (str(uuid.uuid4()),),
+        )
+
+    def _validate_existing_schema(
+        self, connection: sqlite3.Connection, table_names: set[str]
+    ) -> None:
+        if table_names != _EXPECTED_TABLES:
+            raise GroupStoreError("GroupStore schema is partial or corrupt")
+        raw_version = self._metadata_value_from_connection(connection, "schema_version")
+        if raw_version == "1":
+            self._migrate_v1_to_v2(connection)
+            raw_version = "2"
+        if raw_version == "2":
+            self._migrate_v2_to_v3(connection)
+        self._validated_schema_version(
+            self._metadata_value_from_connection(connection, "schema_version")
+        )
+        self._validated_epoch(
+            self._metadata_value_from_connection(connection, "journal_epoch")
+        )
+        self._validate_v3_columns(connection)
+        self._validate_v3_values(connection)
+
+    @staticmethod
+    def _migrate_v1_to_v2(connection: sqlite3.Connection) -> None:
+        connection.execute(
+            "ALTER TABLE group_rooms ADD COLUMN max_reply_rounds INTEGER NOT NULL DEFAULT 3"
+        )
+        connection.execute(
+            "ALTER TABLE group_agents ADD COLUMN reply_without_mention INTEGER NOT NULL DEFAULT 0"
+        )
+        connection.execute(
+            "ALTER TABLE group_messages ADD COLUMN visible INTEGER NOT NULL DEFAULT 1"
+        )
+        connection.execute(
+            "ALTER TABLE group_agent_runs ADD COLUMN reply_mode TEXT NOT NULL DEFAULT 'mentioned'"
+        )
+        connection.execute(
+            "UPDATE group_meta SET value = '2' WHERE key = 'schema_version'"
+        )
+
+    @staticmethod
+    def _migrate_v2_to_v3(connection: sqlite3.Connection) -> None:
+        connection.execute("ALTER TABLE group_agents ADD COLUMN model_override TEXT")
+        connection.execute("ALTER TABLE group_agents ADD COLUMN provider_override TEXT")
+        connection.execute(
+            "ALTER TABLE group_agents ADD COLUMN reasoning_effort_override TEXT"
+        )
+        connection.execute(
+            "ALTER TABLE group_agents ADD COLUMN fast_mode_override INTEGER"
+        )
+        connection.execute(
+            "ALTER TABLE group_agents ADD COLUMN session_config_json TEXT"
+        )
+        connection.execute(
+            "UPDATE group_meta SET value = '3' WHERE key = 'schema_version'"
+        )
+
+    @staticmethod
+    def _validate_v3_columns(connection: sqlite3.Connection) -> None:
+        expected_columns = {
+            "group_meta": {"key", "value"},
+            "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
+            "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
+            "group_messages": {"seq", "id", "room_id", "sender_kind", "sender_id", "sender_name", "root_message_id", "reply_to_message_id", "client_message_id", "content", "reasoning", "tool_state_json", "status", "error", "visible", "created_at", "updated_at"},
+            "group_agent_runs": {"id", "room_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "status", "runtime_session_id", "error", "created_at", "updated_at"},
+            "group_interactions": {"id", "room_id", "agent_id", "run_id", "kind", "payload_json", "status", "created_at", "resolved_at"},
+            "group_events": {"cursor", "epoch", "room_id", "event_type", "payload_json", "created_at"},
+            "group_idempotency": {"request_id", "operation", "request_hash", "response_json", "created_at"},
+        }
+        exact_specs = {
+            ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
+            ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
+            ("group_messages", "visible"): ("INTEGER", 1, "1"),
+            ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
+        }
+        for table, required in expected_columns.items():
+            rows = list(connection.execute(f"PRAGMA table_info({table})"))
+            columns = {row["name"]: row for row in rows}
+            if set(columns) != required:
+                raise GroupStoreError("GroupStore schema is partial or corrupt")
+            for (target_table, name), spec in exact_specs.items():
+                if target_table != table:
+                    continue
+                row = columns[name]
+                actual = (str(row["type"]).upper(), int(row["notnull"]), row["dflt_value"])
+                if actual != spec:
+                    raise GroupStoreError("GroupStore schema is partial or corrupt")
+
+    @staticmethod
+    def _validate_v3_values(connection: sqlite3.Connection) -> None:
+        invalid_value_queries = (
+            f"""SELECT 1 FROM group_rooms
+                WHERE typeof(max_reply_rounds) != 'integer'
+                   OR (max_reply_rounds != -1 AND
+                       (max_reply_rounds < 1 OR
+                        max_reply_rounds > {MAX_FINITE_REPLY_ROUNDS}))
+                LIMIT 1""",
+            """SELECT 1 FROM group_agents
+                WHERE typeof(reply_without_mention) != 'integer'
+                   OR reply_without_mention NOT IN (0, 1)
+                LIMIT 1""",
+            """SELECT 1 FROM group_agents
+                WHERE fast_mode_override IS NOT NULL
+                  AND (typeof(fast_mode_override) != 'integer'
+                       OR fast_mode_override NOT IN (0, 1))
+                LIMIT 1""",
+            """SELECT 1 FROM group_agents
+                WHERE reasoning_effort_override IS NOT NULL
+                  AND reasoning_effort_override NOT IN
+                    ('none','minimal','low','medium','high','xhigh','max','ultra')
+                LIMIT 1""",
+            """SELECT 1 FROM group_messages
+                WHERE typeof(visible) != 'integer'
+                   OR visible NOT IN (0, 1)
+                LIMIT 1""",
+            """SELECT 1 FROM group_agent_runs
+                WHERE typeof(reply_mode) != 'text'
+                   OR reply_mode NOT IN ('mentioned', 'automatic')
+                LIMIT 1""",
+        )
+        for query in invalid_value_queries:
+            if connection.execute(query).fetchone() is not None:
+                raise GroupStoreError("GroupStore stored values are corrupt")
+
+    @staticmethod
+    def _ensure_indexes(connection: sqlite3.Connection) -> None:
+        """Repair v1 operational indexes without a schema-version migration."""
+
+        def canonical_index_sql(value: str) -> str:
+            without_guard = re.sub(
+                r"\bIF\s+NOT\s+EXISTS\b", "", value, flags=re.IGNORECASE
+            )
+            return " ".join(without_guard.split()).casefold()
+
+        indexes = {
+            "idx_group_rooms_ordering": (
+                [("archived", 0), ("updated_at", 1), ("id", 1)],
+                "group_rooms(archived, updated_at DESC, id DESC)",
+            ),
+            "idx_group_agent_runs_agent_status": (
+                [("agent_id", 0), ("status", 0)],
+                "group_agent_runs(agent_id, status)",
+            ),
+            "idx_group_agent_runs_room_status": (
+                [("room_id", 0), ("status", 0)],
+                "group_agent_runs(room_id, status)",
+            ),
+            "idx_group_agent_runs_status_order": (
+                [("status", 0), ("created_at", 0), ("id", 0)],
+                "group_agent_runs(status, created_at, id)",
+            ),
+            "idx_group_idempotency_operation": (
+                [("operation", 0)],
+                "group_idempotency(operation)",
+            ),
+            "idx_group_interactions_run_status_kind": (
+                [("run_id", 0), ("status", 0), ("kind", 0)],
+                "group_interactions(run_id, status, kind)",
+            ),
+        }
+        for name, (expected, definition) in indexes.items():
+            columns = [
+                (row["name"], row["desc"])
+                for row in connection.execute(f"PRAGMA index_xinfo({name})")
+                if row["key"]
+            ]
+            stored = connection.execute(
+                """SELECT sql FROM sqlite_master
+                WHERE type = 'index' AND name = ?""",
+                (name,),
+            ).fetchone()
+            expected_sql = canonical_index_sql(f"CREATE INDEX {name} ON {definition}")
+            stored_sql = (
+                None
+                if stored is None or stored["sql"] is None
+                else canonical_index_sql(stored["sql"])
+            )
+            if columns == expected and stored_sql == expected_sql:
+                continue
+            connection.execute(f"DROP INDEX IF EXISTS {name}")
+            connection.execute(f"CREATE INDEX {name} ON {definition}")
+        active_runtime_definition = """CREATE UNIQUE INDEX
+            idx_group_agent_runs_active_runtime
+            ON group_agent_runs(runtime_session_id)
+            WHERE runtime_session_id IS NOT NULL
+              AND status IN ('running', 'awaiting_input')"""
+        stored = connection.execute(
+            """SELECT sql FROM sqlite_master
+            WHERE type = 'index' AND name = ?""",
+            ("idx_group_agent_runs_active_runtime",),
+        ).fetchone()
+        expected_sql = canonical_index_sql(active_runtime_definition)
+        stored_sql = (
+            None
+            if stored is None or stored["sql"] is None
+            else canonical_index_sql(stored["sql"])
+        )
+        if stored_sql == expected_sql:
+            return
+        connection.execute("DROP INDEX IF EXISTS idx_group_agent_runs_active_runtime")
+        try:
+            connection.execute(active_runtime_definition)
+        except sqlite3.IntegrityError as error:
+            raise GroupStoreError("Active runtime sessions are not unique") from error
+
+    @staticmethod
+    def _user_table_names(connection: sqlite3.Connection) -> set[str]:
+        return {
+            row["name"]
+            for row in connection.execute(
+                """SELECT name FROM sqlite_master
+                WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"""
+            )
+        }
+
+    @staticmethod
+    def _is_busy_or_locked(error: sqlite3.OperationalError) -> bool:
+        message = str(error).lower()
+        return "locked" in message or "busy" in message
+
+    @staticmethod
+    def _validated_schema_version(value: str) -> int:
+        try:
+            version = int(value)
+        except (TypeError, ValueError) as error:
+            raise GroupStoreError("GroupStore schema_version is corrupt") from error
+        if value != str(SCHEMA_VERSION) or version != SCHEMA_VERSION:
+            raise GroupStoreError("Unsupported or corrupt GroupStore schema_version")
+        return version
+
+    def _metadata_value(self, key: str) -> str:
+        try:
+            with self.connection() as connection:
+                return self._metadata_value_from_connection(connection, key)
+        except sqlite3.OperationalError as error:
+            raise GroupStoreError("GroupStore is not initialized") from error
+
+    @staticmethod
+    def _metadata_value_from_connection(
+        connection: sqlite3.Connection, key: str
+    ) -> str:
+        row = connection.execute(
+            "SELECT value FROM group_meta WHERE key = ?", (key,)
+        ).fetchone()
+        if row is None:
+            raise GroupStoreError(f"GroupStore metadata is missing {key}")
+        return row["value"]
+
+    @staticmethod
+    def _validated_epoch(value: str) -> str:
+        try:
+            canonical = str(uuid.UUID(value))
+        except (ValueError, AttributeError) as error:
+            raise GroupStoreError("GroupStore journal_epoch is corrupt") from error
+        if value != canonical:
+            raise GroupStoreError("GroupStore journal_epoch is corrupt")
+        return canonical
+
+    @contextmanager
+    def read_transaction(self) -> Iterator[sqlite3.Connection]:
+        """Yield one consistent read snapshot and always close its connection."""
+        with self.connection() as connection:
+            connection.execute("BEGIN")
+            try:
+                yield connection
+            except Exception:
+                connection.execute("ROLLBACK")
+                raise
+            else:
+                connection.execute("COMMIT")
+
+    @contextmanager
+    def write_transaction(self) -> Iterator[sqlite3.Connection]:
+        """Yield one immediately-locked write transaction and always close it."""
+        with self.connection() as connection:
+            connection.execute("BEGIN IMMEDIATE")
+            try:
+                yield connection
+            except Exception:
+                connection.execute("ROLLBACK")
+                raise
+            else:
+                connection.execute("COMMIT")
+
+    def execute_idempotent(
+        self,
+        operation: str,
+        request_id: str,
+        payload: Mapping[str, object],
+        action: Callable[[sqlite3.Connection], dict[str, object]],
+    ) -> dict[str, object]:
+        """Execute a mutation exactly once for the canonical request and payload."""
+        canonical_request_id = self._canonical_uuid(request_id, "requestId")
+        request_hash = hashlib.sha256(
+            self._canonical_json(payload).encode()
+        ).hexdigest()
+        try:
+            with self.write_transaction() as connection:
+                existing = connection.execute(
+                    """SELECT operation, request_hash, response_json FROM group_idempotency
+                    WHERE request_id = ?""",
+                    (canonical_request_id,),
+                ).fetchone()
+                if existing is not None:
+                    if (
+                        existing["operation"] != operation
+                        or existing["request_hash"] != request_hash
+                    ):
+                        raise IdempotencyConflict(
+                            "requestId was already used for a different request"
+                        )
+                    stored_response = existing["response_json"]
+                    try:
+                        response = self._load_json(stored_response)
+                    except (ValueError, json.JSONDecodeError, TypeError) as error:
+                        raise GroupStoreError(
+                            "Stored idempotency response is corrupt"
+                        ) from error
+                    if (
+                        not isinstance(response, dict)
+                        or self._canonical_json(response) != stored_response
+                    ):
+                        raise GroupStoreError("Stored idempotency response is corrupt")
+                    return response
+                response = action(connection)
+                response_json = self._canonical_json(response)
+                connection.execute(
+                    """INSERT INTO group_idempotency
+                    (request_id, operation, request_hash, response_json, created_at)
+                    VALUES (?, ?, ?, ?, ?)""",
+                    (
+                        canonical_request_id,
+                        operation,
+                        request_hash,
+                        response_json,
+                        self._now(),
+                    ),
+                )
+                return response
+        except sqlite3.IntegrityError as error:
+            raise self._integrity_conflict(error) from error
+
+    def create_room(self, command: Mapping[str, object]) -> dict[str, object]:
+        command = self._command(
+            command, {"requestId", "name", "cwd", "maxReplyRounds", "agents"}
+        )
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            name = normalize_room_name(self._string(command, "name"))
+            cwd = normalize_room_cwd(self._string(command, "cwd"))
+            max_reply_rounds = normalize_max_reply_rounds(
+                command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
+            )
+            agents = self._new_agents(command.get("agents"))
+            now = self._now()
+            room_id = str(uuid.uuid4())
+            connection.execute(
+                """INSERT INTO group_rooms
+                (id, name, cwd, max_reply_rounds, created_at, updated_at, archived)
+                VALUES (?, ?, ?, ?, ?, ?, 0)""",
+                (room_id, name, cwd, max_reply_rounds, now, now),
+            )
+            for agent in agents:
+                self._insert_agent(connection, room_id, agent, now)
+            room = self._room_detail(connection, room_id, include_archived=True)
+            self._append_event(
+                connection, room_id, "room.created", room, created_at=now
+            )
+            return room
+
+        return self.execute_idempotent("room.created", request_id, command, action)
+
+    def get_room(
+        self, room_id: str, *, include_archived: bool = False
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        with self.read_transaction() as connection:
+            return self._room_detail(
+                connection, canonical_room_id, include_archived=include_archived
+            )
+
+    def room_snapshot(self, room_id: str) -> dict[str, object]:
+        """Read all state needed to recover one active room in one snapshot."""
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        with self.read_transaction() as connection:
+            result = self._room_detail(
+                connection, canonical_room_id, include_archived=False
+            )
+            run_rows = connection.execute(
+                """SELECT * FROM group_agent_runs
+                WHERE room_id = ? AND status IN ('queued', 'running', 'awaiting_input')
+                ORDER BY created_at ASC, id ASC""",
+                (canonical_room_id,),
+            ).fetchall()
+            interaction_rows = connection.execute(
+                """SELECT * FROM group_interactions
+                WHERE room_id = ? AND status = 'pending'
+                ORDER BY created_at ASC, id ASC""",
+                (canonical_room_id,),
+            ).fetchall()
+            cursor_row = connection.execute(
+                "SELECT COALESCE(MAX(cursor), 0) AS cursor FROM group_events"
+            ).fetchone()
+
+            status_priority = {
+                "queued": 1,
+                "running": 2,
+                "awaiting_input": 3,
+            }
+            statuses: dict[str, str] = {}
+            for run in run_rows:
+                previous = statuses.get(run["agent_id"], "idle")
+                if status_priority[run["status"]] > status_priority.get(previous, 0):
+                    statuses[run["agent_id"]] = run["status"]
+            for agent in result["agents"]:
+                agent["status"] = statuses.get(agent["id"], "idle")
+
+            result["runs"] = [self._run_wire(row) for row in run_rows]
+            result["pendingInteractions"] = [
+                self._interaction_wire(row) for row in interaction_rows
+            ]
+            result["latestCursor"] = int(cursor_row["cursor"])
+            return result
+
+    def list_rooms(self, *, limit: int, cursor: str | None) -> CursorPage:
+        if (
+            not isinstance(limit, int)
+            or isinstance(limit, bool)
+            or not 1 <= limit <= 100
+        ):
+            raise ValueError("limit must be an integer from 1 to 100")
+        cursor_values = self._decode_cursor(cursor) if cursor is not None else None
+        query = """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
+            (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count
+            FROM group_rooms WHERE archived = 0"""
+        params: list[object] = []
+        if cursor_values is not None:
+            query += " AND ((updated_at < ?) OR (updated_at = ? AND id < ?))"
+            params.extend((cursor_values[0], cursor_values[0], cursor_values[1]))
+        query += " ORDER BY updated_at DESC, id DESC LIMIT ?"
+        params.append(limit + 1)
+        with self.connection() as connection:
+            rows = connection.execute(query, params).fetchall()
+        has_more = len(rows) > limit
+        items = [self._room_summary(row) for row in rows[:limit]]
+        next_cursor = None
+        if has_more and items:
+            last = rows[limit - 1]
+            next_cursor = self._encode_cursor(last["updated_at"], last["id"])
+        return CursorPage(items=items, next_cursor=next_cursor)
+
+    def update_room(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        command = self._command(
+            command, {"requestId", "name", "cwd", "maxReplyRounds"}
+        )
+        request_id = self._command_request_id(command)
+        changes = {
+            key for key in ("name", "cwd", "maxReplyRounds") if key in command
+        }
+        if not changes:
+            raise ValueError("Room update requires name, cwd, or maxReplyRounds")
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            values: list[object] = []
+            assignments: list[str] = []
+            if "name" in changes:
+                assignments.append("name = ?")
+                values.append(normalize_room_name(self._string(command, "name")))
+            if "cwd" in changes:
+                assignments.append("cwd = ?")
+                values.append(normalize_room_cwd(self._string(command, "cwd")))
+            if "maxReplyRounds" in changes:
+                assignments.append("max_reply_rounds = ?")
+                values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
+            assignments.append("updated_at = ?")
+            values.extend((self._now(), canonical_room_id))
+            connection.execute(
+                f"UPDATE group_rooms SET {', '.join(assignments)} WHERE id = ?", values
+            )
+        model_changes = changes & {"model", "provider"}
+        if model_changes and model_changes != {"model", "provider"}:
+            raise ValueError("model and provider must be updated together")
+        if model_changes and ((command["model"] is None) != (command["provider"] is None)):
+            raise ValueError("model and provider must both be set or both be null")
+            room = self._room_detail(
+                connection, canonical_room_id, include_archived=True
+            )
+            self._append_event(connection, canonical_room_id, "room.updated", room)
+            return room
+
+        return self.execute_idempotent(
+            "room.updated",
+            request_id,
+            self._scoped_payload(command, canonical_room_id),
+            action,
+        )
+
+    def archive_room(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        return self.archive_room_with_runtime_targets(room_id, command)["result"]
+
+    def archive_room_with_runtime_targets(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            now = self._now()
+            interrupted = self._interrupt_runs_for_scope(
+                connection,
+                room_id=canonical_room_id,
+                reason="Room archived",
+                now=now,
+            )
+            connection.execute(
+                "UPDATE group_rooms SET archived = 1, updated_at = ? WHERE id = ?",
+                (now, canonical_room_id),
+            )
+            room = self._room_detail(
+                connection, canonical_room_id, include_archived=True
+            )
+            self._append_event(
+                connection, canonical_room_id, "room.deleted", room, created_at=now
+            )
+            return {
+                "result": room,
+                "runtimeSessionIds": interrupted["runtimeSessionIds"],
+            }
+
+        response = self.execute_idempotent(
+            "room.deleted",
+            request_id,
+            self._scoped_payload(command, canonical_room_id),
+            action,
+        )
+        return self._runtime_envelope(response)
+
+    def add_agent(
+        self, room_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        command = self._command(
+            command,
+            {
+                "requestId",
+                "profile",
+                "displayName",
+                "description",
+                "replyWithoutMention",
+                "model",
+                "provider",
+                "reasoningEffort",
+                "fastMode",
+            },
+        )
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            count = connection.execute(
+                "SELECT COUNT(*) FROM group_agents WHERE room_id = ?",
+                (canonical_room_id,),
+            ).fetchone()[0]
+            if count >= MAX_AGENTS_PER_ROOM:
+                raise GroupConflictError(
+                    "Room has reached the maximum number of agents"
+                )
+            agent = self._new_agent(
+                {
+                    key: command[key]
+                    for key in (
+                        "profile",
+                        "displayName",
+                        "description",
+                        "replyWithoutMention",
+                        "model",
+                        "provider",
+                        "reasoningEffort",
+                        "fastMode",
+                    )
+                    if key in command
+                }
+            )
+            now = self._now()
+            agent_id = self._insert_agent(connection, canonical_room_id, agent, now)
+            result = self._agent_detail(connection, canonical_room_id, agent_id)
+            connection.execute(
+                "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
+                (now, canonical_room_id),
+            )
+            self._append_event(connection, canonical_room_id, "agent.created", result)
+            self._append_room_updated_summary(
+                connection, canonical_room_id, created_at=now
+            )
+            return result
+
+        return self.execute_idempotent(
+            "agent.created",
+            request_id,
+            self._scoped_payload(command, canonical_room_id),
+            action,
+        )
+
+    def update_agent(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        return self.update_agent_with_runtime_targets(room_id, agent_id, command)[
+            "result"
+        ]
+
+    def update_agent_with_runtime_targets(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
+        command = self._command(
+            command,
+            {
+                "requestId",
+                "displayName",
+                "description",
+                "enabled",
+                "replyWithoutMention",
+                "model",
+                "provider",
+                "reasoningEffort",
+                "fastMode",
+            },
+        )
+        request_id = self._command_request_id(command)
+        changes = {
+            key
+            for key in (
+                "displayName",
+                "description",
+                "enabled",
+                "replyWithoutMention",
+                "model",
+                "provider",
+                "reasoningEffort",
+                "fastMode",
+            )
+            if key in command
+        }
+        if not changes:
+            raise ValueError(
+                "Agent update requires a mutable agent field"
+            )
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            previous = self._owned_agent(
+                connection, canonical_room_id, canonical_agent_id
+            )
+            assignments: list[str] = []
+            values: list[object] = []
+            if "displayName" in changes:
+                raw_name = command.get("displayName")
+                configured = self._resolved_agent_name(previous["profile"])
+                effective = (
+                    raw_name.strip()
+                    if isinstance(raw_name, str) and raw_name.strip()
+                    else configured or previous["profile"]
+                )
+                display_name, display_key = normalize_display_name(effective)
+                self._check_agent_conflicts(
+                    connection,
+                    canonical_room_id,
+                    display_key,
+                    exclude_agent_id=canonical_agent_id,
+                )
+                assignments.extend(("display_name = ?", "display_name_key = ?"))
+                values.extend((display_name, display_key))
+            if "description" in changes:
+                assignments.append("description = ?")
+                values.append(self._description(command.get("description")))
+            if "enabled" in changes:
+                enabled = command["enabled"]
+                if not isinstance(enabled, bool):
+                    raise ValueError("enabled must be a boolean")
+                assignments.append("enabled = ?")
+                values.append(int(enabled))
+            if "replyWithoutMention" in changes:
+                reply_without_mention = command["replyWithoutMention"]
+                if not isinstance(reply_without_mention, bool):
+                    raise ValueError("replyWithoutMention must be a boolean")
+                assignments.append("reply_without_mention = ?")
+                values.append(int(reply_without_mention))
+            configuration_columns = {
+                "model": "model_override",
+                "provider": "provider_override",
+                "reasoningEffort": "reasoning_effort_override",
+            }
+            for key, column in configuration_columns.items():
+                if key in changes:
+                    assignments.append(f"{column} = ?")
+                    values.append(self._agent_configuration_value(command[key], key))
+            if "fastMode" in changes:
+                fast_mode = command["fastMode"]
+                if fast_mode is not None and not isinstance(fast_mode, bool):
+                    raise ValueError("fastMode must be a boolean or null")
+                assignments.append("fast_mode_override = ?")
+                values.append(None if fast_mode is None else int(fast_mode))
+            if changes & {"model", "provider", "reasoningEffort", "fastMode"}:
+                # The active run keeps its current session. The next scheduler
+                # pass sees this stale marker and rotates before submitting.
+                assignments.append("session_config_json = NULL")
+            now = self._now()
+            interrupted = {"runIds": [], "runtimeSessionIds": []}
+            if (
+                "enabled" in changes
+                and bool(previous["enabled"])
+                and command["enabled"] is False
+            ):
+                interrupted = self._interrupt_runs_for_scope(
+                    connection,
+                    room_id=canonical_room_id,
+                    agent_id=canonical_agent_id,
+                    reason="Agent disabled",
+                    now=now,
+                )
+            assignments.append("updated_at = ?")
+            values.extend((now, canonical_agent_id, canonical_room_id))
+            connection.execute(
+                f"UPDATE group_agents SET {', '.join(assignments)} WHERE id = ? AND room_id = ?",
+                values,
+            )
+            connection.execute(
+                "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
+                (now, canonical_room_id),
+            )
+            result = self._agent_detail(
+                connection, canonical_room_id, canonical_agent_id
+            )
+            self._append_event(connection, canonical_room_id, "agent.updated", result)
+            self._append_room_updated_summary(
+                connection, canonical_room_id, created_at=now
+            )
+            if interrupted["runIds"]:
+                self._append_agent_status(
+                    connection, canonical_room_id, canonical_agent_id, now
+                )
+            return {
+                "result": result,
+                "runtimeSessionIds": interrupted["runtimeSessionIds"],
+            }
+
+        response = self.execute_idempotent(
+            "agent.updated",
+            request_id,
+            self._scoped_payload(command, canonical_room_id, canonical_agent_id),
+            action,
+        )
+        return self._runtime_envelope(response)
+
+    def delete_agent(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        return self.delete_agent_with_runtime_targets(room_id, agent_id, command)[
+            "result"
+        ]
+
+    def delete_agent_with_runtime_targets(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            result = self._agent_detail(
+                connection, canonical_room_id, canonical_agent_id
+            )
+            agent_count = connection.execute(
+                "SELECT COUNT(*) FROM group_agents WHERE room_id = ?",
+                (canonical_room_id,),
+            ).fetchone()[0]
+            if agent_count <= 1:
+                raise GroupConflictError("Room must retain at least one agent")
+            now = self._now()
+            interrupted = self._interrupt_runs_for_scope(
+                connection,
+                room_id=canonical_room_id,
+                agent_id=canonical_agent_id,
+                reason="Agent removed",
+                now=now,
+            )
+            connection.execute(
+                "DELETE FROM group_agents WHERE id = ? AND room_id = ?",
+                (canonical_agent_id, canonical_room_id),
+            )
+            connection.execute(
+                "UPDATE group_rooms SET updated_at = ? WHERE id = ?",
+                (now, canonical_room_id),
+            )
+            self._append_event(
+                connection, canonical_room_id, "agent.deleted", result, created_at=now
+            )
+            self._append_room_updated_summary(
+                connection, canonical_room_id, created_at=now
+            )
+            return {
+                "result": result,
+                "runtimeSessionIds": interrupted["runtimeSessionIds"],
+            }
+
+        response = self.execute_idempotent(
+            "agent.deleted",
+            request_id,
+            self._scoped_payload(command, canonical_room_id, canonical_agent_id),
+            action,
+        )
+        return self._runtime_envelope(response)
+
+    def interrupt_agent_with_runtime_targets(
+        self, room_id: str, agent_id: str, command: Mapping[str, object]
+    ) -> dict[str, object]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_agent_id = self._canonical_uuid(agent_id, "agentId")
+        command = self._command(command, {"requestId"})
+        request_id = self._command_request_id(command)
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            self._owned_agent(connection, canonical_room_id, canonical_agent_id)
+            now = self._now()
+            interrupted = self._interrupt_runs_for_scope(
+                connection,
+                room_id=canonical_room_id,
+                agent_id=canonical_agent_id,
+                reason="Agent interrupted",
+                now=now,
+                include_queued=False,
+            )
+            if interrupted["runIds"]:
+                self._append_agent_status(
+                    connection, canonical_room_id, canonical_agent_id, now
+                )
+            return {
+                "result": {
+                    "roomId": canonical_room_id,
+                    "agentId": canonical_agent_id,
+                    "interruptedRunIds": interrupted["runIds"],
+                },
+                "runtimeSessionIds": interrupted["runtimeSessionIds"],
+            }
+
+        response = self.execute_idempotent(
+            "agent.interrupted",
+            request_id,
+            self._scoped_payload(command, canonical_room_id, canonical_agent_id),
+            action,
+        )
+        return self._runtime_envelope(response)
+
+    def create_human_message(
+        self,
+        room_id: str,
+        *,
+        request_id: str,
+        client_message_id: str,
+        content: str,
+        mention_agent_ids: object,
+    ) -> dict[str, object]:
+        """Persist one human turn and the deterministic agent work it starts."""
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        canonical_request_id = self._canonical_uuid(request_id, "requestId")
+        canonical_client_id = self._canonical_uuid(client_message_id, "clientMessageId")
+        message_content = self._message_text(
+            content, "content", max_bytes=MAX_MESSAGE_BYTES, nonblank=True
+        )
+        agent_ids = self._mention_agent_ids(mention_agent_ids)
+        payload = {
+            "roomId": canonical_room_id,
+            "clientMessageId": canonical_client_id,
+            "content": message_content,
+            "mentionAgentIds": agent_ids,
+        }
+
+        def action(connection: sqlite3.Connection) -> dict[str, object]:
+            self._active_room(connection, canonical_room_id)
+            agents: list[tuple[sqlite3.Row, str]] = []
+            room_agents = connection.execute(
+                """SELECT * FROM group_agents WHERE room_id = ?
+                ORDER BY created_at ASC, id ASC""",
+                (canonical_room_id,),
+            ).fetchall()
+            parsed_ids, _warnings = self._plan_cascade_mentions(
+                message_content,
+                room_agents,
+                source_agent_id="human",
+                include_automatic=False,
+            )
+            explicit_agent_ids = list(dict.fromkeys([*agent_ids, *parsed_ids]))
+            explicit_ids = set(explicit_agent_ids)
+            for agent_id in explicit_agent_ids:
+                agent = self._owned_agent(connection, canonical_room_id, agent_id)
+                if not agent["enabled"]:
+                    raise GroupConflictError("Mentioned agent is disabled")
+                agents.append((agent, "mentioned"))
+            automatic_agents = [
+                agent
+                for agent in room_agents
+                if agent["enabled"] and agent["reply_without_mention"]
+            ]
+            agents.extend(
+                (agent, "automatic")
+                for agent in automatic_agents
+                if agent["id"] not in explicit_ids
+            )
+            now = self._now()
+            human_id = str(uuid.uuid4())
+            connection.execute(
+                """INSERT INTO group_messages
+                (id, room_id, sender_kind, sender_id, sender_name, root_message_id,
+                 reply_to_message_id, client_message_id, content, reasoning, tool_state_json,
+                 status, error, created_at, updated_at)
+                VALUES (?, ?, 'human', 'human', '你', ?, NULL, ?, ?, '', '[]', 'completed', '', ?, ?)""",
+                (
+                    human_id,
+                    canonical_room_id,
+                    human_id,
+                    canonical_client_id,
+                    message_content,
+                    now,
+                    now,
+                ),
+            )
+            message = self._message_wire(self._message_row(connection, human_id))
+            self._append_event(
+                connection, canonical_room_id, "message.upsert", message, created_at=now
+            )
+            runs: list[dict[str, object]] = []
+            for agent, reply_mode in agents:
+                visible = reply_mode == "mentioned"
+                response_id, run_id = str(uuid.uuid4()), str(uuid.uuid4())
+                connection.execute(
+                    """INSERT INTO group_messages
+                    (id, room_id, sender_kind, sender_id, sender_name, root_message_id,
+                     reply_to_message_id, client_message_id, content, reasoning, tool_state_json,
+                     status, error, visible, created_at, updated_at)
+                    VALUES (?, ?, 'agent', ?, ?, ?, ?, NULL, '', '', '[]', 'queued', '', ?, ?, ?)""",
+                    (
+                        response_id,
+                        canonical_room_id,
+                        agent["id"],
+                        agent["display_name"],
+                        human_id,
+                        human_id,
+                        int(visible),
+                        now,
+                        now,
+                    ),
+                )
+                connection.execute(
+                    """INSERT INTO group_agent_runs
+                    (id, room_id, agent_id, trigger_message_id, response_message_id, root_message_id,
+                     depth, reply_mode, status, runtime_session_id, error, created_at, updated_at)
+                    VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'queued', NULL, '', ?, ?)""",
+                    (
+                        run_id,
+                        canonical_room_id,
+                        agent["id"],
+                        human_id,
+                        response_id,
+                        human_id,
+                        reply_mode,
+                        now,
+                        now,
+                    ),
+                )
+                response = self._message_wire(
+                    self._message_row(connection, response_id)
+                )
+                run = self._run_wire(self._run_row(connection, run_id))
+                if visible:
+                    self._append_event(
+                        connection,
+                        canonical_room_id,
+                        "message.upsert",
+                        response,
+                        created_at=now,
+                    )
+                self._append_event(
+                    connection, canonical_room_id, "run.updated", run, created_at=now
+                )
+                runs.append(run)
+            return {"message": message, "runs": runs}
+
+        try:
+            return self.execute_idempotent(
+                "message.create", canonical_request_id, payload, action
+            )
+        except sqlite3.IntegrityError as error:
+            raise self._integrity_conflict(error) from error
+
+    def get_message(self, message_id: str) -> dict[str, object]:
+        canonical_message_id = self._canonical_uuid(message_id, "messageId")
+        with self.read_transaction() as connection:
+            return self._message_wire(
+                self._message_row(connection, canonical_message_id)
+            )
+
+    def get_run(self, run_id: str) -> dict[str, object]:
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        with self.read_transaction() as connection:
+            return self._run_wire(self._run_row(connection, canonical_run_id))
+
+    def get_interaction(self, interaction_id: str) -> dict[str, object]:
+        canonical_interaction_id = self._interaction_id(interaction_id)
+        with self.read_transaction() as connection:
+            return self._interaction_wire(
+                self._interaction_row(connection, canonical_interaction_id)
+            )
+
+    def list_messages(
+        self,
+        room_id: str,
+        *,
+        before_seq: int | None = None,
+        after_seq: int | None = None,
+        limit: int = MAX_MESSAGE_PAGE_SIZE,
+    ) -> list[dict[str, object]]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        self._page_limit(limit, MAX_MESSAGE_PAGE_SIZE, "limit")
+        if before_seq is not None and after_seq is not None:
+            raise ValueError("before_seq and after_seq are mutually exclusive")
+        before = (
+            self._positive_int(before_seq, "before_seq")
+            if before_seq is not None
+            else None
+        )
+        after = (
+            self._positive_int(after_seq, "after_seq")
+            if after_seq is not None
+            else None
+        )
+        with self.read_transaction() as connection:
+            self._room_detail(connection, canonical_room_id, include_archived=True)
+            if before is not None:
+                rows = connection.execute(
+                    "SELECT * FROM group_messages WHERE room_id = ? AND visible = 1 AND seq < ? ORDER BY seq DESC LIMIT ?",
+                    (canonical_room_id, before, limit),
+                ).fetchall()
+                rows = list(reversed(rows))
+            elif after is not None:
+                rows = connection.execute(
+                    "SELECT * FROM group_messages WHERE room_id = ? AND visible = 1 AND seq > ? ORDER BY seq ASC LIMIT ?",
+                    (canonical_room_id, after, limit),
+                ).fetchall()
+            else:
+                rows = connection.execute(
+                    "SELECT * FROM group_messages WHERE room_id = ? AND visible = 1 ORDER BY seq DESC LIMIT ?",
+                    (canonical_room_id, limit),
+                ).fetchall()
+                rows = list(reversed(rows))
+            return [self._message_wire(row) for row in rows]
+
+    def claim_next_runnable_run(self) -> dict[str, object] | None:
+        """Atomically claim the oldest eligible queued run."""
+        with self.write_transaction() as connection:
+            active_count = int(
+                connection.execute(
+                    """SELECT COUNT(*) FROM group_agent_runs
+                    WHERE status IN ('running', 'awaiting_input')"""
+                ).fetchone()[0]
+            )
+            if active_count >= MAX_PLUGIN_CONCURRENCY:
+                return None
+            run = connection.execute(
+                """SELECT candidate.* FROM group_agent_runs AS candidate
+                JOIN group_rooms AS room ON room.id = candidate.room_id
+                JOIN group_agents AS agent
+                  ON agent.id = candidate.agent_id
+                 AND agent.room_id = candidate.room_id
+                JOIN group_messages AS trigger_message
+                  ON trigger_message.id = candidate.trigger_message_id
+                WHERE candidate.status = 'queued'
+                  AND room.archived = 0
+                  AND agent.enabled = 1
+                  AND NOT EXISTS (
+                    SELECT 1 FROM group_agent_runs AS occupied_agent
+                    WHERE occupied_agent.agent_id = candidate.agent_id
+                      AND occupied_agent.status IN ('running', 'awaiting_input')
+                  )
+                  AND (
+                    SELECT COUNT(*) FROM group_agent_runs AS occupied_room
+                    WHERE occupied_room.room_id = candidate.room_id
+                      AND occupied_room.status IN ('running', 'awaiting_input')
+                  ) < ?
+                ORDER BY trigger_message.seq ASC,
+                         candidate.created_at ASC,
+                         candidate.id ASC
+                LIMIT 1""",
+                (MAX_ROOM_CONCURRENCY,),
+            ).fetchone()
+            if run is None:
+                return None
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            now = self._now()
+            result = self._update_run(connection, run, "running", now, UNSET, UNSET)
+            self._append_event(
+                connection,
+                run["room_id"],
+                "run.updated",
+                result,
+                created_at=now,
+            )
+            self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
+            return result
+
+    def read_run_projection(self, run_id: str) -> dict[str, object]:
+        """Return a stable, bounded context projection for one claimed run."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        with self.read_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            if run["status"] not in {"running", "awaiting_input"}:
+                raise GroupConflictError("Run is not claimed")
+            room = self._active_room(connection, run["room_id"])
+            agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            trigger = self._message_row(connection, run["trigger_message_id"])
+            if trigger["room_id"] != run["room_id"]:
+                raise GroupStoreError("Run trigger message is corrupt")
+            lower_seq = int(agent["last_context_message_seq"])
+            trigger_seq = int(trigger["seq"])
+            through_seq = self._safe_context_seq(
+                connection, run["room_id"], lower_seq, trigger_seq
+            )
+            eligibility = """FROM group_messages
+                WHERE room_id = ? AND seq > ? AND seq <= ?
+                  AND visible = 1
+                  AND status IN ('completed', 'failed', 'interrupted')
+                  AND NOT (sender_kind = 'agent' AND sender_id = ?)"""
+            parameters = (
+                run["room_id"],
+                lower_seq,
+                trigger_seq,
+                run["agent_id"],
+            )
+            total = int(
+                connection.execute(
+                    f"SELECT COUNT(*) {eligibility}", parameters
+                ).fetchone()[0]
+            )
+            rows = connection.execute(
+                f"""SELECT * {eligibility}
+                ORDER BY seq DESC LIMIT ?""",
+                (*parameters, INITIAL_CONTEXT_MESSAGE_LIMIT),
+            ).fetchall()
+            rows = list(reversed(rows))
+            omitted = total - len(rows)
+            omitted_through_seq: int | None = None
+            if omitted:
+                omitted_through_seq = int(
+                    connection.execute(
+                        f"""SELECT MAX(seq) {eligibility}
+                        AND seq < ?""",
+                        (*parameters, rows[0]["seq"]),
+                    ).fetchone()[0]
+                )
+            initial = agent["stored_session_id"] is None
+            messages = [self._projection_message(row) for row in rows]
+            messages, omitted, omitted_through_seq, omitted_summary = (
+                self._bound_projection_messages(
+                    messages,
+                    omitted_count=omitted,
+                    omitted_through_seq=omitted_through_seq,
+                )
+            )
+            return {
+                "run": self._run_wire(run),
+                "room": self._room_wire(room),
+                "agent": self._agent_wire(agent),
+                "messages": messages,
+                "initial": initial,
+                "triggerSeq": trigger_seq,
+                "throughSeq": through_seq,
+                "omittedMessageCount": omitted,
+                "omittedThroughSeq": omitted_through_seq,
+                "omittedSummary": omitted_summary,
+                "characterBudget": CONTEXT_CHARACTER_BUDGET,
+            }
+
+    def bind_run_session(
+        self,
+        run_id: str,
+        *,
+        expected_stored_session_id: str | None,
+        stored_session_id: str,
+        runtime_session_id: str,
+    ) -> dict[str, object]:
+        """CAS-bind stored and runtime identities for the current generation."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        expected_stored = self._runtime_session_id(expected_stored_session_id)
+        stored = self._runtime_session_id(stored_session_id)
+        runtime = self._runtime_session_id(runtime_session_id)
+        if stored is None:
+            raise ValueError("storedSessionId is invalid")
+        if runtime is None:
+            raise ValueError("runtimeSessionId is invalid")
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            if run["status"] not in {"running", "awaiting_input"}:
+                raise GroupConflictError("Run does not accept a session binding")
+            if agent["stored_session_id"] != expected_stored:
+                raise GroupConflictError("stored session generation changed")
+            if run["runtime_session_id"] not in {None, runtime}:
+                raise GroupConflictError("Run runtime is already bound")
+            self._require_available_runtime(connection, runtime, canonical_run_id)
+            now = self._now()
+            if agent["stored_session_id"] != stored:
+                connection.execute(
+                    """UPDATE group_agents
+                    SET stored_session_id = ?, updated_at = ? WHERE id = ?""",
+                    (stored, now, agent["id"]),
+                )
+                agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "agent.updated",
+                    self._agent_wire(agent),
+                    created_at=now,
+                )
+            if run["runtime_session_id"] != runtime:
+                changed = self._update_run(
+                    connection, run, run["status"], now, runtime, UNSET
+                )
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "run.updated",
+                    changed,
+                    created_at=now,
+                )
+            else:
+                changed = self._run_wire(run)
+            return {"run": changed, "agent": self._agent_wire(agent)}
+
+    def advance_run_context(
+        self,
+        run_id: str,
+        *,
+        runtime_session_id: str,
+        expected_context_seq: int,
+        through_seq: int,
+    ) -> dict[str, object]:
+        """Advance one Agent context watermark with generation and hole CAS checks."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        runtime = self._runtime_session_id(runtime_session_id)
+        if runtime is None:
+            raise ValueError("runtimeSessionId is invalid")
+        expected = self._nonnegative_int(expected_context_seq, "expectedContextSeq")
+        through = self._nonnegative_int(through_seq, "throughSeq")
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            if run["status"] not in {"running", "awaiting_input"}:
+                raise GroupConflictError("Run no longer accepts context")
+            if run["runtime_session_id"] != runtime:
+                raise GroupConflictError("Run runtime generation changed")
+            if int(agent["last_context_message_seq"]) != expected:
+                raise GroupConflictError("Agent context generation changed")
+            trigger = self._message_row(connection, run["trigger_message_id"])
+            safe = self._safe_context_seq(
+                connection, run["room_id"], expected, int(trigger["seq"])
+            )
+            if through < expected or through > safe:
+                raise GroupConflictError("throughSeq exceeds safe projection")
+            now = self._now()
+            connection.execute(
+                """UPDATE group_agents
+                SET last_context_message_seq = ?, updated_at = ? WHERE id = ?""",
+                (through, now, agent["id"]),
+            )
+            changed = self._agent_wire(
+                self._owned_agent(connection, run["room_id"], run["agent_id"])
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "agent.updated",
+                changed,
+                created_at=now,
+            )
+            return changed
+
+    def commit_prompt_submission(
+        self,
+        run_id: str,
+        *,
+        expected_stored_session_id: str | None,
+        stored_session_id: str,
+        runtime_session_id: str,
+        expected_context_seq: int,
+        through_seq: int,
+    ) -> dict[str, object]:
+        """Commit a successful prompt submission and its durable context CAS."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        expected_stored = self._runtime_session_id(expected_stored_session_id)
+        stored = self._runtime_session_id(stored_session_id)
+        runtime = self._runtime_session_id(runtime_session_id)
+        expected = self._nonnegative_int(expected_context_seq, "expectedContextSeq")
+        through = self._nonnegative_int(through_seq, "throughSeq")
+        if stored is None:
+            raise ValueError("storedSessionId is invalid")
+        if runtime is None:
+            raise ValueError("runtimeSessionId is invalid")
+        operation = f"run.prompt.submitted:{canonical_run_id}"
+        legacy_ledger_id = str(
+            uuid.uuid5(
+                uuid.NAMESPACE_URL,
+                f"hermes://yaoyao-group/{canonical_run_id}/prompt-submission",
+            )
+        )
+        ledger_id = f"internal:prompt:{canonical_run_id}"
+        payload = {
+            "runId": canonical_run_id,
+            "expectedStoredSessionId": expected_stored,
+            "storedSessionId": stored,
+            "runtimeSessionId": runtime,
+            "expectedContextSeq": expected,
+            "throughSeq": through,
+        }
+        request_hash = hashlib.sha256(
+            self._canonical_json(payload).encode()
+        ).hexdigest()
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            if run["status"] != "running":
+                raise GroupConflictError("Run no longer accepts a prompt submission")
+            if run["runtime_session_id"] != runtime:
+                raise GroupConflictError("Run runtime generation changed")
+            current_stored = agent["stored_session_id"]
+            current_context = int(agent["last_context_message_seq"])
+            existing = connection.execute(
+                """SELECT request_id, operation, request_hash, response_json
+                FROM group_idempotency WHERE request_id = ?""",
+                (ledger_id,),
+            ).fetchone()
+            legacy_existing = None
+            if existing is None:
+                candidate = connection.execute(
+                    """SELECT operation, request_hash, response_json
+                    FROM group_idempotency WHERE request_id = ?""",
+                    (legacy_ledger_id,),
+                ).fetchone()
+                if candidate is not None and candidate["operation"] == operation:
+                    existing = candidate
+                    legacy_existing = candidate
+            if existing is not None:
+                if (
+                    existing["operation"] != operation
+                    or existing["request_hash"] != request_hash
+                ):
+                    raise GroupConflictError("Prompt submission generation changed")
+                if current_stored != stored or current_context != through:
+                    raise GroupConflictError("Prompt submission generation changed")
+                stored_response = existing["response_json"]
+                try:
+                    replay = self._load_json(stored_response)
+                except (TypeError, ValueError, json.JSONDecodeError) as error:
+                    raise GroupStoreError(
+                        "Stored prompt submission is corrupt"
+                    ) from error
+                if (
+                    not isinstance(replay, dict)
+                    or self._canonical_json(replay) != stored_response
+                ):
+                    raise GroupStoreError("Stored prompt submission is corrupt")
+                if legacy_existing is not None:
+                    connection.execute(
+                        """UPDATE group_idempotency SET request_id = ?
+                        WHERE request_id = ?""",
+                        (ledger_id, legacy_ledger_id),
+                    )
+                return replay
+            if current_stored != expected_stored:
+                raise GroupConflictError("stored session generation changed")
+            if current_context != expected:
+                raise GroupConflictError("Agent context generation changed")
+            trigger = self._message_row(connection, run["trigger_message_id"])
+            safe = self._safe_context_seq(
+                connection, run["room_id"], expected, int(trigger["seq"])
+            )
+            if through < expected or through > safe:
+                raise GroupConflictError("throughSeq exceeds safe projection")
+            now = self._now()
+            if current_stored != stored or current_context != through:
+                connection.execute(
+                    """UPDATE group_agents
+                    SET stored_session_id = ?, last_context_message_seq = ?,
+                        updated_at = ? WHERE id = ?""",
+                    (stored, through, now, agent["id"]),
+                )
+                agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "agent.updated",
+                    self._agent_wire(agent),
+                    created_at=now,
+                )
+            result = {
+                "run": self._run_wire(run),
+                "agent": self._agent_wire(agent),
+            }
+            connection.execute(
+                """INSERT INTO group_idempotency
+                (request_id, operation, request_hash, response_json, created_at)
+                VALUES (?, ?, ?, ?, ?)""",
+                (
+                    ledger_id,
+                    operation,
+                    request_hash,
+                    self._canonical_json(result),
+                    now,
+                ),
+            )
+            return result
+
+    def settle_run(
+        self,
+        run_id: str,
+        *,
+        runtime_session_id: str | None,
+        expected_stored_session_id: str | None,
+        stored_session_id: str | None,
+        outcome: str,
+        error: str = "",
+    ) -> dict[str, object]:
+        """Settle message, run, session rotation, and status in one transaction."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        runtime = self._runtime_session_id(runtime_session_id)
+        expected_stored = self._runtime_session_id(expected_stored_session_id)
+        stored = self._runtime_session_id(stored_session_id)
+        run_error = self._message_text(error, "error", max_bytes=4096)
+        if outcome not in {"completed", "failed", "interrupted"}:
+            raise ValueError("outcome is invalid")
+        if runtime is None and outcome != "failed":
+            raise ValueError("A runtime-less settle only supports failed")
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            agent = self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            if run["status"] not in {"running", "awaiting_input"}:
+                raise GroupConflictError("Run is already settled")
+            if run["runtime_session_id"] != runtime:
+                raise GroupConflictError("Run runtime generation changed")
+            if agent["stored_session_id"] != expected_stored:
+                raise GroupConflictError("stored session generation changed")
+            if outcome == "completed" and response["status"] != "completed":
+                raise GroupConflictError("Response message must be completed first")
+            now = self._now()
+            if stored != agent["stored_session_id"]:
+                connection.execute(
+                    """UPDATE group_agents
+                    SET stored_session_id = ?, updated_at = ? WHERE id = ?""",
+                    (stored, now, agent["id"]),
+                )
+                agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "agent.updated",
+                    self._agent_wire(agent),
+                    created_at=now,
+                )
+            self._cancel_pending_interactions(connection, run, now)
+            changed = self._update_run(connection, run, outcome, now, None, run_error)
+            self._fail_claimed_interaction_ledgers(
+                connection,
+                run_id=run["id"],
+                reason=run_error or f"Run {outcome}",
+                runtime_session_ids=[] if runtime is None else [runtime],
+                run=changed,
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "run.updated",
+                changed,
+                created_at=now,
+            )
+            if (
+                outcome in {"failed", "interrupted"}
+                and response["status"] != "completed"
+            ):
+                connection.execute(
+                    """UPDATE group_messages
+                    SET status = ?, error = ?, updated_at = ? WHERE id = ?""",
+                    (outcome, run_error, now, response["id"]),
+                )
+                response = self._message_row(connection, response["id"])
+                if response["visible"]:
+                    self._append_event(
+                        connection,
+                        run["room_id"],
+                        "message.upsert",
+                        self._message_wire(response),
+                        created_at=now,
+                    )
+            if outcome == "completed":
+                self._record_cascade_plan(
+                    connection,
+                    source=run,
+                    source_message=response,
+                    created_at=now,
+                )
+            self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
+            return {
+                "run": changed,
+                "message": self._message_wire(response),
+                "agent": self._agent_wire(agent),
+            }
+
+    @classmethod
+    def _cascade_request_id(cls, source_run_id: str) -> str:
+        return f"internal:cascade:{source_run_id}"
+
+    @classmethod
+    def _cascade_request_hash(
+        cls, source_run_id: str, *, parse_version: int = _CASCADE_PARSE_VERSION
+    ) -> str:
+        payload = {
+            "parseVersion": parse_version,
+            "sourceRunId": source_run_id,
+            "version": 1,
+        }
+        return hashlib.sha256(cls._canonical_json(payload).encode()).hexdigest()
+
+    def _record_cascade_plan(
+        self,
+        connection: sqlite3.Connection,
+        *,
+        source: sqlite3.Row,
+        source_message: sqlite3.Row,
+        created_at: float,
+    ) -> None:
+        request_id = self._cascade_request_id(source["id"])
+        existing = connection.execute(
+            "SELECT 1 FROM group_idempotency WHERE request_id = ?",
+            (request_id,),
+        ).fetchone()
+        if existing is not None:
+            raise GroupStoreError("Cascade ledger already exists")
+        agents = connection.execute(
+            "SELECT * FROM group_agents WHERE room_id = ? "
+            "ORDER BY created_at ASC, id ASC",
+            (source["room_id"],),
+        ).fetchall()
+        if source_message["visible"]:
+            target_ids, warnings = self._plan_cascade_mentions(
+                source_message["content"],
+                agents,
+                source_agent_id=source["agent_id"],
+            )
+            explicit_ids, _ = self._plan_cascade_mentions(
+                source_message["content"],
+                agents,
+                source_agent_id=source["agent_id"],
+                include_automatic=False,
+            )
+        else:
+            target_ids, warnings = [], []
+            explicit_ids = []
+        explicit_set = set(explicit_ids)
+        plan = {
+            "parseVersion": _CASCADE_PARSE_VERSION,
+            "sourceMessageId": source_message["id"],
+            "sourceRunId": source["id"],
+            "state": "pending",
+            "targets": [
+                {
+                    "agentId": agent_id,
+                    "replyMode": (
+                        "mentioned" if agent_id in explicit_set else "automatic"
+                    ),
+                }
+                for agent_id in target_ids
+            ],
+            "warnings": warnings,
+            "version": 1,
+        }
+        encoded = self._canonical_json(plan)
+        if len(encoded.encode("utf-8")) > _MAX_CASCADE_PLAN_BYTES:
+            raise GroupStoreError("Cascade plan exceeds the internal limit")
+        connection.execute(
+            """INSERT INTO group_idempotency
+            (request_id, operation, request_hash, response_json, created_at)
+            VALUES (?, ?, ?, ?, ?)""",
+            (
+                request_id,
+                _CASCADE_PENDING_OPERATION,
+                self._cascade_request_hash(source["id"]),
+                encoded,
+                created_at,
+            ),
+        )
+
+    @classmethod
+    def _plan_cascade_mentions(
+        cls,
+        content: str,
+        agents: list[sqlite3.Row],
+        *,
+        source_agent_id: str,
+        include_automatic: bool = True,
+    ) -> tuple[list[str], list[dict[str, str]]]:
+        searchable = cls._mention_search_text(content)
+        entries: list[tuple[str, int, sqlite3.Row]] = []
+        for agent in agents:
+            aliases = (
+                (agent["id"], 0),
+                (agent["profile"], 1),
+                (agent["display_name"], 2),
+            )
+            seen: set[str] = set()
+            for alias, priority in aliases:
+                key = alias.casefold()
+                if key in seen or is_reserved_mention_alias(key):
+                    continue
+                seen.add(key)
+                entries.append((key, priority, agent))
+        entries.sort(key=lambda item: (-len(item[0]), item[1], item[0], item[2]["id"]))
+        targets: list[str] = []
+        warning_names: list[str] = []
+        index = 0
+        while index < len(searchable):
+            if searchable[index] != "@" or (
+                index > 0 and cls._mention_word_char(searchable[index - 1])
+            ):
+                index += 1
+                continue
+            room_wide_match = next(
+                (
+                    match
+                    for alias in ALL_MENTION_ALIASES
+                    if (match := cls._casefold_prefix(searchable, index + 1, alias))
+                    is not None
+                    and cls._mention_end(searchable, match)
+                ),
+                None,
+            )
+            if room_wide_match is not None:
+                for agent in agents:
+                    if (
+                        agent["enabled"]
+                        and agent["id"] != source_agent_id
+                        and agent["id"] not in targets
+                    ):
+                        targets.append(agent["id"])
+                index = room_wide_match
+                continue
+            matched = False
+            for key, _priority, agent in entries:
+                end = cls._casefold_prefix(searchable, index + 1, key)
+                if end is None or not cls._mention_end(searchable, end):
+                    continue
+                matched = True
+                if not agent["enabled"]:
+                    warning_names.append(f"Agent disabled: {agent['display_name']}")
+                elif agent["id"] != source_agent_id and agent["id"] not in targets:
+                    targets.append(agent["id"])
+                index = end
+                break
+            if matched:
+                continue
+            token = re.match(
+                r"[^\s@,,。!?!?;;::()()\[\]{}]{1,100}",
+                searchable[index + 1 :],
+            )
+            if token is not None:
+                warning_names.append(f"Unknown Mention: @{token.group(0)}")
+                index += 1 + len(token.group(0))
+            else:
+                index += 1
+        if include_automatic:
+            for agent in agents:
+                if (
+                    agent["enabled"]
+                    and agent["reply_without_mention"]
+                    and agent["id"] != source_agent_id
+                    and agent["id"] not in targets
+                ):
+                    targets.append(agent["id"])
+        warnings = [
+            {"code": "mention", "message": value}
+            for value in list(dict.fromkeys(warning_names))[:MAX_AGENTS_PER_ROOM]
+        ]
+        return targets[:MAX_AGENTS_PER_ROOM], warnings
+
+    @staticmethod
+    def _casefold_prefix(content: str, start: int, expected: str) -> int | None:
+        folded = ""
+        index = start
+        while index < len(content) and len(folded) < len(expected):
+            folded += content[index].casefold()
+            index += 1
+        return index if folded == expected else None
+
+    @staticmethod
+    def _mention_search_text(content: str) -> str:
+        lines = content.splitlines(keepends=True)
+        visible: list[str] = []
+        fence: tuple[str, int] | None = None
+        for line in lines:
+            leading_spaces = len(line) - len(line.lstrip(" "))
+            candidate = line[leading_spaces:] if leading_spaces <= 3 else ""
+            marker_character = (
+                candidate[0]
+                if candidate.startswith("`") or candidate.startswith("~")
+                else None
+            )
+            marker_length = 0
+            if marker_character is not None:
+                while (
+                    marker_length < len(candidate)
+                    and candidate[marker_length] == marker_character
+                ):
+                    marker_length += 1
+            if fence is None and marker_length >= 3:
+                fence = (marker_character, marker_length)
+                continue
+            if fence is not None:
+                fence_character, fence_length = fence
+                remainder = candidate[marker_length:]
+                if (
+                    marker_character == fence_character
+                    and marker_length >= fence_length
+                    and not remainder.strip()
+                ):
+                    fence = None
+                continue
+            visible.append(line)
+        searchable = "".join(visible)
+        inline_visible: list[str] = []
+        for line in searchable.splitlines(keepends=True):
+            index = 0
+            while index < len(line):
+                if line[index] != "`":
+                    inline_visible.append(line[index])
+                    index += 1
+                    continue
+                end = index + 1
+                while end < len(line) and line[end] == "`":
+                    end += 1
+                delimiter = line[index:end]
+                close = line.find(delimiter, end)
+                if close < 0:
+                    if line.endswith("\n"):
+                        inline_visible.append("\n")
+                    break
+                index = close + len(delimiter)
+        searchable = "".join(inline_visible)
+        searchable = re.sub(
+            r"(?:[A-Za-z][A-Za-z0-9+.-]*://|www\.)"
+            r"[^\s,。!?;:()()\[\]{}]+",
+            "",
+            searchable,
+            flags=re.IGNORECASE,
+        )
+        return re.sub(r"\\@", "", searchable)
+
+    @staticmethod
+    def _mention_word_char(value: str) -> bool:
+        return value.isalnum() or value in {"_", ".", "-", "+", "@"}
+
+    @classmethod
+    def _mention_end(cls, value: str, end: int) -> bool:
+        return end >= len(value) or not cls._mention_word_char(value[end])
+
+    def list_pending_cascades(
+        self, *, limit: int = _MAX_CASCADE_PAGE_SIZE, after: int | None = None
+    ) -> dict[str, object]:
+        if (
+            isinstance(limit, bool)
+            or not isinstance(limit, int)
+            or not (1 <= limit <= _MAX_CASCADE_PAGE_SIZE)
+        ):
+            raise ValueError("limit must be between 1 and 32")
+        if after is None:
+            after_value = 0
+        elif isinstance(after, bool) or not isinstance(after, int) or after < 0:
+            raise ValueError("after must be a non-negative integer")
+        else:
+            after_value = after
+        with self.read_transaction() as connection:
+            rows = connection.execute(
+                """SELECT rowid, request_id, operation, request_hash, response_json
+                FROM group_idempotency
+                WHERE operation = ? AND rowid > ?
+                ORDER BY rowid ASC LIMIT ?""",
+                (_CASCADE_PENDING_OPERATION, after_value, limit + 1),
+            ).fetchall()
+        validated = [
+            (int(row["rowid"]), self._cascade_plan_from_ledger(row)) for row in rows
+        ]
+        has_more = len(validated) > limit
+        validated = validated[:limit]
+        items = [{"sourceRunId": plan["sourceRunId"]} for _rowid, plan in validated]
+        return {
+            "items": items,
+            "nextCursor": (validated[-1][0] if has_more and validated else None),
+        }
+
+    def _cascade_plan_from_ledger(
+        self, ledger: Mapping[str, object]
+    ) -> dict[str, object]:
+        try:
+            request_id = ledger["request_id"]
+            operation = ledger["operation"]
+            request_hash = ledger["request_hash"]
+            encoded = ledger["response_json"]
+        except (KeyError, IndexError, TypeError) as error:
+            raise GroupStoreError("Stored cascade ledger is corrupt") from error
+        prefix = "internal:cascade:"
+        if (
+            not isinstance(request_id, str)
+            or not request_id.startswith(prefix)
+            or operation != _CASCADE_PENDING_OPERATION
+            or not isinstance(encoded, str)
+            or len(encoded.encode("utf-8")) > _MAX_CASCADE_PLAN_BYTES
+        ):
+            raise GroupStoreError("Stored cascade ledger is corrupt")
+        raw_source_id = request_id[len(prefix) :]
+        try:
+            source_id = self._canonical_uuid(raw_source_id, "sourceRunId")
+        except ValueError as error:
+            raise GroupStoreError("Stored cascade ledger is corrupt") from error
+        if raw_source_id != source_id:
+            raise GroupStoreError("Stored cascade ledger is corrupt")
+        try:
+            plan = self._load_json(encoded)
+        except (TypeError, ValueError, json.JSONDecodeError) as error:
+            raise GroupStoreError("Stored cascade plan is corrupt") from error
+        if not isinstance(plan, dict) or self._canonical_json(plan) != encoded:
+            raise GroupStoreError("Stored cascade plan is corrupt")
+        parse_version = plan.get("parseVersion")
+        if (
+            parse_version not in {1, 2}
+            or request_hash
+            != self._cascade_request_hash(source_id, parse_version=parse_version)
+        ):
+            raise GroupStoreError("Stored cascade ledger is corrupt")
+        common_invalid = (
+            plan.get("state") != "pending"
+            or plan.get("version") != 1
+            or plan.get("sourceRunId") != source_id
+            or not isinstance(plan.get("warnings"), list)
+            or not all(isinstance(item, dict) for item in plan["warnings"])
+            or len(plan["warnings"]) > MAX_AGENTS_PER_ROOM
+        )
+        if common_invalid:
+            raise GroupStoreError("Stored cascade plan is corrupt")
+        if parse_version == 1:
+            expected_keys = {
+                "parseVersion",
+                "sourceMessageId",
+                "sourceRunId",
+                "state",
+                "targetAgentIds",
+                "version",
+                "warnings",
+            }
+            target_ids = plan.get("targetAgentIds")
+            if (
+                set(plan) != expected_keys
+                or not isinstance(target_ids, list)
+                or not all(isinstance(item, str) for item in target_ids)
+            ):
+                raise GroupStoreError("Stored cascade plan is corrupt")
+            targets = [
+                {"agentId": item, "replyMode": "mentioned"} for item in target_ids
+            ]
+        else:
+            expected_keys = {
+                "parseVersion",
+                "sourceMessageId",
+                "sourceRunId",
+                "state",
+                "targets",
+                "version",
+                "warnings",
+            }
+            targets = plan.get("targets")
+            if (
+                set(plan) != expected_keys
+                or not isinstance(targets, list)
+                or not all(isinstance(item, dict) for item in targets)
+            ):
+                raise GroupStoreError("Stored cascade plan is corrupt")
+        if (
+            len(targets) > MAX_AGENTS_PER_ROOM
+            or any(set(item) != {"agentId", "replyMode"} for item in targets)
+            or any(not isinstance(item.get("agentId"), str) for item in targets)
+            or any(item.get("replyMode") not in {"mentioned", "automatic"} for item in targets)
+            or len({item.get("agentId") for item in targets}) != len(targets)
+        ):
+            raise GroupStoreError("Stored cascade plan is corrupt")
+        try:
+            if (
+                self._canonical_uuid(plan["sourceMessageId"], "sourceMessageId")
+                != plan["sourceMessageId"]
+            ):
+                raise ValueError
+            for target in targets:
+                if (
+                    self._canonical_uuid(target["agentId"], "targetAgentId")
+                    != target["agentId"]
+                ):
+                    raise ValueError
+            for warning in plan["warnings"]:
+                if set(warning) != {"code", "message"} or warning.get("code") != "mention":
+                    raise ValueError
+                self._message_text(
+                    warning.get("message"),
+                    "warning.message",
+                    max_bytes=MAX_MESSAGE_BYTES,
+                    nonblank=True,
+                )
+        except (KeyError, TypeError, ValueError) as error:
+            raise GroupStoreError("Stored cascade plan is corrupt") from error
+        return {
+            "parseVersion": parse_version,
+            "sourceMessageId": plan["sourceMessageId"],
+            "sourceRunId": source_id,
+            "state": "pending",
+            "targets": targets,
+            "version": 1,
+            "warnings": plan["warnings"],
+        }
+
+    def enqueue_cascade_runs(
+        self,
+        source_run_id: str,
+        *,
+        agent_ids: list[str],
+        warnings: list[Mapping[str, object]],
+    ) -> dict[str, object]:
+        """Create bounded, deduplicated Agent cascades and visible warnings."""
+        canonical_source_id = self._canonical_uuid(source_run_id, "sourceRunId")
+        canonical_agent_ids, requested_warnings = self._validated_cascade_inputs(
+            agent_ids, warnings
+        )
+        with self.write_transaction() as connection:
+            source = self._run_row(connection, canonical_source_id)
+            self._active_room(connection, source["room_id"])
+            self._enabled_agent(connection, source["room_id"], source["agent_id"])
+            if source["status"] != "completed":
+                raise GroupConflictError("Cascade source run must be completed")
+            source_message = self._message_row(
+                connection, source["response_message_id"]
+            )
+            if source_message["status"] != "completed":
+                raise GroupConflictError("Cascade source message must be completed")
+            return self._enqueue_cascade_runs_transaction(
+                connection,
+                source=source,
+                canonical_agent_ids=canonical_agent_ids,
+                requested_warnings=requested_warnings,
+                now=self._now(),
+            )
+
+    def _validated_cascade_inputs(
+        self,
+        agent_ids: object,
+        warnings: object,
+    ) -> tuple[list[str], list[str]]:
+        if not isinstance(agent_ids, list):
+            raise ValueError("agent_ids must be a list")
+        if len(agent_ids) > MAX_AGENTS_PER_ROOM:
+            raise ValueError(
+                f"agent_ids must contain at most {MAX_AGENTS_PER_ROOM} entries"
+            )
+        canonical_agent_ids: list[str] = []
+        seen_agent_ids: set[str] = set()
+        for value in agent_ids:
+            agent_id = self._canonical_uuid(value, "agentId")
+            if agent_id not in seen_agent_ids:
+                seen_agent_ids.add(agent_id)
+                canonical_agent_ids.append(agent_id)
+        if not isinstance(warnings, list):
+            raise ValueError("warnings must be a list of objects")
+        if len(warnings) > MAX_AGENTS_PER_ROOM:
+            raise ValueError(
+                f"warnings must contain at most {MAX_AGENTS_PER_ROOM} entries"
+            )
+        if not all(isinstance(item, Mapping) for item in warnings):
+            raise ValueError("warnings must be a list of objects")
+        warnings_json = self._strict_json(
+            [dict(warning) for warning in warnings], "warnings"
+        )
+        if len(warnings_json.encode("utf-8")) > MAX_MESSAGE_BYTES:
+            raise ValueError("warnings exceed maximum size")
+        requested_warnings: list[str] = []
+        for warning in warnings:
+            message = warning.get("message")
+            requested_warnings.append(
+                self._message_text(
+                    message,
+                    "warning.message",
+                    max_bytes=MAX_MESSAGE_BYTES,
+                    nonblank=True,
+                )
+            )
+        return canonical_agent_ids, requested_warnings
+
+    def complete_cascade(self, source_run_id: str) -> dict[str, object]:
+        """Execute one frozen Mention plan exactly once and terminalize it."""
+        canonical_source_id = self._canonical_uuid(source_run_id, "sourceRunId")
+        request_id = self._cascade_request_id(canonical_source_id)
+        expected_hashes = {
+            self._cascade_request_hash(canonical_source_id, parse_version=1),
+            self._cascade_request_hash(canonical_source_id, parse_version=2),
+        }
+        with self.write_transaction() as connection:
+            ledger = connection.execute(
+                """SELECT request_id, operation, request_hash, response_json
+                FROM group_idempotency WHERE request_id = ?""",
+                (request_id,),
+            ).fetchone()
+            if ledger is None:
+                raise GroupNotFoundError("Cascade source not found")
+            if (
+                ledger["operation"] not in _CASCADE_OPERATIONS
+                or ledger["request_hash"] not in expected_hashes
+            ):
+                raise GroupStoreError("Stored cascade ledger is corrupt")
+            if ledger["operation"] != _CASCADE_PENDING_OPERATION:
+                return self._cascade_result(
+                    ledger["response_json"], ledger["operation"]
+                )
+            plan = self._cascade_plan_from_ledger(ledger)
+            source = connection.execute(
+                "SELECT * FROM group_agent_runs WHERE id = ?",
+                (canonical_source_id,),
+            ).fetchone()
+            if source is None:
+                raise GroupStoreError("Stored cascade source is missing")
+            room = connection.execute(
+                "SELECT archived FROM group_rooms WHERE id = ?",
+                (source["room_id"],),
+            ).fetchone()
+            source_agent = connection.execute(
+                "SELECT enabled FROM group_agents WHERE id = ? AND room_id = ?",
+                (source["agent_id"], source["room_id"]),
+            ).fetchone()
+            if room is None:
+                raise GroupStoreError("Stored cascade ownership is corrupt")
+            if source["status"] != "completed":
+                raise GroupStoreError("Stored cascade source is not completed")
+            source_message = self._message_row(
+                connection, source["response_message_id"]
+            )
+            if (
+                source_message["id"] != plan["sourceMessageId"]
+                or source_message["status"] != "completed"
+            ):
+                raise GroupStoreError("Stored cascade message is corrupt")
+            if room["archived"] or source_agent is None or not source_agent["enabled"]:
+                result = self._cascade_summary([], [], state="discarded")
+                connection.execute(
+                    """UPDATE group_idempotency
+                    SET operation = ?, response_json = ? WHERE request_id = ?""",
+                    (
+                        _CASCADE_DISCARDED_OPERATION,
+                        self._canonical_json(result),
+                        request_id,
+                    ),
+                )
+                return result
+            try:
+                canonical_ids, warning_texts = self._validated_cascade_inputs(
+                    [target["agentId"] for target in plan["targets"]],
+                    plan["warnings"],
+                )
+                reply_modes = {
+                    target["agentId"]: target["replyMode"]
+                    for target in plan["targets"]
+                }
+            except ValueError as error:
+                raise GroupStoreError("Stored cascade plan is corrupt") from error
+            created = self._enqueue_cascade_runs_transaction(
+                connection,
+                source=source,
+                canonical_agent_ids=canonical_ids,
+                requested_warnings=warning_texts,
+                reply_modes=reply_modes,
+                now=self._now(),
+            )
+            result = self._cascade_summary(
+                created["runs"], created["systemMessages"], state="completed"
+            )
+            connection.execute(
+                """UPDATE group_idempotency
+                SET operation = ?, response_json = ? WHERE request_id = ?""",
+                (
+                    _CASCADE_COMPLETED_OPERATION,
+                    self._canonical_json(result),
+                    request_id,
+                ),
+            )
+            return result
+
+    @classmethod
+    def _cascade_result(cls, encoded: object, operation: str) -> dict[str, object]:
+        try:
+            result = cls._load_json(encoded)
+        except (TypeError, ValueError, json.JSONDecodeError) as error:
+            raise GroupStoreError("Stored cascade result is corrupt") from error
+        expected_state = (
+            "completed" if operation == _CASCADE_COMPLETED_OPERATION else "discarded"
+        )
+        expected_keys = {
+            "runCount",
+            "runIds",
+            "state",
+            "systemMessageCount",
+            "systemMessageIds",
+        }
+        if expected_state == "discarded":
+            expected_keys.add("discarded")
+        if (
+            not isinstance(result, dict)
+            or cls._canonical_json(result) != encoded
+            or set(result) != expected_keys
+            or result.get("state") != expected_state
+            or not isinstance(result.get("runIds"), list)
+            or not all(isinstance(item, str) for item in result["runIds"])
+            or len(result["runIds"]) > MAX_AGENTS_PER_ROOM
+            or len(result["runIds"]) != len(set(result["runIds"]))
+            or type(result.get("runCount")) is not int
+            or result.get("runCount") != len(result["runIds"])
+            or not isinstance(result.get("systemMessageIds"), list)
+            or not all(isinstance(item, str) for item in result["systemMessageIds"])
+            or len(result["systemMessageIds"]) > MAX_AGENTS_PER_ROOM
+            or len(result["systemMessageIds"]) != len(set(result["systemMessageIds"]))
+            or type(result.get("systemMessageCount")) is not int
+            or result.get("systemMessageCount") != len(result["systemMessageIds"])
+            or (expected_state == "discarded") != (result.get("discarded") is True)
+        ):
+            raise GroupStoreError("Stored cascade result is corrupt")
+        try:
+            if any(
+                cls._canonical_uuid(item, "runId") != item for item in result["runIds"]
+            ) or any(
+                cls._canonical_uuid(item, "messageId") != item
+                for item in result["systemMessageIds"]
+            ):
+                raise ValueError
+        except ValueError as error:
+            raise GroupStoreError("Stored cascade result is corrupt") from error
+        return result
+
+    @staticmethod
+    def _cascade_summary(
+        runs: list[Mapping[str, object]],
+        messages: list[Mapping[str, object]],
+        *,
+        state: str,
+    ) -> dict[str, object]:
+        result: dict[str, object] = {
+            "runCount": len(runs),
+            "runIds": [run["id"] for run in runs],
+            "state": state,
+            "systemMessageCount": len(messages),
+            "systemMessageIds": [message["id"] for message in messages],
+        }
+        if state == "discarded":
+            result["discarded"] = True
+        return result
+
+    def _enqueue_cascade_runs_transaction(
+        self,
+        connection: sqlite3.Connection,
+        *,
+        source: sqlite3.Row,
+        canonical_agent_ids: list[str],
+        requested_warnings: list[str],
+        now: float,
+        reply_modes: Mapping[str, str] | None = None,
+    ) -> dict[str, list[dict[str, object]]]:
+        room = self._active_room(connection, source["room_id"])
+        room_agents = connection.execute(
+            """SELECT * FROM group_agents WHERE room_id = ?
+            ORDER BY created_at ASC, id ASC""",
+            (source["room_id"],),
+        ).fetchall()
+        agents_by_id = {agent["id"]: agent for agent in room_agents}
+        warning_texts = list(requested_warnings)
+        next_depth = int(source["depth"]) + 1
+        max_reply_rounds = int(room["max_reply_rounds"])
+        if max_reply_rounds != -1 and next_depth >= max_reply_rounds:
+            warning_texts.append("Cascade depth limit reached")
+            canonical_agent_ids = []
+        source_message = self._message_row(connection, source["response_message_id"])
+        explicit_ids, _ = self._plan_cascade_mentions(
+            source_message["content"],
+            room_agents,
+            source_agent_id=source["agent_id"],
+            include_automatic=False,
+        )
+        explicit_set = set(explicit_ids)
+        created_runs: list[dict[str, object]] = []
+        for agent_id in canonical_agent_ids:
+            agent = agents_by_id.get(agent_id)
+            if agent is None:
+                warning_texts.append(f"Unknown Agent: {agent_id}")
+                continue
+            if not agent["enabled"]:
+                warning_texts.append(f"Agent disabled: {agent['display_name']}")
+                continue
+            duplicate = connection.execute(
+                """SELECT 1 FROM group_agent_runs
+                WHERE room_id = ? AND root_message_id = ?
+                  AND agent_id = ? AND depth = ? LIMIT 1""",
+                (
+                    source["room_id"],
+                    source["root_message_id"],
+                    agent_id,
+                    next_depth,
+                ),
+            ).fetchone()
+            if duplicate is not None:
+                continue
+            reply_mode = (
+                reply_modes.get(agent_id, "mentioned")
+                if reply_modes is not None
+                else ("mentioned" if agent_id in explicit_set else "automatic")
+            )
+            if reply_mode not in {"mentioned", "automatic"}:
+                raise GroupStoreError("Stored cascade plan is corrupt")
+            visible = reply_mode == "mentioned"
+            response_id = str(uuid.uuid4())
+            run_id = str(uuid.uuid4())
+            connection.execute(
+                """INSERT INTO group_messages
+                (id, room_id, sender_kind, sender_id, sender_name,
+                 root_message_id, reply_to_message_id, client_message_id,
+                 content, reasoning, tool_state_json, status, error, visible,
+                 created_at, updated_at)
+                VALUES (?, ?, 'agent', ?, ?, ?, ?, NULL,
+                        '', '', '[]', 'queued', '', ?, ?, ?)""",
+                (
+                    response_id,
+                    source["room_id"],
+                    agent_id,
+                    agent["display_name"],
+                    source["root_message_id"],
+                    source["response_message_id"],
+                    int(visible),
+                    now,
+                    now,
+                ),
+            )
+            connection.execute(
+                """INSERT INTO group_agent_runs
+                (id, room_id, agent_id, trigger_message_id,
+                 response_message_id, root_message_id, depth, reply_mode, status,
+                 runtime_session_id, error, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL, '', ?, ?)""",
+                (
+                    run_id,
+                    source["room_id"],
+                    agent_id,
+                    source["response_message_id"],
+                    response_id,
+                    source["root_message_id"],
+                    next_depth,
+                    reply_mode,
+                    now,
+                    now,
+                ),
+            )
+            message = self._message_wire(self._message_row(connection, response_id))
+            run = self._run_wire(self._run_row(connection, run_id))
+            if visible:
+                self._append_event(
+                    connection,
+                    source["room_id"],
+                    "message.upsert",
+                    message,
+                    created_at=now,
+                )
+            self._append_event(
+                connection,
+                source["room_id"],
+                "run.updated",
+                run,
+                created_at=now,
+            )
+            created_runs.append(run)
+        system_messages: list[dict[str, object]] = []
+        for warning_text in list(dict.fromkeys(warning_texts))[:MAX_AGENTS_PER_ROOM]:
+            duplicate = connection.execute(
+                """SELECT * FROM group_messages
+                WHERE room_id = ? AND sender_kind = 'system'
+                  AND root_message_id = ? AND reply_to_message_id = ?
+                  AND content = ? AND status = 'completed'
+                ORDER BY seq ASC LIMIT 1""",
+                (
+                    source["room_id"],
+                    source["root_message_id"],
+                    source["response_message_id"],
+                    warning_text,
+                ),
+            ).fetchone()
+            if duplicate is not None:
+                continue
+            message_id = str(uuid.uuid4())
+            connection.execute(
+                """INSERT INTO group_messages
+                (id, room_id, sender_kind, sender_id, sender_name,
+                 root_message_id, reply_to_message_id, client_message_id,
+                 content, reasoning, tool_state_json, status, error,
+                 created_at, updated_at)
+                VALUES (?, ?, 'system', 'system', '系统', ?, ?, NULL,
+                        ?, '', '[]', 'completed', '', ?, ?)""",
+                (
+                    message_id,
+                    source["room_id"],
+                    source["root_message_id"],
+                    source["response_message_id"],
+                    warning_text,
+                    now,
+                    now,
+                ),
+            )
+            message = self._message_wire(self._message_row(connection, message_id))
+            self._append_event(
+                connection,
+                source["room_id"],
+                "message.upsert",
+                message,
+                created_at=now,
+            )
+            system_messages.append(message)
+        return {"runs": created_runs, "systemMessages": system_messages}
+
+    def upsert_agent_message(
+        self,
+        run_id: str,
+        *,
+        content: str,
+        reasoning: str,
+        tool_state: object,
+        status: str,
+        error: str = "",
+        publish: bool = True,
+    ) -> dict[str, object]:
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        content = self._message_text(content, "content", max_bytes=MAX_MESSAGE_BYTES)
+        reasoning = self._message_text(
+            reasoning, "reasoning", max_bytes=MAX_MESSAGE_BYTES
+        )
+        error = self._message_text(error, "error", max_bytes=4096)
+        if not isinstance(status, str) or status not in _MESSAGE_STATUSES:
+            raise ValueError("status is invalid")
+        tool_json = self._tool_state_json(tool_state)
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            if (
+                response["room_id"] != run["room_id"]
+                or response["sender_id"] != run["agent_id"]
+            ):
+                raise GroupStoreError("Run response message is corrupt")
+            self._validate_run_message_state(run, response)
+            if status not in _AGENT_MESSAGE_STATUSES_BY_RUN[run["status"]]:
+                raise GroupConflictError("Message status is invalid for run state")
+            if status not in _AGENT_MESSAGE_TRANSITIONS.get(
+                response["status"], frozenset()
+            ):
+                raise GroupConflictError("Message status cannot move backwards")
+            now = self._now()
+            visible = bool(response["visible"]) or publish
+            connection.execute(
+                """UPDATE group_messages SET content = ?, reasoning = ?, tool_state_json = ?,
+                status = ?, error = ?, visible = ?, updated_at = ? WHERE id = ?""",
+                (
+                    content,
+                    reasoning,
+                    tool_json,
+                    status,
+                    error,
+                    int(visible),
+                    now,
+                    run["response_message_id"],
+                ),
+            )
+            message = self._message_wire(
+                self._message_row(connection, run["response_message_id"])
+            )
+            if visible:
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "message.upsert",
+                    message,
+                    created_at=now,
+                )
+            return message
+
+    def transition_run(
+        self,
+        run_id: str,
+        status: str,
+        *,
+        runtime_session_id: object = UNSET,
+        error: object = UNSET,
+    ) -> dict[str, object]:
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        if not isinstance(status, str) or status not in RUN_TRANSITIONS:
+            raise ValueError("status is invalid")
+        runtime_value = (
+            self._runtime_session_id(runtime_session_id)
+            if runtime_session_id is not UNSET
+            else UNSET
+        )
+        error_value = (
+            self._message_text(error, "error", max_bytes=4096)
+            if error is not UNSET
+            else UNSET
+        )
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            if (
+                response["room_id"] != run["room_id"]
+                or response["sender_id"] != run["agent_id"]
+            ):
+                raise GroupStoreError("Run response message is corrupt")
+            self._validate_run_message_state(run, response)
+            if status == run["status"]:
+                if status == "running":
+                    if (
+                        runtime_value is not UNSET
+                        and runtime_value is not None
+                        and run["runtime_session_id"] is not None
+                        and runtime_value == run["runtime_session_id"]
+                    ):
+                        return self._run_wire(run)
+                    raise GroupConflictError("Run is already claimed")
+                return self._run_wire(run)
+            if status not in RUN_TRANSITIONS[run["status"]]:
+                raise GroupConflictError("Illegal run status transition")
+            if status == "completed" and response["status"] != "completed":
+                raise GroupConflictError("Response message must be completed first")
+            if status not in {"failed", "interrupted"}:
+                self._validate_run_message_status(status, response["status"])
+            now = self._now()
+            if status in {"completed", "failed", "interrupted"}:
+                self._cancel_pending_interactions(connection, run, now)
+            result = self._update_run(
+                connection, run, status, now, runtime_value, error_value
+            )
+            if status in {"completed", "failed", "interrupted"}:
+                self._fail_claimed_interaction_ledgers(
+                    connection,
+                    run_id=run["id"],
+                    reason=result["error"] or f"Run {status}",
+                    runtime_session_ids=(
+                        []
+                        if run["runtime_session_id"] is None
+                        else [run["runtime_session_id"]]
+                    ),
+                    run=result,
+                )
+            self._append_event(
+                connection, result["roomId"], "run.updated", result, created_at=now
+            )
+            if status in {"failed", "interrupted"}:
+                if response["status"] == "completed":
+                    self._validate_run_message_status(status, response["status"])
+                else:
+                    terminal_error = result["error"]
+                    connection.execute(
+                        """UPDATE group_messages
+                        SET status = ?, error = ?, updated_at = ? WHERE id = ?""",
+                        (status, terminal_error, now, run["response_message_id"]),
+                    )
+                    message = self._message_wire(
+                        self._message_row(connection, run["response_message_id"])
+                    )
+                    self._validate_run_message_status(
+                        result["status"], message["status"]
+                    )
+                    if message["visible"]:
+                        self._append_event(
+                            connection,
+                            result["roomId"],
+                            "message.upsert",
+                            message,
+                            created_at=now,
+                        )
+            return result
+
+    def bind_run_runtime(
+        self, run_id: str, runtime_session_id: str
+    ) -> dict[str, object]:
+        """Bind the first real runtime identity without reopening the run claim."""
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        runtime_value = self._runtime_session_id(runtime_session_id)
+        if runtime_value is None:
+            raise ValueError("runtimeSessionId is invalid")
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            if run["status"] not in {"running", "awaiting_input"}:
+                raise GroupConflictError("Run does not accept a runtime binding")
+            if run["runtime_session_id"] == runtime_value:
+                return self._run_wire(run)
+            if run["runtime_session_id"] is not None:
+                raise GroupConflictError("Run runtime is already bound")
+            self._require_available_runtime(connection, runtime_value, canonical_run_id)
+            now = self._now()
+            result = self._update_run(
+                connection, run, run["status"], now, runtime_value, UNSET
+            )
+            self._append_event(
+                connection, run["room_id"], "run.updated", result, created_at=now
+            )
+            return result
+
+    def create_interaction(
+        self,
+        interaction_id: str,
+        run_id: str,
+        *,
+        kind: str,
+        payload: object,
+    ) -> dict[str, object]:
+        return self._create_gateway_interaction(
+            run_id,
+            kind=kind,
+            gateway_interaction_id=interaction_id,
+            payload=payload,
+            allow_awaiting=False,
+        )
+
+    def create_gateway_interaction(
+        self,
+        run_id: str,
+        *,
+        kind: str,
+        gateway_interaction_id: str | None,
+        payload: object,
+    ) -> dict[str, object]:
+        return self._create_gateway_interaction(
+            run_id,
+            kind=kind,
+            gateway_interaction_id=gateway_interaction_id,
+            payload=payload,
+            allow_awaiting=True,
+        )
+
+    def _create_gateway_interaction(
+        self,
+        run_id: str,
+        *,
+        kind: str,
+        gateway_interaction_id: str | None,
+        payload: object,
+        allow_awaiting: bool,
+    ) -> dict[str, object]:
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        if not isinstance(kind, str) or kind not in _INTERACTION_KINDS:
+            raise ValueError("kind is invalid")
+        payload_json = self._json_object(payload, "payload")
+        payload_value = self._load_json(payload_json)
+        clarification_gateway_id: str | None = None
+        if kind == "approval" and gateway_interaction_id is None:
+            interaction_id = f"approval-{uuid.uuid4()}"
+        elif gateway_interaction_id is None:
+            raise ValueError("Gateway clarification interactionId is required")
+        else:
+            gateway_id = self._interaction_id(gateway_interaction_id)
+            if kind == "clarification" and allow_awaiting:
+                if "gatewayRequestId" in payload_value:
+                    raise ValueError("payload.gatewayRequestId is reserved")
+                payload_value["gatewayRequestId"] = gateway_id
+                payload_json = self._json_object(payload_value, "payload")
+                clarification_gateway_id = gateway_id
+                interaction_id = None
+            else:
+                interaction_id = gateway_id
+        try:
+            with self.write_transaction() as connection:
+                run = self._run_row(connection, canonical_run_id)
+                self._active_room(connection, run["room_id"])
+                self._enabled_agent(connection, run["room_id"], run["agent_id"])
+                response = self._message_row(connection, run["response_message_id"])
+                self._validate_run_message_state(run, response)
+                accepted_statuses = (
+                    {"running", "awaiting_input"} if allow_awaiting else {"running"}
+                )
+                if run["status"] not in accepted_statuses:
+                    raise GroupConflictError("Interaction requires a running run")
+                self._validate_run_message_status("awaiting_input", response["status"])
+                if clarification_gateway_id is not None:
+                    pending = connection.execute(
+                        """SELECT * FROM group_interactions
+                        WHERE run_id = ? AND kind = 'clarification'
+                          AND status = 'pending' ORDER BY rowid ASC""",
+                        (canonical_run_id,),
+                    ).fetchall()
+                    matching = []
+                    for candidate in pending:
+                        candidate_wire = self._interaction_wire(candidate)
+                        if (
+                            candidate_wire["payload"].get("gatewayRequestId")
+                            == clarification_gateway_id
+                        ):
+                            matching.append(candidate)
+                    if len(matching) > 1:
+                        raise GroupStoreError(
+                            "Stored clarification identities are not unique"
+                        )
+                    if matching:
+                        existing = matching[0]
+                        if existing["payload_json"] != payload_json:
+                            raise GroupConflictError("Interaction already exists")
+                        return self._interaction_wire(existing)
+                    interaction_id = f"clarification-{uuid.uuid4()}"
+                if interaction_id is None:
+                    raise GroupStoreError("Interaction identity is missing")
+                existing = connection.execute(
+                    "SELECT * FROM group_interactions WHERE id = ?",
+                    (interaction_id,),
+                ).fetchone()
+                if existing is not None:
+                    if (
+                        existing["room_id"] != run["room_id"]
+                        or existing["agent_id"] != run["agent_id"]
+                        or existing["run_id"] != canonical_run_id
+                        or existing["kind"] != kind
+                        or existing["payload_json"] != payload_json
+                    ):
+                        raise GroupConflictError("Interaction already exists")
+                    return self._interaction_wire(existing)
+                now = self._now()
+                connection.execute(
+                    """INSERT INTO group_interactions
+                    (id, room_id, agent_id, run_id, kind, payload_json, status, created_at, resolved_at)
+                    VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, NULL)""",
+                    (
+                        interaction_id,
+                        run["room_id"],
+                        run["agent_id"],
+                        canonical_run_id,
+                        kind,
+                        payload_json,
+                        now,
+                    ),
+                )
+                interaction = self._interaction_wire(
+                    self._interaction_row(connection, interaction_id)
+                )
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "interaction.requested",
+                    interaction,
+                    created_at=now,
+                )
+                if run["status"] == "running":
+                    changed = self._update_run(
+                        connection, run, "awaiting_input", now, UNSET, UNSET
+                    )
+                    self._append_event(
+                        connection,
+                        run["room_id"],
+                        "run.updated",
+                        changed,
+                        created_at=now,
+                    )
+                return interaction
+        except sqlite3.IntegrityError as error:
+            if "group_interactions.id" in str(error):
+                raise GroupConflictError("Interaction already exists") from error
+            raise self._integrity_conflict(error) from error
+
+    def resolve_interaction(
+        self, interaction_id: str, *, status: str = "resolved"
+    ) -> dict[str, object]:
+        interaction_id = self._interaction_id(interaction_id)
+        if not isinstance(status, str) or status not in _INTERACTION_TERMINAL_STATUSES:
+            raise ValueError("status must be resolved or cancelled")
+        with self.write_transaction() as connection:
+            interaction = self._interaction_row(connection, interaction_id)
+            if interaction["status"] != "pending":
+                raise GroupConflictError("Interaction is already resolved")
+            run = self._run_row(connection, interaction["run_id"])
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            self._validate_run_message_state(run, response)
+            now = self._now()
+            connection.execute(
+                "UPDATE group_interactions SET status = ?, resolved_at = ? WHERE id = ?",
+                (status, now, interaction_id),
+            )
+            result = self._interaction_wire(
+                self._interaction_row(connection, interaction_id)
+            )
+            self._append_event(
+                connection,
+                result["roomId"],
+                "interaction.resolved",
+                result,
+                created_at=now,
+            )
+            if status == "resolved":
+                if run["status"] != "awaiting_input":
+                    raise GroupConflictError("Interaction run is not awaiting input")
+                remaining = connection.execute(
+                    """SELECT 1 FROM group_interactions
+                    WHERE run_id = ? AND status = 'pending' LIMIT 1""",
+                    (run["id"],),
+                ).fetchone()
+                if remaining is None:
+                    self._validate_run_message_status("running", response["status"])
+                    changed = self._update_run(
+                        connection, run, "running", now, UNSET, UNSET
+                    )
+                    self._append_event(
+                        connection,
+                        changed["roomId"],
+                        "run.updated",
+                        changed,
+                        created_at=now,
+                    )
+                    run_result = changed
+                else:
+                    run_result = self._run_wire(run)
+            else:
+                run_result = self._run_wire(run)
+            self._fail_claimed_interaction_ledgers(
+                connection,
+                run_id=run["id"],
+                interaction_id=interaction_id,
+                reason=f"Interaction {status}",
+                runtime_session_ids=(
+                    []
+                    if run["runtime_session_id"] is None
+                    else [run["runtime_session_id"]]
+                ),
+                run=run_result,
+            )
+            return result
+
+    def begin_interaction_response(
+        self,
+        room_id: str,
+        interaction_id: str,
+        *,
+        request_id: str,
+        kind: str,
+        response: Mapping[str, object],
+    ) -> dict[str, object]:
+        """Durably claim one FIFO Gateway response without a public status."""
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        interaction_id = self._interaction_id(interaction_id)
+        canonical_request_id = self._canonical_uuid(request_id, "requestId")
+        if kind not in _INTERACTION_KINDS:
+            raise ValueError("kind is invalid")
+        response_json = self._json_object(response, "response")
+        response_value = self._load_json(response_json)
+        legacy_operation = f"interaction.{kind}.response"
+        operation = self._interaction_operation(kind, interaction_id)
+        payload = {
+            "roomId": canonical_room_id,
+            "interactionId": interaction_id,
+            "kind": kind,
+            "response": response_value,
+        }
+        request_hash = hashlib.sha256(
+            self._canonical_json(payload).encode()
+        ).hexdigest()
+        with self.write_transaction() as connection:
+            existing = connection.execute(
+                """SELECT operation, request_hash, response_json
+                FROM group_idempotency WHERE request_id = ?""",
+                (canonical_request_id,),
+            ).fetchone()
+            if existing is not None:
+                if (
+                    existing["operation"] not in {operation, legacy_operation}
+                    or existing["request_hash"] != request_hash
+                ):
+                    raise IdempotencyConflict(
+                        "requestId was already used for a different request"
+                    )
+                state = self._idempotency_state(existing["response_json"])
+                if state["state"] == "claimed":
+                    raise GroupConflictError(
+                        "Interaction response is already in progress"
+                    )
+                if state["state"] == "completed":
+                    return {"state": "replay", "response": state["response"]}
+                if state["state"] == "failed":
+                    return state
+                raise GroupStoreError("Stored interaction response is corrupt")
+            competing = connection.execute(
+                """SELECT operation, response_json FROM group_idempotency
+                WHERE operation IN (?, ?)""",
+                (operation, legacy_operation),
+            ).fetchall()
+            for candidate in competing:
+                state = self._idempotency_state(candidate["response_json"])
+                if (
+                    state["state"] == "claimed"
+                    and state["interactionId"] == interaction_id
+                ):
+                    raise GroupConflictError("Interaction response is already claimed")
+            interaction = self._interaction_row(connection, interaction_id)
+            if interaction["room_id"] != canonical_room_id:
+                raise GroupNotFoundError("Interaction not found")
+            if interaction["kind"] != kind:
+                raise GroupConflictError("Interaction kind does not match")
+            if interaction["status"] != "pending":
+                raise GroupConflictError("Interaction is already resolved")
+            run = self._run_row(connection, interaction["run_id"])
+            self._active_room(connection, canonical_room_id)
+            self._enabled_agent(connection, canonical_room_id, run["agent_id"])
+            if run["status"] != "awaiting_input":
+                raise GroupConflictError("Interaction run is not awaiting input")
+            if run["runtime_session_id"] is None:
+                raise GroupConflictError("Interaction run has no runtime session")
+            first = connection.execute(
+                """SELECT id FROM group_interactions
+                WHERE run_id = ? AND kind = ? AND status = 'pending'
+                ORDER BY rowid ASC LIMIT 1""",
+                (run["id"], kind),
+            ).fetchone()
+            if first is None or first["id"] != interaction_id:
+                raise GroupConflictError(
+                    "Interaction responses must preserve FIFO order"
+                )
+            sentinel = {
+                "state": "claimed",
+                "interactionId": interaction_id,
+                "kind": kind,
+            }
+            connection.execute(
+                """INSERT INTO group_idempotency
+                (request_id, operation, request_hash, response_json, created_at)
+                VALUES (?, ?, ?, ?, ?)""",
+                (
+                    canonical_request_id,
+                    operation,
+                    request_hash,
+                    self._canonical_json(sentinel),
+                    self._now(),
+                ),
+            )
+            return {
+                "state": "claimed",
+                "interaction": self._interaction_wire(interaction),
+                "run": self._run_wire(run),
+            }
+
+    def finish_interaction_response(
+        self,
+        request_id: str,
+        *,
+        response: Mapping[str, object],
+        interaction_status: str = "resolved",
+    ) -> dict[str, object]:
+        """Commit a known Gateway response and release the run when possible."""
+        if interaction_status not in _INTERACTION_TERMINAL_STATUSES:
+            raise ValueError("interaction_status must be resolved or cancelled")
+        canonical_request_id = self._canonical_uuid(request_id, "requestId")
+        response_json = self._json_object(response, "response")
+        public_response = self._load_json(response_json)
+        with self.write_transaction() as connection:
+            claim = connection.execute(
+                """SELECT operation, response_json FROM group_idempotency
+                WHERE request_id = ?""",
+                (canonical_request_id,),
+            ).fetchone()
+            operation = (
+                None
+                if claim is None
+                else self._interaction_response_operation(claim["operation"])
+            )
+            if claim is None or operation is None:
+                raise GroupNotFoundError("Interaction response claim not found")
+            state = self._idempotency_state(claim["response_json"])
+            if state["state"] == "claimed" and (
+                operation[0] != state["kind"]
+                or (operation[1] is not None and operation[1] != state["interactionId"])
+            ):
+                raise GroupStoreError("Stored interaction response is corrupt")
+            if state["state"] == "completed":
+                stored_status = state.get("interactionStatus", "resolved")
+                if stored_status != interaction_status:
+                    raise GroupConflictError(
+                        "Interaction response terminal status changed"
+                    )
+                return state["response"]
+            if state["state"] != "claimed":
+                raise GroupConflictError("Interaction response claim is terminal")
+            interaction = self._interaction_row(connection, state["interactionId"])
+            if interaction["status"] != "pending":
+                raise GroupConflictError("Interaction is already resolved")
+            run = self._run_row(connection, interaction["run_id"])
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            if run["status"] != "awaiting_input":
+                raise GroupConflictError("Interaction run is not awaiting input")
+            now = self._now()
+            connection.execute(
+                """UPDATE group_interactions
+                SET status = ?, resolved_at = ? WHERE id = ?""",
+                (interaction_status, now, interaction["id"]),
+            )
+            resolved = self._interaction_wire(
+                self._interaction_row(connection, interaction["id"])
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "interaction.resolved",
+                resolved,
+                created_at=now,
+            )
+            pending = connection.execute(
+                """SELECT 1 FROM group_interactions
+                WHERE run_id = ? AND status = 'pending' LIMIT 1""",
+                (run["id"],),
+            ).fetchone()
+            if pending is None:
+                response_row = self._message_row(connection, run["response_message_id"])
+                self._validate_run_message_status("running", response_row["status"])
+                changed = self._update_run(
+                    connection, run, "running", now, UNSET, UNSET
+                )
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "run.updated",
+                    changed,
+                    created_at=now,
+                )
+            final = {
+                "state": "completed",
+                "interactionStatus": interaction_status,
+                "response": public_response,
+            }
+            connection.execute(
+                """UPDATE group_idempotency SET response_json = ?
+                WHERE request_id = ?""",
+                (self._canonical_json(final), canonical_request_id),
+            )
+            return public_response
+
+    def fail_interaction_response(
+        self, request_id: str, *, reason: str, uncertain: bool
+    ) -> dict[str, object]:
+        """Fail closed on an unknown Gateway outcome or release a retryable claim."""
+        canonical_request_id = self._canonical_uuid(request_id, "requestId")
+        failure_reason = self._message_text(
+            reason, "reason", max_bytes=4096, nonblank=True
+        )
+        if not isinstance(uncertain, bool):
+            raise ValueError("uncertain must be a boolean")
+        with self.write_transaction() as connection:
+            claim = connection.execute(
+                """SELECT operation, response_json FROM group_idempotency
+                WHERE request_id = ?""",
+                (canonical_request_id,),
+            ).fetchone()
+            operation = (
+                None
+                if claim is None
+                else self._interaction_response_operation(claim["operation"])
+            )
+            if claim is None or operation is None:
+                raise GroupNotFoundError("Interaction response claim not found")
+            state = self._idempotency_state(claim["response_json"])
+            if state["state"] == "claimed" and (
+                operation[0] != state["kind"]
+                or (operation[1] is not None and operation[1] != state["interactionId"])
+            ):
+                raise GroupStoreError("Stored interaction response is corrupt")
+            if state["state"] == "failed":
+                return state
+            if state["state"] != "claimed":
+                raise GroupConflictError("Interaction response claim is terminal")
+            interaction = self._interaction_row(connection, state["interactionId"])
+            run = self._run_row(connection, interaction["run_id"])
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            if run["status"] != "awaiting_input":
+                raise GroupConflictError("Interaction run is not awaiting input")
+            if not uncertain:
+                connection.execute(
+                    "DELETE FROM group_idempotency WHERE request_id = ?",
+                    (canonical_request_id,),
+                )
+                return {"state": "retryable"}
+            runtime_ids = (
+                [] if run["runtime_session_id"] is None else [run["runtime_session_id"]]
+            )
+            now = self._now()
+            self._cancel_pending_interactions(connection, run, now)
+            response_row = self._message_row(connection, run["response_message_id"])
+            changed = self._update_run(
+                connection, run, "failed", now, None, failure_reason
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "run.updated",
+                changed,
+                created_at=now,
+            )
+            if response_row["status"] != "completed":
+                connection.execute(
+                    """UPDATE group_messages
+                    SET status = 'failed', error = ?, updated_at = ? WHERE id = ?""",
+                    (failure_reason, now, response_row["id"]),
+                )
+                message = self._message_wire(
+                    self._message_row(connection, response_row["id"])
+                )
+                if message["visible"]:
+                    self._append_event(
+                        connection,
+                        run["room_id"],
+                        "message.upsert",
+                        message,
+                        created_at=now,
+                    )
+            self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
+            final = {
+                "state": "failed",
+                "reason": failure_reason,
+                "runtimeSessionIds": runtime_ids,
+                "run": changed,
+            }
+            self._fail_claimed_interaction_ledgers(
+                connection,
+                run_id=run["id"],
+                reason=failure_reason,
+                runtime_session_ids=runtime_ids,
+                run=changed,
+            )
+            connection.execute(
+                """UPDATE group_idempotency SET response_json = ?
+                WHERE request_id = ?""",
+                (self._canonical_json(final), canonical_request_id),
+            )
+            return final
+
+    def expire_interaction(self, interaction_id: str) -> dict[str, object]:
+        """Idempotently cancel a stale interaction and resume when it was last."""
+        interaction_id = self._interaction_id(interaction_id)
+        with self.write_transaction() as connection:
+            interaction = self._interaction_row(connection, interaction_id)
+            if interaction["status"] != "pending":
+                return self._interaction_wire(interaction)
+            run = self._run_row(connection, interaction["run_id"])
+            self._active_room(connection, run["room_id"])
+            self._enabled_agent(connection, run["room_id"], run["agent_id"])
+            now = self._now()
+            connection.execute(
+                """UPDATE group_interactions
+                SET status = 'cancelled', resolved_at = ? WHERE id = ?""",
+                (now, interaction_id),
+            )
+            result = self._interaction_wire(
+                self._interaction_row(connection, interaction_id)
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "interaction.resolved",
+                result,
+                created_at=now,
+            )
+            remaining = connection.execute(
+                """SELECT 1 FROM group_interactions
+                WHERE run_id = ? AND status = 'pending' LIMIT 1""",
+                (run["id"],),
+            ).fetchone()
+            if run["status"] == "awaiting_input" and remaining is None:
+                response = self._message_row(connection, run["response_message_id"])
+                self._validate_run_message_status("running", response["status"])
+                changed = self._update_run(
+                    connection, run, "running", now, UNSET, UNSET
+                )
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "run.updated",
+                    changed,
+                    created_at=now,
+                )
+                run_result = changed
+            else:
+                run_result = self._run_wire(run)
+            self._fail_claimed_interaction_ledgers(
+                connection,
+                run_id=run["id"],
+                interaction_id=interaction_id,
+                reason="Interaction expired",
+                runtime_session_ids=(
+                    []
+                    if run["runtime_session_id"] is None
+                    else [run["runtime_session_id"]]
+                ),
+                run=run_result,
+            )
+            return result
+
+    def events_after(
+        self, cursor: int, limit: int = MAX_EVENT_BATCH_SIZE
+    ) -> list[dict[str, object]]:
+        cursor = self._nonnegative_int(cursor, "cursor")
+        self._page_limit(limit, MAX_EVENT_BATCH_SIZE, "limit")
+        with self.read_transaction() as connection:
+            current_epoch = self._validated_epoch(
+                self._metadata_value_from_connection(connection, "journal_epoch")
+            )
+            rows = connection.execute(
+                "SELECT * FROM group_events WHERE cursor > ? ORDER BY cursor ASC LIMIT ?",
+                (cursor, limit),
+            ).fetchall()
+            return [self._event_wire(row, current_epoch) for row in rows]
+
+    def cursor_status(self, epoch: str, cursor: int) -> str:
+        canonical_epoch = self._canonical_uuid(epoch, "epoch")
+        cursor = self._nonnegative_int(cursor, "cursor")
+        with self.read_transaction() as connection:
+            if canonical_epoch != self._validated_epoch(
+                self._metadata_value_from_connection(connection, "journal_epoch")
+            ):
+                return "epoch_mismatch"
+            bounds = connection.execute(
+                "SELECT MIN(cursor) AS oldest, MAX(cursor) AS latest FROM group_events"
+            ).fetchone()
+            latest = int(bounds["latest"] or 0)
+            if cursor > latest:
+                return "cursor_expired"
+            oldest = bounds["oldest"]
+            if oldest is not None and cursor < int(oldest) - 1:
+                return "cursor_expired"
+            return "ok"
+
+    def prune_events(self, now: float | None = None) -> int:
+        prune_now = self._now() if now is None else self._finite_timestamp(now, "now")
+        with self.write_transaction() as connection:
+            threshold = connection.execute(
+                "SELECT cursor FROM group_events ORDER BY cursor DESC LIMIT 1 OFFSET ?",
+                (MIN_RETAINED_EVENTS - 1,),
+            ).fetchone()
+            if threshold is None:
+                return 0
+            result = connection.execute(
+                "DELETE FROM group_events WHERE created_at < ? AND cursor < ?",
+                (prune_now - EVENT_RETENTION_SECONDS, threshold["cursor"]),
+            )
+            return result.rowcount
+
+    def _interrupt_runs_for_scope(
+        self,
+        connection: sqlite3.Connection,
+        *,
+        room_id: str,
+        reason: str,
+        now: float,
+        agent_id: str | None = None,
+        include_queued: bool = True,
+    ) -> dict[str, list[str]]:
+        statuses = (
+            "('queued', 'running', 'awaiting_input')"
+            if include_queued
+            else "('running', 'awaiting_input')"
+        )
+        query = f"""SELECT run.* FROM group_agent_runs AS run
+            JOIN group_messages AS response
+              ON response.id = run.response_message_id
+            WHERE run.room_id = ? AND run.status IN {statuses}"""
+        params: list[object] = [room_id]
+        if agent_id is not None:
+            query += " AND run.agent_id = ?"
+            params.append(agent_id)
+        query += " ORDER BY response.seq ASC, run.id ASC"
+        runs = connection.execute(query, params).fetchall()
+        changed_ids: list[str] = []
+        runtime_ids: list[str] = []
+        for run in runs:
+            run_runtime_ids = (
+                [] if run["runtime_session_id"] is None else [run["runtime_session_id"]]
+            )
+            if (
+                run["runtime_session_id"] is not None
+                and run["runtime_session_id"] not in runtime_ids
+            ):
+                runtime_ids.append(run["runtime_session_id"])
+            response = self._message_row(connection, run["response_message_id"])
+            if (
+                response["room_id"] != run["room_id"]
+                or response["sender_id"] != run["agent_id"]
+            ):
+                raise GroupStoreError("Run response message is corrupt")
+            self._validate_run_message_state(run, response)
+            changed = self._update_run(
+                connection, run, "interrupted", now, None, reason
+            )
+            self._append_event(
+                connection, room_id, "run.updated", changed, created_at=now
+            )
+            if response["status"] == "completed":
+                self._validate_run_message_status(changed["status"], response["status"])
+            else:
+                connection.execute(
+                    """UPDATE group_messages
+                    SET status = 'interrupted', error = ?, updated_at = ? WHERE id = ?""",
+                    (reason, now, run["response_message_id"]),
+                )
+                message = self._message_wire(
+                    self._message_row(connection, run["response_message_id"])
+                )
+                self._validate_run_message_status(changed["status"], message["status"])
+                if message["visible"]:
+                    self._append_event(
+                        connection,
+                        room_id,
+                        "message.upsert",
+                        message,
+                        created_at=now,
+                    )
+            self._cancel_pending_interactions(
+                connection, run, now, order_by_created_at=True
+            )
+            self._fail_claimed_interaction_ledgers(
+                connection,
+                run_id=run["id"],
+                reason=reason,
+                runtime_session_ids=run_runtime_ids,
+                run=changed,
+            )
+            changed_ids.append(run["id"])
+        return {"runIds": changed_ids, "runtimeSessionIds": runtime_ids}
+
+    def recover_after_restart(self) -> list[str]:
+        with self.write_transaction() as connection:
+            runs = connection.execute(
+                "SELECT * FROM group_agent_runs WHERE status IN ('running', 'awaiting_input') ORDER BY created_at ASC, id ASC"
+            ).fetchall()
+            if not runs:
+                return []
+            now = self._now()
+            changed_ids: list[str] = []
+            for run in runs:
+                runtime_ids = (
+                    []
+                    if run["runtime_session_id"] is None
+                    else [run["runtime_session_id"]]
+                )
+                response = self._message_row(connection, run["response_message_id"])
+                if (
+                    response["room_id"] != run["room_id"]
+                    or response["sender_id"] != run["agent_id"]
+                ):
+                    raise GroupStoreError("Run response message is corrupt")
+                self._validate_run_message_state(run, response)
+                changed = self._update_run(
+                    connection, run, "interrupted", now, None, "Dashboard restarted"
+                )
+                self._append_event(
+                    connection, run["room_id"], "run.updated", changed, created_at=now
+                )
+                if response["status"] == "completed":
+                    self._validate_run_message_status(
+                        changed["status"], response["status"]
+                    )
+                else:
+                    connection.execute(
+                        """UPDATE group_messages
+                        SET status = 'interrupted', error = 'Dashboard restarted',
+                            updated_at = ? WHERE id = ?""",
+                        (now, run["response_message_id"]),
+                    )
+                    message = self._message_wire(
+                        self._message_row(connection, run["response_message_id"])
+                    )
+                    self._validate_run_message_status(
+                        changed["status"], message["status"]
+                    )
+                    if message["visible"]:
+                        self._append_event(
+                            connection,
+                            run["room_id"],
+                            "message.upsert",
+                            message,
+                            created_at=now,
+                        )
+                self._cancel_pending_interactions(
+                    connection, run, now, order_by_created_at=True
+                )
+                self._fail_claimed_interaction_ledgers(
+                    connection,
+                    run_id=run["id"],
+                    reason="Dashboard restarted",
+                    runtime_session_ids=runtime_ids,
+                    run=changed,
+                )
+                changed_ids.append(run["id"])
+            return changed_ids
+
+    def _cancel_pending_interactions(
+        self,
+        connection: sqlite3.Connection,
+        run: sqlite3.Row,
+        now: float,
+        *,
+        order_by_created_at: bool = False,
+    ) -> list[dict[str, object]]:
+        """Cancel a run's pending cards and publish their durable resolutions."""
+        ordering = "created_at ASC, id ASC" if order_by_created_at else "rowid ASC"
+        interactions = connection.execute(
+            f"""SELECT id FROM group_interactions
+            WHERE run_id = ? AND status = 'pending' ORDER BY {ordering}""",
+            (run["id"],),
+        ).fetchall()
+        cancelled: list[dict[str, object]] = []
+        for interaction in interactions:
+            connection.execute(
+                """UPDATE group_interactions
+                SET status = 'cancelled', resolved_at = ? WHERE id = ?""",
+                (now, interaction["id"]),
+            )
+            resolved = self._interaction_wire(
+                self._interaction_row(connection, interaction["id"])
+            )
+            self._append_event(
+                connection,
+                run["room_id"],
+                "interaction.resolved",
+                resolved,
+                created_at=now,
+            )
+            cancelled.append(resolved)
+        return cancelled
+
+    def _fail_claimed_interaction_ledgers(
+        self,
+        connection: sqlite3.Connection,
+        *,
+        run_id: str,
+        reason: str,
+        runtime_session_ids: list[str],
+        run: Mapping[str, object],
+        interaction_id: str | None = None,
+    ) -> None:
+        interactions = connection.execute(
+            """SELECT id, kind FROM group_interactions
+            WHERE run_id = ? ORDER BY rowid ASC""",
+            (run_id,),
+        ).fetchall()
+        if interaction_id is not None:
+            interactions = [
+                item for item in interactions if item["id"] == interaction_id
+            ]
+        if not interactions:
+            return
+        target_ids = {item["id"] for item in interactions}
+        scoped_operations = [
+            self._interaction_operation(item["kind"], item["id"])
+            for item in interactions
+        ]
+        placeholders = ", ".join("?" for _ in scoped_operations)
+        ledgers = connection.execute(
+            f"""SELECT request_id, operation, response_json
+            FROM group_idempotency WHERE operation IN ({placeholders})""",
+            scoped_operations,
+        ).fetchall()
+        legacy = connection.execute(
+            """SELECT request_id, operation, response_json
+            FROM group_idempotency WHERE operation IN (
+                'interaction.approval.response',
+                'interaction.clarification.response'
+            )"""
+        ).fetchall()
+        seen: set[str] = set()
+        terminal = {
+            "state": "failed",
+            "reason": reason,
+            "runtimeSessionIds": list(runtime_session_ids),
+            "run": dict(run),
+        }
+        encoded = self._canonical_json(terminal)
+        for ledger in (*ledgers, *legacy):
+            if ledger["request_id"] in seen:
+                continue
+            seen.add(ledger["request_id"])
+            operation = self._interaction_response_operation(ledger["operation"])
+            state = self._idempotency_state(ledger["response_json"])
+            if operation is None or state["state"] != "claimed":
+                continue
+            claimed_id = state["interactionId"]
+            if claimed_id not in target_ids:
+                continue
+            if operation[0] != state["kind"] or (
+                operation[1] is not None and operation[1] != claimed_id
+            ):
+                raise GroupStoreError("Stored interaction response is corrupt")
+            connection.execute(
+                """UPDATE group_idempotency SET response_json = ?
+                WHERE request_id = ?""",
+                (encoded, ledger["request_id"]),
+            )
+
+    def queued_run_count(self) -> int:
+        with self.connection() as connection:
+            return int(
+                connection.execute(
+                    "SELECT COUNT(*) FROM group_agent_runs WHERE status = 'queued'"
+                ).fetchone()[0]
+            )
+
+    def list_pending_interactions(self, room_id: str) -> list[dict[str, object]]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        with self.read_transaction() as connection:
+            self._room_detail(connection, canonical_room_id, include_archived=True)
+            rows = connection.execute(
+                "SELECT * FROM group_interactions WHERE room_id = ? AND status = 'pending' ORDER BY created_at, id",
+                (canonical_room_id,),
+            ).fetchall()
+            return [self._interaction_wire(row) for row in rows]
+
+    def list_queued_runs(self, room_id: str) -> list[dict[str, object]]:
+        canonical_room_id = self._canonical_uuid(room_id, "roomId")
+        with self.read_transaction() as connection:
+            self._room_detail(connection, canonical_room_id, include_archived=True)
+            rows = connection.execute(
+                "SELECT * FROM group_agent_runs WHERE room_id = ? AND status = 'queued' ORDER BY created_at, id",
+                (canonical_room_id,),
+            ).fetchall()
+            return [self._run_wire(row) for row in rows]
+
+    def latest_cursor(self) -> int:
+        with self.connection() as connection:
+            row = connection.execute(
+                "SELECT COALESCE(MAX(cursor), 0) AS cursor FROM group_events"
+            ).fetchone()
+            return int(row["cursor"])
+
+    @staticmethod
+    def _safe_context_seq(
+        connection: sqlite3.Connection,
+        room_id: str,
+        after_seq: int,
+        trigger_seq: int,
+    ) -> int:
+        hole = connection.execute(
+            """SELECT MIN(seq) AS seq FROM group_messages
+            WHERE room_id = ? AND seq > ? AND seq <= ?
+              AND status NOT IN ('completed', 'failed', 'interrupted')""",
+            (room_id, after_seq, trigger_seq),
+        ).fetchone()["seq"]
+        if hole is None:
+            return max(after_seq, trigger_seq)
+        return max(after_seq, int(hole) - 1)
+
+    def _append_agent_status(
+        self,
+        connection: sqlite3.Connection,
+        room_id: str,
+        agent_id: str,
+        now: float,
+    ) -> None:
+        row = connection.execute(
+            """SELECT id, status FROM group_agent_runs
+            WHERE room_id = ? AND agent_id = ?
+              AND status IN ('queued', 'running', 'awaiting_input')
+            ORDER BY CASE status
+                WHEN 'awaiting_input' THEN 3
+                WHEN 'running' THEN 2
+                ELSE 1 END DESC,
+                created_at ASC, id ASC
+            LIMIT 1""",
+            (room_id, agent_id),
+        ).fetchone()
+        payload: dict[str, object] = {
+            "roomId": room_id,
+            "agentId": agent_id,
+            "status": "idle" if row is None else row["status"],
+            "runId": None if row is None else row["id"],
+        }
+        self._append_event(connection, room_id, "agent.status", payload, created_at=now)
+
+    def _append_event(
+        self,
+        connection: sqlite3.Connection,
+        room_id: str,
+        event_type: str,
+        payload: Mapping[str, object],
+        *,
+        created_at: float | None = None,
+    ) -> int:
+        epoch = self._metadata_value_from_connection(connection, "journal_epoch")
+        cursor = connection.execute(
+            """INSERT INTO group_events(epoch, room_id, event_type, payload_json, created_at)
+            VALUES (?, ?, ?, ?, ?) RETURNING cursor""",
+            (
+                epoch,
+                room_id,
+                event_type,
+                self._canonical_json(payload),
+                self._now() if created_at is None else created_at,
+            ),
+        ).fetchone()["cursor"]
+        return int(cursor)
+
+    def _append_room_updated_summary(
+        self,
+        connection: sqlite3.Connection,
+        room_id: str,
+        *,
+        created_at: float,
+    ) -> None:
+        room = connection.execute(
+            """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
+            (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
+                AS agent_count
+            FROM group_rooms WHERE id = ? AND archived = 0""",
+            (room_id,),
+        ).fetchone()
+        if room is None:
+            raise GroupNotFoundError("Room not found")
+        self._append_event(
+            connection,
+            room_id,
+            "room.updated",
+            self._room_summary(room),
+            created_at=created_at,
+        )
+
+    def _room_detail(
+        self, connection: sqlite3.Connection, room_id: str, *, include_archived: bool
+    ) -> dict[str, object]:
+        room = connection.execute(
+            "SELECT * FROM group_rooms WHERE id = ?", (room_id,)
+        ).fetchone()
+        if room is None or (room["archived"] and not include_archived):
+            raise GroupNotFoundError("Room not found")
+        agents = connection.execute(
+            "SELECT * FROM group_agents WHERE room_id = ? ORDER BY created_at ASC, id ASC",
+            (room_id,),
+        ).fetchall()
+        result = self._room_wire(room)
+        result["agents"] = [self._agent_wire(agent) for agent in agents]
+        return result
+
+    def prepare_run_session_configuration(
+        self, run_id: str, configuration: Mapping[str, object]
+    ) -> bool:
+        """Detach a stale stored session before this claimed run is bound.
+
+        Configuration changes never interrupt the currently bound run. A later
+        claimed run reaches this method only after agent serialization, so it
+        can safely rotate the dormant stored session and rebuild full context.
+        """
+        canonical_run_id = self._canonical_uuid(run_id, "runId")
+        normalized = {
+            "model": configuration.get("model"),
+            "provider": configuration.get("provider"),
+            "reasoning_effort": configuration.get("reasoning_effort"),
+            "fast": configuration.get("fast"),
+        }
+        serialized = self._canonical_json(normalized)
+        with self.write_transaction() as connection:
+            run = self._run_row(connection, canonical_run_id)
+            if run["status"] != "running" or run["runtime_session_id"] is not None:
+                raise GroupConflictError("Run does not accept configuration rotation")
+            agent = self._owned_agent(connection, run["room_id"], run["agent_id"])
+            if agent["session_config_json"] == serialized:
+                return False
+            now = self._now()
+            connection.execute(
+                """UPDATE group_agents
+                SET stored_session_id = NULL,
+                    last_context_message_seq = 0,
+                    session_config_json = ?,
+                    updated_at = ?
+                WHERE id = ? AND room_id = ?""",
+                (serialized, now, agent["id"], run["room_id"]),
+            )
+            refreshed = self._owned_agent(connection, run["room_id"], run["agent_id"])
+            self._append_event(
+                connection,
+                run["room_id"],
+                "agent.updated",
+                self._agent_wire(refreshed),
+                created_at=now,
+            )
+            return True
+
+    def _active_room(self, connection: sqlite3.Connection, room_id: str) -> sqlite3.Row:
+        room = connection.execute(
+            "SELECT * FROM group_rooms WHERE id = ? AND archived = 0", (room_id,)
+        ).fetchone()
+        if room is None:
+            raise GroupNotFoundError("Room not found")
+        return room
+
+    def _owned_agent(
+        self, connection: sqlite3.Connection, room_id: str, agent_id: str
+    ) -> sqlite3.Row:
+        agent = connection.execute(
+            "SELECT * FROM group_agents WHERE id = ? AND room_id = ?",
+            (agent_id, room_id),
+        ).fetchone()
+        if agent is None:
+            raise GroupNotFoundError("Agent not found in room")
+        return agent
+
+    def _enabled_agent(
+        self, connection: sqlite3.Connection, room_id: str, agent_id: str
+    ) -> sqlite3.Row:
+        agent = self._owned_agent(connection, room_id, agent_id)
+        if not agent["enabled"]:
+            raise GroupConflictError("Agent is disabled")
+        return agent
+
+    @staticmethod
+    def _require_available_runtime(
+        connection: sqlite3.Connection, runtime_session_id: str, run_id: str
+    ) -> None:
+        occupied = connection.execute(
+            """SELECT id FROM group_agent_runs
+            WHERE runtime_session_id = ?
+              AND status IN ('running', 'awaiting_input')
+              AND id != ? LIMIT 1""",
+            (runtime_session_id, run_id),
+        ).fetchone()
+        if occupied is not None:
+            raise GroupConflictError("Runtime session is already in use")
+
+    def _agent_detail(
+        self, connection: sqlite3.Connection, room_id: str, agent_id: str
+    ) -> dict[str, object]:
+        return self._agent_wire(self._owned_agent(connection, room_id, agent_id))
+
+    @staticmethod
+    def _message_row(connection: sqlite3.Connection, message_id: str) -> sqlite3.Row:
+        row = connection.execute(
+            "SELECT * FROM group_messages WHERE id = ?", (message_id,)
+        ).fetchone()
+        if row is None:
+            raise GroupNotFoundError("Message not found")
+        return row
+
+    @staticmethod
+    def _run_row(connection: sqlite3.Connection, run_id: str) -> sqlite3.Row:
+        row = connection.execute(
+            "SELECT * FROM group_agent_runs WHERE id = ?", (run_id,)
+        ).fetchone()
+        if row is None:
+            raise GroupNotFoundError("Run not found")
+        return row
+
+    @staticmethod
+    def _interaction_row(
+        connection: sqlite3.Connection, interaction_id: str
+    ) -> sqlite3.Row:
+        row = connection.execute(
+            "SELECT * FROM group_interactions WHERE id = ?", (interaction_id,)
+        ).fetchone()
+        if row is None:
+            raise GroupNotFoundError("Interaction not found")
+        return row
+
+    @classmethod
+    def _validate_run_message_state(
+        cls, run: sqlite3.Row, message: sqlite3.Row
+    ) -> None:
+        cls._validate_run_message_status(run["status"], message["status"])
+
+    @staticmethod
+    def _validate_run_message_status(run_status: str, message_status: str) -> None:
+        if message_status not in _MESSAGE_STATUSES_BY_RUN.get(run_status, frozenset()):
+            raise GroupConflictError("Run and response message states conflict")
+
+    def _update_run(
+        self,
+        connection: sqlite3.Connection,
+        run: sqlite3.Row,
+        status: str,
+        now: float,
+        runtime_session_id: object,
+        error: object,
+    ) -> dict[str, object]:
+        runtime = (
+            run["runtime_session_id"]
+            if runtime_session_id is UNSET
+            else runtime_session_id
+        )
+        run_error = run["error"] if error is UNSET else error
+        try:
+            connection.execute(
+                "UPDATE group_agent_runs SET status = ?, runtime_session_id = ?, error = ?, updated_at = ? WHERE id = ?",
+                (status, runtime, run_error, now, run["id"]),
+            )
+        except sqlite3.IntegrityError as conflict:
+            if "group_agent_runs.runtime_session_id" in str(conflict):
+                raise GroupConflictError(
+                    "Runtime session is already in use"
+                ) from conflict
+            raise
+        return self._run_wire(self._run_row(connection, run["id"]))
+
+    @staticmethod
+    def _message_wire(row: sqlite3.Row) -> dict[str, object]:
+        try:
+            tool_state = GroupStore._load_json(row["tool_state_json"])
+        except (
+            TypeError,
+            ValueError,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+        ) as error:
+            raise GroupStoreError("Stored message tool state is corrupt") from error
+        if not isinstance(tool_state, list) or not all(
+            isinstance(item, dict) for item in tool_state
+        ):
+            raise GroupStoreError("Stored message tool state is corrupt")
+        return {
+            "seq": row["seq"],
+            "id": row["id"],
+            "roomId": row["room_id"],
+            "senderKind": row["sender_kind"],
+            "senderId": row["sender_id"],
+            "senderName": row["sender_name"],
+            "rootMessageId": row["root_message_id"],
+            "replyToMessageId": row["reply_to_message_id"],
+            "clientMessageId": row["client_message_id"],
+            "content": row["content"],
+            "reasoning": row["reasoning"],
+            "toolState": tool_state,
+            "status": row["status"],
+            "error": row["error"],
+            "visible": bool(row["visible"]),
+            "createdAt": row["created_at"],
+            "updatedAt": row["updated_at"],
+        }
+
+    @classmethod
+    def _projection_message(cls, row: sqlite3.Row) -> dict[str, object]:
+        try:
+            tool_state = cls._load_json(row["tool_state_json"])
+        except (
+            TypeError,
+            ValueError,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+        ) as error:
+            raise GroupStoreError("Stored message tool state is corrupt") from error
+        if not isinstance(tool_state, list) or not all(
+            isinstance(item, dict) for item in tool_state
+        ):
+            raise GroupStoreError("Stored message tool state is corrupt")
+        tool_names: list[str] = []
+        tool_names_truncated = False
+        for item in tool_state:
+            name = None
+            for key in ("name", "toolName"):
+                candidate = item.get(key)
+                if not isinstance(candidate, str) or not candidate:
+                    continue
+                if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,119}", candidate) is None:
+                    tool_names_truncated = True
+                    continue
+                name = candidate
+                break
+            if name is None or name in tool_names:
+                continue
+            if len(tool_names) >= 16:
+                tool_names_truncated = True
+                break
+            tool_names.append(name)
+        return {
+            "seq": int(row["seq"]),
+            "senderKind": row["sender_kind"],
+            "senderId": row["sender_id"],
+            "senderName": row["sender_name"],
+            "content": row["content"],
+            "reasoning": row["reasoning"],
+            "toolNames": tool_names,
+            "status": row["status"],
+            "summary": row["error"],
+            "truncated": tool_names_truncated,
+        }
+
+    @classmethod
+    def _bound_projection_messages(
+        cls,
+        messages: list[dict[str, object]],
+        *,
+        omitted_count: int,
+        omitted_through_seq: int | None,
+    ) -> tuple[list[dict[str, object]], int, int | None, dict[str, object] | None]:
+        while len(messages) > 1:
+            omitted_summary = cls._projection_omitted_summary(
+                omitted_count, omitted_through_seq
+            )
+            if (
+                cls._projection_size(messages, omitted_summary)
+                <= CONTEXT_CHARACTER_BUDGET
+            ):
+                break
+            removed = messages.pop(0)
+            omitted_count += 1
+            omitted_through_seq = int(removed["seq"])
+        omitted_summary = cls._projection_omitted_summary(
+            omitted_count, omitted_through_seq
+        )
+        if (
+            messages
+            and cls._projection_size(messages, omitted_summary)
+            > CONTEXT_CHARACTER_BUDGET
+        ):
+            cls._truncate_projection_message(messages[0], omitted_summary)
+        if cls._projection_size(messages, omitted_summary) > CONTEXT_CHARACTER_BUDGET:
+            raise GroupStoreError("Projection metadata exceeds the character budget")
+        return messages, omitted_count, omitted_through_seq, omitted_summary
+
+    @staticmethod
+    def _projection_omitted_summary(
+        count: int, through_seq: int | None
+    ) -> dict[str, object] | None:
+        if count == 0:
+            return None
+        if through_seq is None:
+            raise GroupStoreError("Projection omission metadata is corrupt")
+        return {
+            "messageCount": count,
+            "throughSeq": through_seq,
+            "text": f"Omitted {count} earlier messages through seq {through_seq}.",
+        }
+
+    @classmethod
+    def _projection_size(
+        cls,
+        messages: list[dict[str, object]],
+        omitted_summary: dict[str, object] | None,
+    ) -> int:
+        return len(
+            cls._canonical_json(
+                {"messages": messages, "omittedSummary": omitted_summary}
+            ).encode("utf-8")
+        )
+
+    @classmethod
+    def _truncate_projection_message(
+        cls,
+        message: dict[str, object],
+        omitted_summary: dict[str, object] | None,
+    ) -> None:
+        marker = "…[truncated]"
+        originals = {
+            field: message[field]
+            for field in ("content", "reasoning", "summary")
+            if isinstance(message[field], str) and message[field]
+        }
+        message["truncated"] = True
+        for field in originals:
+            message[field] = marker
+        wrapper = [message]
+        if cls._projection_size(wrapper, omitted_summary) > CONTEXT_CHARACTER_BUDGET:
+            raise GroupStoreError("Projection identity exceeds the character budget")
+        for field in ("content", "reasoning", "summary"):
+            original = originals.get(field)
+            if original is None:
+                continue
+            low = 0
+            high = len(original)
+            while low < high:
+                candidate = (low + high + 1) // 2
+                message[field] = (
+                    original
+                    if candidate == len(original)
+                    else f"{original[:candidate]}{marker}"
+                )
+                if (
+                    cls._projection_size(wrapper, omitted_summary)
+                    <= CONTEXT_CHARACTER_BUDGET
+                ):
+                    low = candidate
+                else:
+                    high = candidate - 1
+            message[field] = (
+                original if low == len(original) else f"{original[:low]}{marker}"
+            )
+
+    @staticmethod
+    def _run_wire(row: sqlite3.Row) -> dict[str, object]:
+        return {
+            "id": row["id"],
+            "roomId": row["room_id"],
+            "agentId": row["agent_id"],
+            "triggerMessageId": row["trigger_message_id"],
+            "responseMessageId": row["response_message_id"],
+            "rootMessageId": row["root_message_id"],
+            "depth": row["depth"],
+            "replyMode": row["reply_mode"],
+            "status": row["status"],
+            "runtimeSessionId": row["runtime_session_id"],
+            "error": row["error"],
+            "createdAt": row["created_at"],
+            "updatedAt": row["updated_at"],
+        }
+
+    @staticmethod
+    def _interaction_wire(row: sqlite3.Row) -> dict[str, object]:
+        try:
+            payload = GroupStore._load_json(row["payload_json"])
+        except (
+            TypeError,
+            ValueError,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+        ) as error:
+            raise GroupStoreError("Stored interaction payload is corrupt") from error
+        if not isinstance(payload, dict):
+            raise GroupStoreError("Stored interaction payload is corrupt")
+        return {
+            "id": row["id"],
+            "roomId": row["room_id"],
+            "agentId": row["agent_id"],
+            "runId": row["run_id"],
+            "kind": row["kind"],
+            "payload": payload,
+            "status": row["status"],
+            "createdAt": row["created_at"],
+            "resolvedAt": row["resolved_at"],
+        }
+
+    def _event_wire(self, row: sqlite3.Row, current_epoch: str) -> dict[str, object]:
+        try:
+            epoch = self._validated_epoch(row["epoch"])
+            payload = self._load_json(row["payload_json"])
+        except (
+            TypeError,
+            ValueError,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+        ) as error:
+            raise GroupStoreError("Stored event is corrupt") from error
+        if epoch != current_epoch or not isinstance(payload, dict):
+            raise GroupStoreError("Stored event is corrupt")
+        return {
+            "cursor": row["cursor"],
+            "epoch": epoch,
+            "roomId": row["room_id"],
+            "eventType": row["event_type"],
+            "payload": payload,
+            "createdAt": row["created_at"],
+        }
+
+    @staticmethod
+    def _message_text(
+        value: object, field: str, *, max_bytes: int, nonblank: bool = False
+    ) -> str:
+        if not isinstance(value, str):
+            raise ValueError(f"{field} must be a string")
+        try:
+            encoded = value.encode("utf-8")
+        except UnicodeEncodeError as error:
+            raise ValueError(f"{field} must be valid UTF-8") from error
+        if len(encoded) > max_bytes:
+            raise ValueError(f"{field} exceeds maximum size")
+        if nonblank and not value.strip():
+            raise ValueError(f"{field} must not be blank")
+        return value
+
+    @classmethod
+    def _mention_agent_ids(cls, value: object) -> list[str]:
+        if not isinstance(value, list):
+            raise ValueError("mentionAgentIds must be a list")
+        result = [
+            cls._canonical_uuid(agent_id, "mentionAgentIds") for agent_id in value
+        ]
+        if len(result) != len(set(result)):
+            raise GroupConflictError("mentionAgentIds must be unique")
+        return result
+
+    @staticmethod
+    def _interaction_id(value: object) -> str:
+        return normalize_interaction_id(value)
+
+    @staticmethod
+    def _runtime_session_id(value: object) -> str | None:
+        if value is None:
+            return None
+        if (
+            not isinstance(value, str)
+            or not value
+            or len(value) > 200
+            or any(unicodedata.category(character) == "Cc" for character in value)
+        ):
+            raise ValueError("runtimeSessionId is invalid")
+        return value
+
+    @classmethod
+    def _tool_state_json(cls, value: object) -> str:
+        if not isinstance(value, list):
+            raise ValueError("tool_state must be a list")
+        if not all(isinstance(item, Mapping) for item in value):
+            raise ValueError("tool_state entries must be objects")
+        encoded = cls._strict_json(value, "tool_state")
+        if len(encoded.encode("utf-8")) > MAX_TOOL_STATE_BYTES:
+            raise ValueError("tool_state exceeds maximum size")
+        return encoded
+
+    @classmethod
+    def _json_object(cls, value: object, field: str) -> str:
+        if not isinstance(value, Mapping):
+            raise ValueError(f"{field} must be an object")
+        encoded = cls._strict_json(dict(value), field)
+        if (
+            field == "payload"
+            and len(encoded.encode("utf-8")) > MAX_INTERACTION_PAYLOAD_BYTES
+        ):
+            raise ValueError("payload exceeds maximum size")
+        return encoded
+
+    @staticmethod
+    def _strict_json(value: object, field: str) -> str:
+        try:
+            encoded = json.dumps(
+                value,
+                ensure_ascii=False,
+                sort_keys=True,
+                separators=(",", ":"),
+                allow_nan=False,
+            )
+            encoded.encode("utf-8")
+            return encoded
+        except (TypeError, ValueError, UnicodeEncodeError) as error:
+            raise ValueError(f"{field} must be JSON-serializable") from error
+
+    @staticmethod
+    def _load_json(value: object) -> object:
+        if not isinstance(value, str):
+            raise ValueError("JSON must be a string")
+        return json.loads(
+            value,
+            parse_constant=lambda constant: (_ for _ in ()).throw(ValueError(constant)),
+        )
+
+    @staticmethod
+    def _page_limit(value: object, maximum: int, field: str) -> int:
+        if (
+            not isinstance(value, int)
+            or isinstance(value, bool)
+            or not 1 <= value <= maximum
+        ):
+            raise ValueError(f"{field} must be an integer from 1 to {maximum}")
+        return value
+
+    @staticmethod
+    def _positive_int(value: object, field: str) -> int:
+        if not isinstance(value, int) or isinstance(value, bool) or value < 1:
+            raise ValueError(f"{field} must be a positive integer")
+        return value
+
+    @staticmethod
+    def _nonnegative_int(value: object, field: str) -> int:
+        if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+            raise ValueError(f"{field} must be a nonnegative integer")
+        return value
+
+    @staticmethod
+    def _finite_timestamp(value: object, field: str) -> float:
+        try:
+            finite = (
+                math.isfinite(value)
+                if isinstance(value, (int, float)) and not isinstance(value, bool)
+                else False
+            )
+        except OverflowError:
+            finite = False
+        if not finite:
+            raise ValueError(f"{field} must be finite")
+        return float(value)
+
+    def _new_agents(self, value: object) -> list[dict[str, object]]:
+        if not isinstance(value, list) or not 1 <= len(value) <= MAX_AGENTS_PER_ROOM:
+            raise ValueError(f"agents must contain 1 to {MAX_AGENTS_PER_ROOM} members")
+        agents = [self._new_agent(agent) for agent in value]
+        profiles = [agent["profile"] for agent in agents]
+        display_keys = [agent["display_name_key"] for agent in agents]
+        if len(profiles) != len(set(profiles)):
+            raise GroupConflictError("Agent profile must be unique within a room")
+        if len(display_keys) != len(set(display_keys)):
+            raise GroupConflictError("Agent display name must be unique within a room")
+        return agents
+
+    def _new_agent(self, command: object) -> dict[str, object]:
+        command = self._command(
+            command,
+            {
+                "profile", "displayName", "description", "replyWithoutMention",
+                "model", "provider", "reasoningEffort", "fastMode",
+            },
+        )
+        profile = self._profile(command.get("profile"))
+        raw_name = command.get("displayName")
+        explicit_name = (
+            raw_name.strip()
+            if isinstance(raw_name, str) and raw_name.strip()
+            else ""
+        )
+        display_name, display_name_key = normalize_display_name(
+            explicit_name or self._resolved_agent_name(profile) or profile
+        )
+        reply_without_mention = command.get("replyWithoutMention", False)
+        if not isinstance(reply_without_mention, bool):
+            raise ValueError("replyWithoutMention must be a boolean")
+        fast_mode = command.get("fastMode")
+        if fast_mode is not None and not isinstance(fast_mode, bool):
+            raise ValueError("fastMode must be a boolean or null")
+        if (command.get("model") is None) != (command.get("provider") is None):
+            raise ValueError("model and provider must both be set or both be null")
+        return {
+            "profile": profile,
+            "display_name": display_name,
+            "display_name_key": display_name_key,
+            "description": self._description(command.get("description", "")),
+            "reply_without_mention": reply_without_mention,
+            "model_override": self._agent_configuration_value(
+                command.get("model"), "model"
+            ),
+            "provider_override": self._agent_configuration_value(
+                command.get("provider"), "provider"
+            ),
+            "reasoning_effort_override": self._agent_configuration_value(
+                command.get("reasoningEffort"), "reasoningEffort"
+            ),
+            "fast_mode_override": fast_mode,
+        }
+
+    @staticmethod
+    def _agent_configuration_value(value: object, field: str) -> str | None:
+        if value is None:
+            return None
+        if not isinstance(value, str) or not value.strip():
+            raise ValueError(f"{field} must be a non-empty string or null")
+        normalized = value.strip()
+        if field == "reasoningEffort" and normalized not in {
+            "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
+        }:
+            raise ValueError("reasoningEffort is invalid")
+        return normalized
+
+    def _resolved_agent_name(self, profile: str) -> str:
+        try:
+            value = self._agent_name_resolver(profile)
+        except Exception as error:
+            raise GroupStoreError("Agent name settings could not be read") from error
+        if not isinstance(value, str):
+            raise GroupStoreError("Agent name settings are invalid")
+        normalized = value.strip()
+        return "" if is_reserved_mention_alias(normalized) else normalized
+
+    def _insert_agent(
+        self,
+        connection: sqlite3.Connection,
+        room_id: str,
+        agent: Mapping[str, object],
+        now: float,
+    ) -> str:
+        self._check_agent_conflicts(
+            connection, room_id, agent["display_name_key"], profile=agent["profile"]
+        )
+        agent_id = str(uuid.uuid4())
+        connection.execute(
+            """INSERT INTO group_agents
+            (id, room_id, profile, display_name, display_name_key, description,
+             reply_without_mention, model_override, provider_override,
+             reasoning_effort_override, fast_mode_override, created_at, updated_at)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            (
+                agent_id,
+                room_id,
+                agent["profile"],
+                agent["display_name"],
+                agent["display_name_key"],
+                agent["description"],
+                int(bool(agent["reply_without_mention"])),
+                agent["model_override"],
+                agent["provider_override"],
+                agent["reasoning_effort_override"],
+                None if agent["fast_mode_override"] is None
+                else int(bool(agent["fast_mode_override"])),
+                now,
+                now,
+            ),
+        )
+        return agent_id
+
+    @staticmethod
+    def _check_agent_conflicts(
+        connection: sqlite3.Connection,
+        room_id: str,
+        display_name_key: str,
+        *,
+        profile: str | None = None,
+        exclude_agent_id: str | None = None,
+    ) -> None:
+        exclusion = " AND id != ?" if exclude_agent_id is not None else ""
+        suffix: tuple[object, ...] = (
+            (exclude_agent_id,) if exclude_agent_id is not None else ()
+        )
+        if (
+            profile is not None
+            and connection.execute(
+                f"SELECT 1 FROM group_agents WHERE room_id = ? AND profile = ?{exclusion}",
+                (room_id, profile, *suffix),
+            ).fetchone()
+            is not None
+        ):
+            raise GroupConflictError("Agent profile must be unique within a room")
+        if (
+            connection.execute(
+                f"SELECT 1 FROM group_agents WHERE room_id = ? AND display_name_key = ?{exclusion}",
+                (room_id, display_name_key, *suffix),
+            ).fetchone()
+            is not None
+        ):
+            raise GroupConflictError("Agent display name must be unique within a room")
+
+    @staticmethod
+    def _room_wire(row: sqlite3.Row) -> dict[str, object]:
+        return {
+            "id": row["id"],
+            "name": row["name"],
+            "cwd": row["cwd"],
+            "maxReplyRounds": row["max_reply_rounds"],
+            "createdAt": row["created_at"],
+            "updatedAt": row["updated_at"],
+            "archived": bool(row["archived"]),
+        }
+
+    @staticmethod
+    def _room_summary(row: sqlite3.Row) -> dict[str, object]:
+        return {
+            "id": row["id"],
+            "name": row["name"],
+            "cwd": row["cwd"],
+            "maxReplyRounds": row["max_reply_rounds"],
+            "createdAt": row["created_at"],
+            "updatedAt": row["updated_at"],
+            "archived": False,
+            "agentCount": row["agent_count"],
+        }
+
+    @staticmethod
+    def _agent_wire(row: sqlite3.Row) -> dict[str, object]:
+        return {
+            "id": row["id"],
+            "roomId": row["room_id"],
+            "profile": row["profile"],
+            "displayName": row["display_name"],
+            "description": row["description"],
+            "storedSessionId": row["stored_session_id"],
+            "lastContextMessageSeq": row["last_context_message_seq"],
+            "enabled": bool(row["enabled"]),
+            "replyWithoutMention": bool(row["reply_without_mention"]),
+            "model": row["model_override"],
+            "provider": row["provider_override"],
+            "reasoningEffort": row["reasoning_effort_override"],
+            "fastMode": None if row["fast_mode_override"] is None
+            else bool(row["fast_mode_override"]),
+            "createdAt": row["created_at"],
+            "updatedAt": row["updated_at"],
+        }
+
+    @staticmethod
+    def _canonical_uuid(value: object, field: str) -> str:
+        if not isinstance(value, str):
+            raise ValueError(f"{field} must be a canonical UUID")
+        try:
+            canonical = str(uuid.UUID(value))
+        except ValueError as error:
+            raise ValueError(f"{field} must be a canonical UUID") from error
+        if value != canonical:
+            raise ValueError(f"{field} must be a canonical UUID")
+        return canonical
+
+    @staticmethod
+    def _canonical_json(value: object) -> str:
+        try:
+            encoded = json.dumps(
+                value,
+                ensure_ascii=False,
+                sort_keys=True,
+                separators=(",", ":"),
+                allow_nan=False,
+            )
+            encoded.encode("utf-8")
+            return encoded
+        except (TypeError, ValueError, UnicodeEncodeError) as error:
+            raise ValueError("value must be strict JSON") from error
+
+    @classmethod
+    def _interaction_response_operation(
+        cls, value: object
+    ) -> tuple[str, str | None] | None:
+        if not isinstance(value, str):
+            return None
+        base, separator, interaction_id = value.partition(":")
+        operations = {
+            "interaction.approval.response": "approval",
+            "interaction.clarification.response": "clarification",
+        }
+        kind = operations.get(base)
+        if kind is None:
+            return None
+        if not separator:
+            return kind, None
+        try:
+            canonical_interaction_id = cls._interaction_id(interaction_id)
+        except ValueError:
+            return None
+        if canonical_interaction_id != interaction_id:
+            return None
+        return kind, canonical_interaction_id
+
+    @classmethod
+    def _interaction_operation(cls, kind: str, interaction_id: str) -> str:
+        if kind not in _INTERACTION_KINDS:
+            raise GroupStoreError("Stored interaction kind is corrupt")
+        canonical_interaction_id = cls._interaction_id(interaction_id)
+        if canonical_interaction_id != interaction_id:
+            raise GroupStoreError("Stored interaction identity is corrupt")
+        return f"interaction.{kind}.response:{canonical_interaction_id}"
+
+    @classmethod
+    def _idempotency_state(cls, value: object) -> dict[str, object]:
+        try:
+            state = cls._load_json(value)
+        except (TypeError, ValueError, json.JSONDecodeError) as error:
+            raise GroupStoreError("Stored interaction response is corrupt") from error
+        if not isinstance(state, dict) or cls._canonical_json(state) != value:
+            raise GroupStoreError("Stored interaction response is corrupt")
+        status = state.get("state")
+        if status == "claimed":
+            if (
+                not isinstance(state.get("interactionId"), str)
+                or state.get("kind") not in _INTERACTION_KINDS
+            ):
+                raise GroupStoreError("Stored interaction response is corrupt")
+        elif status == "completed":
+            if not isinstance(state.get("response"), dict):
+                raise GroupStoreError("Stored interaction response is corrupt")
+            interaction_status = state.get("interactionStatus", "resolved")
+            if interaction_status not in _INTERACTION_TERMINAL_STATUSES:
+                raise GroupStoreError("Stored interaction response is corrupt")
+        elif status == "failed":
+            runtime_ids = state.get("runtimeSessionIds")
+            if (
+                not isinstance(state.get("reason"), str)
+                or not isinstance(runtime_ids, list)
+                or not all(isinstance(item, str) for item in runtime_ids)
+                or len(runtime_ids) != len(set(runtime_ids))
+                or not isinstance(state.get("run"), dict)
+            ):
+                raise GroupStoreError("Stored interaction response is corrupt")
+        else:
+            raise GroupStoreError("Stored interaction response is corrupt")
+        return state
+
+    @staticmethod
+    def _runtime_envelope(response: dict[str, object]) -> dict[str, object]:
+        if "result" not in response and "runtimeSessionIds" not in response:
+            return {"result": response, "runtimeSessionIds": []}
+        result = response.get("result")
+        runtime_ids = response.get("runtimeSessionIds")
+        if not isinstance(result, dict) or not isinstance(runtime_ids, list):
+            raise GroupStoreError("Stored lifecycle response is corrupt")
+        if not all(isinstance(value, str) for value in runtime_ids):
+            raise GroupStoreError("Stored lifecycle response is corrupt")
+        if len(runtime_ids) != len(set(runtime_ids)):
+            raise GroupStoreError("Stored lifecycle response is corrupt")
+        return {"result": result, "runtimeSessionIds": runtime_ids}
+
+    @staticmethod
+    def _scoped_payload(
+        command: Mapping[str, object], room_id: str, agent_id: str | None = None
+    ) -> dict[str, object]:
+        payload: dict[str, object] = {"body": dict(command), "roomId": room_id}
+        if agent_id is not None:
+            payload["agentId"] = agent_id
+        return payload
+
+    @staticmethod
+    def _integrity_conflict(error: sqlite3.IntegrityError) -> GroupStoreError:
+        message = str(error)
+        if "group_agents.room_id, group_agents.profile" in message:
+            return GroupConflictError("Agent profile must be unique within a room")
+        if "group_agents.room_id, group_agents.display_name_key" in message:
+            return GroupConflictError("Agent display name must be unique within a room")
+        if "group_messages.client_message_id" in message:
+            return GroupConflictError("clientMessageId already exists")
+        if "group_agent_runs.runtime_session_id" in message:
+            return GroupConflictError("Runtime session is already in use")
+        return GroupStoreError("Group storage constraint failed")
+
+    @staticmethod
+    def _command(value: object, allowed: set[str]) -> dict[str, object]:
+        if not isinstance(value, Mapping) or not all(
+            isinstance(key, str) for key in value
+        ):
+            raise ValueError("command must be an object")
+        unexpected = set(value) - allowed
+        if unexpected:
+            raise ValueError(
+                f"Unexpected command fields: {', '.join(sorted(unexpected))}"
+            )
+        return dict(value)
+
+    def _command_request_id(self, command: Mapping[str, object]) -> str:
+        if "requestId" not in command:
+            raise ValueError("requestId is required")
+        return self._canonical_uuid(command["requestId"], "requestId")
+
+    @staticmethod
+    def _string(command: Mapping[str, object], key: str) -> str:
+        value = command.get(key)
+        if not isinstance(value, str):
+            raise ValueError(f"{key} must be a string")
+        return value
+
+    @staticmethod
+    def _profile(value: object) -> str:
+        if not isinstance(value, str):
+            raise ValueError("profile must be a string")
+        profile = value.strip()
+        if not 1 <= len(profile) <= 100:
+            raise ValueError("profile must contain 1 to 100 characters")
+        return profile
+
+    @staticmethod
+    def _description(value: object) -> str:
+        if not isinstance(value, str):
+            raise ValueError("description must be a string")
+        description = value.strip()
+        if len(description) > 500:
+            raise ValueError("description must contain at most 500 characters")
+        return description
+
+    @staticmethod
+    def _now() -> float:
+        return time.time()
+
+    @staticmethod
+    def _encode_cursor(updated_at: float, room_id: str) -> str:
+        raw = json.dumps([updated_at, room_id], separators=(",", ":")).encode()
+        return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+    @classmethod
+    def _decode_cursor(cls, cursor: str) -> tuple[float, str]:
+        if (
+            not isinstance(cursor, str)
+            or not cursor
+            or not re.fullmatch(r"[A-Za-z0-9_-]+", cursor)
+        ):
+            raise ValueError("cursor is malformed")
+        try:
+            raw = base64.b64decode(
+                cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True
+            )
+            decoded = json.loads(raw.decode("utf-8"))
+            if (
+                not isinstance(decoded, list)
+                or len(decoded) != 2
+                or isinstance(decoded[0], bool)
+                or not isinstance(decoded[0], (int, float))
+                or not math.isfinite(decoded[0])
+            ):
+                raise ValueError
+            updated_at = float(decoded[0])
+            room_id = cls._canonical_uuid(decoded[1], "cursor room id")
+            if cursor != cls._encode_cursor(updated_at, room_id):
+                raise ValueError
+            return updated_at, room_id
+        except (
+            ValueError,
+            TypeError,
+            UnicodeDecodeError,
+            json.JSONDecodeError,
+            binascii.Error,
+            OverflowError,
+        ) as error:
+            raise ValueError("cursor is malformed") from error

+ 413 - 0
hermes-plugins/yaoyao/dashboard/group_stream.py

@@ -0,0 +1,413 @@
+"""Authenticated, cursor-based event streaming for Yaoyao Group Chat."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+import time
+from collections.abc import Callable, Mapping
+from typing import Any
+from uuid import UUID
+
+from fastapi import WebSocket
+from starlette.websockets import WebSocketDisconnect
+
+from .group_protocol import MAX_EVENT_BATCH_SIZE, MAX_EVENT_FRAME_BYTES
+
+
+HEARTBEAT_SECONDS = 20
+EVENT_POLL_SECONDS = 0.1
+IDLE_POLL_SECONDS = 0.5
+PLUGIN_STATE_POLL_SECONDS = 1.0
+MAX_CURSOR = 2**63 - 1
+FRAME_TOO_LARGE_CLOSE_CODE = 1009
+PLUGIN_DISABLED_CLOSE_CODE = 4404
+
+_ASCII_DECIMAL = re.compile(r"[0-9]+", flags=re.ASCII)
+_DISCONNECT_RUNTIME_MESSAGES = frozenset(
+    {
+        'cannot call "receive" once a disconnect message has been received.',
+        'cannot call "send" once a close message has been sent.',
+        'websocket is not connected. need to call "accept" first.',
+    }
+)
+
+
+def websocket_upgrade_allowed(websocket: WebSocket) -> tuple[bool, int]:
+    """Apply the core Dashboard WebSocket guards, in their required order."""
+    try:
+        from hermes_cli import web_server
+    except Exception:
+        return False, 4401
+
+    try:
+        if not web_server._ws_auth_ok(websocket):
+            return False, 4401
+    except Exception:
+        return False, 4401
+
+    try:
+        if not web_server._ws_request_is_allowed(websocket):
+            return False, 4403
+    except Exception:
+        return False, 4403
+    return True, 1000
+
+
+def parse_cursor(value: str | None) -> int:
+    """Parse an unsigned 63-bit ASCII decimal cursor without coercion."""
+    raw = "0" if value is None else value
+    if not isinstance(raw, str) or _ASCII_DECIMAL.fullmatch(raw) is None:
+        raise ValueError("cursor is malformed")
+    cursor = int(raw, 10)
+    if cursor > MAX_CURSOR:
+        raise ValueError("cursor is out of range")
+    return cursor
+
+
+def _canonical_epoch(value: object) -> str | None:
+    if not isinstance(value, str):
+        return None
+    try:
+        canonical = str(UUID(value))
+    except ValueError:
+        return None
+    return canonical if value == canonical else None
+
+
+def _epoch_matches(value: str | None, current_epoch: str) -> bool:
+    return _canonical_epoch(value) == current_epoch
+
+
+async def _store_call(
+    store_provider: Callable[[], object],
+    method: str,
+    *args: object,
+) -> Any:
+    """Resolve the shared store and perform one complete read off-loop."""
+
+    def invoke() -> Any:
+        store = store_provider()
+        return getattr(store, method)(*args)
+
+    return await asyncio.to_thread(invoke)
+
+
+def event_frame(event: Mapping[str, object]) -> dict[str, object]:
+    """Project a persisted Store event onto the exact public WS envelope."""
+    return {
+        "type": "group.event",
+        "epoch": event["epoch"],
+        "cursor": event["cursor"],
+        "roomId": event["roomId"],
+        "event": event["eventType"],
+        "payload": event["payload"],
+    }
+
+
+def _encode_frame_json(frame: object) -> str:
+    """Match Starlette's text-frame JSON encoding for byte-limit checks."""
+    return json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
+
+
+async def _send_json_bounded(
+    websocket: WebSocket,
+    frame: dict[str, object],
+) -> bool:
+    """Send one v1 frame, or close without advancing a replay cursor."""
+    encoded = _encode_frame_json(frame)
+    if len(encoded.encode("utf-8")) > MAX_EVENT_FRAME_BYTES:
+        await websocket.close(code=FRAME_TOO_LARGE_CLOSE_CODE)
+        return False
+    await websocket.send_json(frame)
+    return True
+
+
+def _batch_has_different_epoch(events: object, *, epoch: str) -> bool:
+    """Return whether a shaped event belongs to another journal epoch."""
+    if not isinstance(events, list):
+        return False
+    for event in events:
+        if not isinstance(event, Mapping):
+            continue
+        event_epoch = _canonical_epoch(event.get("epoch"))
+        if event_epoch is not None and event_epoch != epoch:
+            return True
+    return False
+
+
+def _batch_is_contiguous(
+    events: object,
+    *,
+    cursor: int,
+    epoch: str,
+) -> bool:
+    if not isinstance(events, list):
+        return False
+    expected = cursor + 1
+    for event in events:
+        if not isinstance(event, Mapping):
+            return False
+        event_cursor = event.get("cursor")
+        if (
+            not isinstance(event_cursor, int)
+            or isinstance(event_cursor, bool)
+            or event_cursor != expected
+            or event.get("epoch") != epoch
+            or "roomId" not in event
+            or not isinstance(event.get("eventType"), str)
+            or not isinstance(event.get("payload"), Mapping)
+        ):
+            return False
+        expected += 1
+    return True
+
+
+async def _send_reset(
+    websocket: WebSocket,
+    store_provider: Callable[[], object],
+    *,
+    reason: str,
+    epoch: str,
+    accepted: bool,
+) -> None:
+    latest_cursor = await _store_call(store_provider, "latest_cursor")
+    if not accepted:
+        await websocket.accept()
+    if not await _send_json_bounded(
+        websocket,
+        {
+            "type": "group.reset_required",
+            "reason": reason,
+            "epoch": epoch,
+            "cursor": latest_cursor,
+        },
+    ):
+        return
+    await websocket.close(code=4409)
+
+
+async def _send_status_reset(
+    websocket: WebSocket,
+    store_provider: Callable[[], object],
+    *,
+    status: object,
+    current_epoch: str,
+    accepted: bool,
+) -> None:
+    reason = "epoch_mismatch" if status == "epoch_mismatch" else "cursor_expired"
+    epoch = current_epoch
+    if reason == "epoch_mismatch":
+        epoch = await _store_call(store_provider, "journal_epoch")
+    await _send_reset(
+        websocket,
+        store_provider,
+        reason=reason,
+        epoch=epoch,
+        accepted=accepted,
+    )
+
+
+async def _watch_disconnect(websocket: WebSocket) -> None:
+    """Wait for peer disconnect without consuming or mutating runtime state."""
+    while True:
+        message = await websocket.receive()
+        if message.get("type") == "websocket.disconnect":
+            return
+
+
+async def _pause_until_poll(
+    disconnect_task: asyncio.Task[None], seconds: float
+) -> bool:
+    done, _ = await asyncio.wait({disconnect_task}, timeout=seconds)
+    if disconnect_task not in done:
+        return True
+    await disconnect_task
+    return False
+
+
+def _is_disconnect_runtime_error(error: RuntimeError) -> bool:
+    return str(error).lower() in _DISCONNECT_RUNTIME_MESSAGES
+
+
+async def _stream_authorized(
+    websocket: WebSocket,
+    store_provider: Callable[[], object],
+    *,
+    availability_provider: Callable[[], bool] | None = None,
+) -> None:
+    if availability_provider is not None and not await asyncio.to_thread(
+        availability_provider
+    ):
+        await websocket.close(code=PLUGIN_DISABLED_CLOSE_CODE)
+        return
+
+    current_epoch = await _store_call(store_provider, "journal_epoch")
+    requested_epoch = websocket.query_params.get("epoch")
+    if not _epoch_matches(requested_epoch, current_epoch):
+        await _send_reset(
+            websocket,
+            store_provider,
+            reason="epoch_mismatch",
+            epoch=current_epoch,
+            accepted=False,
+        )
+        return
+
+    try:
+        cursor = parse_cursor(websocket.query_params.get("cursor"))
+    except ValueError:
+        await _send_reset(
+            websocket,
+            store_provider,
+            reason="cursor_expired",
+            epoch=current_epoch,
+            accepted=False,
+        )
+        return
+
+    status = await _store_call(store_provider, "cursor_status", current_epoch, cursor)
+    if status != "ok":
+        await _send_status_reset(
+            websocket,
+            store_provider,
+            status=status,
+            current_epoch=current_epoch,
+            accepted=False,
+        )
+        return
+
+    await websocket.accept()
+    if not await _send_json_bounded(
+        websocket,
+        {
+            "type": "group.ready",
+            "epoch": current_epoch,
+            "cursor": cursor,
+            "heartbeatSeconds": HEARTBEAT_SECONDS,
+        },
+    ):
+        return
+    last_activity = time.monotonic()
+    last_availability_check = last_activity
+    disconnect_task = asyncio.create_task(_watch_disconnect(websocket))
+    try:
+        while not disconnect_task.done():
+            now = time.monotonic()
+            if (
+                availability_provider is not None
+                and now - last_availability_check >= PLUGIN_STATE_POLL_SECONDS
+            ):
+                if not await asyncio.to_thread(availability_provider):
+                    await websocket.close(code=PLUGIN_DISABLED_CLOSE_CODE)
+                    return
+                last_availability_check = now
+
+            status = await _store_call(
+                store_provider, "cursor_status", current_epoch, cursor
+            )
+            if status != "ok":
+                await _send_status_reset(
+                    websocket,
+                    store_provider,
+                    status=status,
+                    current_epoch=current_epoch,
+                    accepted=True,
+                )
+                return
+
+            events = await _store_call(
+                store_provider,
+                "events_after",
+                cursor,
+                MAX_EVENT_BATCH_SIZE,
+            )
+            observed_epoch = await _store_call(store_provider, "journal_epoch")
+            if observed_epoch != current_epoch or _batch_has_different_epoch(
+                events, epoch=current_epoch
+            ):
+                await _send_reset(
+                    websocket,
+                    store_provider,
+                    reason="epoch_mismatch",
+                    epoch=observed_epoch,
+                    accepted=True,
+                )
+                return
+            if not _batch_is_contiguous(events, cursor=cursor, epoch=current_epoch):
+                await _send_reset(
+                    websocket,
+                    store_provider,
+                    reason="cursor_expired",
+                    epoch=current_epoch,
+                    accepted=True,
+                )
+                return
+
+            if disconnect_task.done():
+                await disconnect_task
+                return
+
+            for event in events:
+                next_cursor = int(event["cursor"])
+                if not await _send_json_bounded(websocket, event_frame(event)):
+                    return
+                cursor = next_cursor
+                last_activity = time.monotonic()
+
+            now = time.monotonic()
+            if not events and now - last_activity >= HEARTBEAT_SECONDS:
+                if not await _send_json_bounded(
+                    websocket,
+                    {"type": "group.heartbeat", "cursor": cursor},
+                ):
+                    return
+                last_activity = time.monotonic()
+
+            delay = EVENT_POLL_SECONDS if events else IDLE_POLL_SECONDS
+            if not await _pause_until_poll(disconnect_task, delay):
+                return
+    finally:
+        disconnect_task.cancel()
+        try:
+            await disconnect_task
+        except (asyncio.CancelledError, WebSocketDisconnect):
+            pass
+        except RuntimeError as error:
+            if not _is_disconnect_runtime_error(error):
+                raise
+
+
+async def stream_group_events(
+    websocket: WebSocket,
+    store_provider: Callable[[], object],
+) -> None:
+    """Authenticate and stream persisted events without touching Agent runtime."""
+    allowed, close_code = websocket_upgrade_allowed(websocket)
+    if not allowed:
+        await websocket.close(code=close_code)
+        return
+
+    await stream_authorized_group_events(websocket, store_provider)
+
+
+async def stream_authorized_group_events(
+    websocket: WebSocket,
+    store_provider: Callable[[], object],
+    *,
+    availability_provider: Callable[[], bool] | None = None,
+) -> None:
+    """Stream after the caller has consumed auth and request guards exactly once."""
+
+    try:
+        await _stream_authorized(
+            websocket,
+            store_provider,
+            availability_provider=availability_provider,
+        )
+    except WebSocketDisconnect:
+        return
+    except RuntimeError as error:
+        if not _is_disconnect_runtime_error(error):
+            raise

+ 11 - 0
hermes-plugins/yaoyao/dashboard/manifest.json

@@ -0,0 +1,11 @@
+{
+  "name": "yaoyao",
+  "label": "夭夭",
+  "description": "文件归档、Agent 名称、语音设置与多设备群聊",
+  "icon": "FolderOpen",
+  "version": "1.6.1",
+  "tab": { "path": "/yaoyao-files", "position": "after:files" },
+  "entry": "dist/index.js",
+  "css": "dist/style.v1.4.0.css",
+  "api": "plugin_api.py"
+}

+ 715 - 0
hermes-plugins/yaoyao/dashboard/plugin_api.py

@@ -0,0 +1,715 @@
+"""Yaoyao plugin backend routes for files, settings, voice, and group chat.
+
+Auto-mounted at /api/plugins/yaoyao/ by the dashboard plugin loader
+(web_server.py:17277). HTTP routes run behind the dashboard's session-token
+auth middleware just like core /api routes - no explicit auth dependency is
+needed (see the kanban plugin_api.py docstring for the authoritative note).
+
+Response field naming is camelCase to match iOS FileLibraryModels.swift
+CodingKeys (itemId / messageId / displayName / contentType / ...). The
+``message-files/query`` payload shape matches the existing ArchivedMessageFile
+contract so iOS keeps working with only a path-string change.
+
+This file is loaded by path as a standalone module (web_server.py:17256
+spec_from_file_location), so it puts its own directory on sys.path and imports
+its companions (store, poller) as top-level modules.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import logging
+import os
+import sys
+import tempfile
+import json
+from pathlib import Path
+from typing import Any, Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import FileResponse, StreamingResponse
+from pydantic import BaseModel, Field
+from urllib.parse import quote
+
+log = logging.getLogger("yaoyao.plugin_api")
+
+# Make sibling modules importable when this file is loaded by path.
+_THIS_DIR = str(Path(__file__).resolve().parent)
+if _THIS_DIR not in sys.path:
+    sys.path.insert(0, _THIS_DIR)
+
+import store  # noqa: E402
+import poller  # noqa: E402
+import voice_store  # noqa: E402
+
+
+def _load_group_plugin_api():
+    module_path = Path(_THIS_DIR) / "group_plugin_api.py"
+    module_name = (
+        "_hermes_yaoyao_group_api_"
+        + hashlib.sha256(str(module_path).encode("utf-8")).hexdigest()[:24]
+    )
+    loaded = sys.modules.get(module_name)
+    if loaded is not None:
+        loaded_path = getattr(loaded, "__file__", None)
+        if loaded_path is None or Path(loaded_path).resolve() != module_path.resolve():
+            raise ImportError("Yaoyao group API module identity is invalid")
+        return loaded
+
+    spec = importlib.util.spec_from_file_location(module_name, module_path)
+    if spec is None or spec.loader is None:
+        raise ImportError("Unable to load Yaoyao group API")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[module_name] = module
+    try:
+        spec.loader.exec_module(module)
+    except BaseException:
+        sys.modules.pop(module_name, None)
+        raise
+    return module
+
+
+group_plugin_api = _load_group_plugin_api()
+group_plugin_api.set_agent_name_resolver(
+    lambda profile: str(store.load_agent_settings(profile).get("agentName") or "")
+)
+
+
+def _store_for(profile: Optional[str]):
+    """Resolve the Store for ``profile`` (default if None). Raises 404 if the
+    profile is unknown. Validates against the live profile set so a typo or a
+    not-yet-created profile returns 404 instead of silently creating an empty
+    data dir."""
+    if not profile or profile == "default":
+        return store.get_store(store.DEFAULT_DATA_ROOT)
+    # Validate: the profile must exist on disk. Use profiles_to_serve to avoid
+    # importing hermes_cli.profiles when not needed, but fall back to a path
+    # check.
+    try:
+        from hermes_cli.profiles import profile_exists  # type: ignore
+
+        if not profile_exists(profile):
+            raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
+    except HTTPException:
+        raise
+    except Exception:
+        # hermes_cli not importable (standalone runtime): best-effort path check.
+        data_root = store.data_root_for_profile(profile)
+        if not data_root.parent.parent.parent.is_dir():  # <home>/plugins/yaoyao
+            raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
+    return store.get_store(store.data_root_for_profile(profile))
+
+
+router = APIRouter()
+router.include_router(group_plugin_api.router)
+
+# Start the background archiver as soon as the module loads (the dashboard
+# loader imports this file once at server startup). Idempotent.
+try:
+    poller.start()
+except Exception:
+    log.exception("yaoyao: poller failed to start (routes still serve existing data)")
+
+
+# ---------------------------------------------------------------------------
+# Request / response models
+# ---------------------------------------------------------------------------
+
+
+class MessageFileQueryRequest(BaseModel):
+    # Field names mirror iOS FileLibraryModels.swift MessageFileArchiveQueryRequest
+    # CodingKeys: contextType / contextId / messageIds (camelCase).
+    context_type: str = Field(default="session", alias="contextType")
+    context_id: Optional[str] = Field(default=None, alias="contextId")
+    message_ids: list[int] = Field(default_factory=list, alias="messageIds")
+
+    model_config = {"populate_by_name": True}
+
+
+class UploadBody(BaseModel):
+    # Reserved for the future upload endpoint; kept here for schema stability.
+    display_name: Optional[str] = None
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+def _kind_of(item: dict) -> str:
+    return store.infer_kind(item["displayName"], item["contentType"])
+
+
+def _to_file_library_item(it: dict) -> dict:
+    """Render an attachment row as an iOS FileLibraryItem-compatible payload.
+
+    Field names match FileLibraryItem.CodingKeys (FileLibraryModels.swift):
+    id / fileId / path / name / extension / mimeType / size / modifiedAt /
+    exists / firstSeenAt / lastSeenAt / messageTimestamp / origins. The origin
+    block matches FileLibraryOrigin.CodingKeys.
+    """
+    name = it["displayName"]
+    ext = os.path.splitext(name)[1]  # includes the leading "."
+    ts = it["discoveredAt"]
+    sender = it.get("sender") or "agent"
+    author_kind = "user" if sender == "user" else "agent"
+    source_kind = "agent_generated" if sender == "agent" else "upload"
+    event_kind = "generated" if sender == "agent" else "uploaded"
+    session_id = it.get("sessionId") or ""
+    origin = {
+        "id": it["id"],
+        "sourceKind": source_kind,
+        "eventKind": event_kind,
+        "userId": None,
+        "username": "",
+        "profile": "",
+        "agent": "",
+        "agentProfile": None,
+        "agentDisplayName": None,
+        "authorKind": author_kind,
+        "authorId": None,
+        "authorName": None,
+        "contextType": "session",
+        "sessionId": session_id,
+        "sessionTitle": "",
+        "roomId": "",
+        "roomName": "",
+        "messageId": str(it["sourceMessageId"])
+        if it.get("sourceMessageId") is not None
+        else "",
+        "runId": "",
+        "workspace": "",
+        "originalPath": "",
+        "observedAt": ts,
+        "messageTimestamp": ts,
+        "messageSequence": None,
+        "messageOrdinal": None,
+    }
+    return {
+        "id": it["id"],
+        "fileId": None,
+        "path": f"yaoyao-file-library://archive/{it['id']}?name={quote(name, safe='')}",
+        "name": name,
+        "extension": ext,
+        "mimeType": it.get("contentType") or "application/octet-stream",
+        "size": it.get("byteCount", 0),
+        "modifiedAt": ts,
+        "exists": True,
+        "firstSeenAt": ts,
+        "lastSeenAt": ts,
+        "messageTimestamp": ts,
+        "origins": [origin],
+    }
+
+
+@router.get("/profiles")
+def list_profiles():
+    """Profiles with an existing state.db (i.e. ones the poller watches).
+
+    Returns ``[{name, label, isDefault}]``. Used by the dashboard's profile
+    dropdown. ``default`` is always first when present.
+    """
+    pairs = poller._discover_profiles()
+    out = []
+    for name, home in pairs:
+        agent_settings = store.load_agent_settings(name)
+        agent_name = agent_settings["agentName"]
+        out.append(
+            {
+                "name": name,
+                "label": agent_name or ("默认" if name == "default" else name),
+                "agentName": agent_name,
+                "isDefault": name == "default",
+            }
+        )
+    return {"profiles": out}
+
+
+@router.get("/files")
+def list_files(
+    sender: Optional[str] = Query(None, description="agent | user"),
+    kind: Optional[str] = Query(None, description="image | video | text | file"),
+    session_id: Optional[str] = Query(None),
+    search: Optional[str] = Query(None),
+    profile: Optional[str] = Query(
+        None, description="which agent's library to read (default if omitted)"
+    ),
+    limit: int = Query(50, ge=1, le=200),
+    cursor: Optional[int] = Query(
+        None, description="last item id seen, for pagination"
+    ),
+):
+    """Page over the archive, newest first. Powers both iOS list and the dashboard tab.
+
+    Each item is shaped as an iOS FileLibraryItem so the iOS list service can
+    decode the response directly. ``profile`` selects which agent's library to
+    read; omitted -> the default profile.
+    """
+    st = _store_for(profile)
+    items, next_cursor, total = st.query_attachments(
+        sender=sender,
+        kind=kind,
+        session_id=session_id,
+        search=search,
+        limit=limit,
+        cursor=cursor,
+    )
+    return {
+        "items": [_to_file_library_item(it) for it in items],
+        "nextCursor": str(next_cursor) if next_cursor is not None else None,
+        "total": total,
+        "profile": profile or "default",
+    }
+
+
+@router.get("/stats")
+def get_stats(
+    profile: Optional[str] = Query(
+        None, description="which agent's library to read (default if omitted)"
+    ),
+):
+    """Archive summary: counts and total bytes for one profile."""
+    return _store_for(profile).stats()
+
+
+@router.get("/{item_id}")
+def get_item(
+    item_id: int,
+    profile: Optional[str] = Query(
+        None, description="which agent's library holds this item"
+    ),
+):
+    att = _store_for(profile).get_attachment(item_id)
+    if att is None:
+        raise HTTPException(status_code=404, detail="attachment not found")
+    return {**att, "kind": _kind_of(att)}
+
+
+@router.get("/{item_id}/download")
+def download_item(
+    item_id: int,
+    profile: Optional[str] = Query(
+        None, description="which agent's library holds this item"
+    ),
+):
+    """Stream the archived file body. Arbitrary extension; behind dashboard auth.
+
+    The static /dashboard-plugins/<name>/<file> route rejects non-browser
+    suffixes (.pdf/.zip/...) and is unauthenticated, so arbitrary file
+    downloads must live here as a FileResponse under /api/plugins/yaoyao/.
+    """
+    st = _store_for(profile)
+    att = st.get_attachment(item_id)
+    if att is None:
+        raise HTTPException(status_code=404, detail="attachment not found")
+    storage_name = st.get_object_storage_name(att["objectSha256"])
+    if storage_name is None:
+        raise HTTPException(status_code=404, detail="object body missing")
+    path = st.objects_dir / storage_name
+    if not path.is_file():
+        raise HTTPException(status_code=404, detail="object body not on disk")
+    return FileResponse(
+        path=str(path),
+        filename=att["displayName"],
+        media_type=att["contentType"] or "application/octet-stream",
+    )
+
+
+@router.post("/message-files/query")
+def query_message_files(
+    req: MessageFileQueryRequest,
+    profile: Optional[str] = Query(
+        None, description="which agent's library to read (default if omitted)"
+    ),
+):
+    """Batch-resolve archived files for a set of message ids.
+
+    Mirrors the legacy /api/hermes/file-library/message-files/query contract:
+    request {contextType, contextId, messageIds}, response
+    {messages: {<messageId>: [file, ...]}} where each file's fields match
+    iOS ArchivedMessageFile (camelCase) so a path-string change is the only
+    iOS delta needed.
+
+    ``profile`` selects which agent's library to read; omitted -> the default
+    profile. Read from the query string so iOS callers that route through the
+    shared HermesRESTClient (which sends profile via the X-Hermes-Profile
+    header) can pass it explicitly as a query item.
+    """
+    st = _store_for(profile)
+    grouped = st.query_message_files(req.message_ids)
+    out: dict[str, list[dict]] = {}
+    for mid, files in grouped.items():
+        out[str(mid)] = [
+            {
+                "itemId": f["id"],
+                "messageId": str(f["sourceMessageId"])
+                if f["sourceMessageId"] is not None
+                else "",
+                "ordinal": idx,
+                "originalPath": "",
+                "referencePath": f"/api/plugins/yaoyao/{f['id']}/download",
+                "name": f["displayName"],
+                "size": f["byteCount"],
+                "mimeType": f["contentType"],
+                "archiveStatus": "ready",
+                "archivedAt": f["discoveredAt"],
+                "availability": "archived",
+            }
+            for idx, f in enumerate(files)
+        ]
+    return {"messages": out}
+
+
+@router.post("/rescan")
+def rescan():
+    """Force one immediate poll cycle (handy after a big agent run)."""
+    try:
+        poller.poll_now()
+    except Exception:
+        log.debug("rescan: poll_now failed", exc_info=True)
+    return store.stats()
+
+
+# ===========================================================================
+# Agent display-name settings
+#
+# The profile id stays the stable routing key. ``agentName`` is an optional,
+# per-profile presentation value used by Dashboard and Yaoyao clients.
+#
+#   GET /agent/settings?profile=<profile>
+#   PUT /agent/settings?profile=<profile>  <- {"agentName": "..."}
+# ===========================================================================
+
+
+@router.get("/agent/settings")
+def get_agent_settings(
+    profile: Optional[str] = Query(
+        None, description="Hermes profile (default if omitted)"
+    ),
+):
+    """Read one profile's configured Agent display name."""
+    _store_for(profile)
+    return store.load_agent_settings(profile)
+
+
+@router.put("/agent/settings")
+def put_agent_settings(
+    body: dict,
+    profile: Optional[str] = Query(
+        None, description="Hermes profile (default if omitted)"
+    ),
+):
+    """Set or clear one profile's Agent display name."""
+    _store_for(profile)
+    try:
+        return store.save_agent_settings(profile, body)
+    except ValueError as error:
+        raise HTTPException(status_code=400, detail=str(error)) from error
+
+
+# ===========================================================================
+# iOS duplex voice settings
+#
+# Mirrors the yaoyao-webui ``ios-duplex-voice`` contract: one shared API key,
+# a voice list (id + name), and the currently selected voice. The iOS app
+# connects directly to the TTS provider (e.g. Volcano/doubao) for low-latency
+# duplex voice; this plugin stores the configuration it downloads.
+#
+# Endpoints (matching yaoyao-webui's /api/app/duplex-voice/* shape):
+#   GET  /voice/settings        -> {hasApiKey, voices, currentVoiceId, updatedAt}
+#   PUT  /voice/settings        <- {apiKey?, voices, currentVoiceId}
+#   GET  /voice/runtime         -> {apiKey, voices, currentVoiceId, updatedAt}
+#   PUT  /voice/current-voice   <- {currentVoiceId}
+# ===========================================================================
+
+
+@router.get("/voice/settings")
+def get_duplex_settings():
+    """Public settings for the dashboard UI (apiKey masked as hasApiKey)."""
+    return store.public_duplex_voice()
+
+
+@router.put("/voice/settings")
+def put_duplex_settings(body: dict):
+    """Update duplex voice settings. Partial: omit apiKey to keep existing.
+    Returns public settings (apiKey masked as hasApiKey), matching
+    yaoyao-webui's saveSettings controller."""
+    try:
+        store.save_duplex_voice(body)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    return store.public_duplex_voice()
+
+
+@router.get("/voice/runtime")
+def get_duplex_runtime():
+    """Runtime settings for the iOS app (includes the real apiKey)."""
+    return store.runtime_duplex_voice()
+
+
+@router.put("/voice/current-voice")
+def put_duplex_current_voice(body: dict):
+    """Quick-switch the current voice without re-sending the full list.
+    Returns {voices, currentVoiceId, updatedAt} (no apiKey fields), matching
+    yaoyao-webui's saveCurrentVoice controller."""
+    try:
+        store.save_duplex_voice({"currentVoiceId": body.get("currentVoiceId")})
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    s = store.load_duplex_voice()
+    return {
+        "voices": s["voices"],
+        "currentVoiceId": s["currentVoiceId"],
+        "updatedAt": s["updatedAt"],
+    }
+
+
+# ===========================================================================
+# TTS provider settings (mirrors yaoyao-webui's /api/hermes/tts/settings/*)
+# ===========================================================================
+
+
+@router.get("/tts/settings")
+def tts_list_settings():
+    """List all configured TTS providers + the active one."""
+    return voice_store.tts_list_settings()
+
+
+@router.put("/tts/settings/active")
+def tts_set_active(body: dict):
+    try:
+        return {"activeProvider": voice_store.tts_set_active(body.get("provider", ""))}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/tts/settings/{provider}")
+def tts_save_provider(provider: str, body: dict):
+    try:
+        row = voice_store.tts_save_provider(
+            provider, body.get("settings"), body.get("secrets")
+        )
+        # Optionally set active
+        if "activeProvider" in body:
+            voice_store.tts_set_active(body["activeProvider"])
+        return {"setting": row, "activeProvider": voice_store.tts_get_active()}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/tts/settings/{provider}")
+def tts_delete_provider(provider: str):
+    try:
+        deleted = voice_store.tts_delete_provider(provider)
+        return {
+            "success": True,
+            "deleted": deleted,
+            "activeProvider": voice_store.tts_get_active(),
+        }
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/tts/settings/{provider}/base-url-preset")
+def tts_delete_base_url_preset(provider: str, url: str = Query(...)):
+    row = voice_store.tts_delete_base_url_preset(provider, url)
+    if row is None:
+        raise HTTPException(status_code=404, detail="provider not found")
+    return {"success": True, "setting": row}
+
+
+@router.delete("/tts/settings/{provider}/secret/{secret_name}")
+def tts_clear_secret(provider: str, secret_name: str):
+    try:
+        row = voice_store.tts_clear_secret(provider, secret_name)
+        if row is None:
+            raise HTTPException(status_code=404, detail="provider not found")
+        return {"success": True, "setting": row}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+# ===========================================================================
+# STT provider settings (mirrors yaoyao-webui's /api/hermes/stt/settings/*)
+# ===========================================================================
+
+
+@router.get("/stt/settings")
+def stt_list_settings():
+    """List all configured STT providers + the active one."""
+    return voice_store.stt_list_settings()
+
+
+@router.put("/stt/settings/active")
+def stt_set_active(body: dict):
+    try:
+        return {"activeProvider": voice_store.stt_set_active(body.get("provider", ""))}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.put("/stt/settings/{provider}")
+def stt_save_provider(provider: str, body: dict):
+    try:
+        row = voice_store.stt_save_provider(
+            provider, body.get("settings"), body.get("secrets")
+        )
+        if "activeProvider" in body:
+            voice_store.stt_set_active(body["activeProvider"])
+        return {"setting": row, "activeProvider": voice_store.stt_get_active()}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/stt/settings/{provider}")
+def stt_delete_provider(provider: str):
+    deleted = voice_store.stt_delete_provider(provider)
+    return {
+        "success": True,
+        "deleted": deleted,
+        "activeProvider": voice_store.stt_get_active(),
+    }
+
+
+@router.delete("/stt/settings/{provider}/base-url-preset")
+def stt_delete_base_url_preset(provider: str, url: str = Query(...)):
+    row = voice_store.stt_delete_base_url_preset(provider, url)
+    if row is None:
+        raise HTTPException(status_code=404, detail="provider not found")
+    return {"success": True, "setting": row}
+
+
+@router.delete("/stt/settings/{provider}/secret/{secret_name}")
+def stt_clear_secret(provider: str, secret_name: str):
+    try:
+        row = voice_store.stt_clear_secret(provider, secret_name)
+        if row is None:
+            raise HTTPException(status_code=404, detail="provider not found")
+        return {"success": True, "setting": row}
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+# ===========================================================================
+# Provider info + probe
+# ===========================================================================
+
+
+@router.get("/voice/providers-info")
+def get_providers_info():
+    """Static provider metadata for the frontend picker."""
+    return voice_store.provider_info()
+
+
+class ProbeRequest(BaseModel):
+    kind: str  # 'tts' | 'stt'
+    provider: Optional[str] = None
+    compatibility: Optional[str] = None  # 'openai-compatible' | 'manual'
+    baseUrl: Optional[str] = None
+    apiKey: Optional[str] = None
+
+
+@router.post("/voice/probe")
+def probe_provider(req: ProbeRequest):
+    """Probe a TTS/STT provider's /models endpoint to validate connectivity.
+
+    Mirrors yaoyao-webui's /api/voice/providers/probe. For 'manual' mode,
+    just validates the baseUrl. For 'openai-compatible', fetches <baseUrl>/models.
+    """
+    import urllib.request
+    import urllib.error
+
+    compatibility = req.compatibility or "openai-compatible"
+    base_url = (req.baseUrl or "").strip()
+
+    if not base_url:
+        raise HTTPException(status_code=400, detail="baseUrl is required")
+
+    if not (base_url.startswith("http://") or base_url.startswith("https://")):
+        raise HTTPException(status_code=400, detail="baseUrl must be http(s)")
+
+    if compatibility == "manual":
+        return {
+            "ok": True,
+            "models": [],
+            "recommendedModel": "",
+            "errorSummary": "",
+            "manualModelAllowed": True,
+            "normalizedBaseUrl": base_url,
+        }
+
+    # openai-compatible: fetch /models
+    api_key = req.apiKey or ""
+    url = base_url.rstrip("/") + "/models"
+    headers = {"Content-Type": "application/json"}
+    if api_key:
+        headers["Authorization"] = f"Bearer {api_key}"
+
+    try:
+        request = urllib.request.Request(url, headers=headers, method="GET")
+        with urllib.request.urlopen(request, timeout=10) as resp:
+            body = resp.read().decode("utf-8", errors="replace")
+            data = json.loads(body)
+            raw_models = data.get("data", []) if isinstance(data, dict) else []
+            models = []
+            for m in raw_models[:100]:
+                mid = m.get("id", "") if isinstance(m, dict) else str(m)
+                if not mid:
+                    continue
+                mid_lower = mid.lower()
+                # TTS vs STT preference
+                if req.kind == "tts":
+                    pref = any(
+                        k in mid_lower
+                        for k in (
+                            "tts",
+                            "speech",
+                            "audio",
+                            "voice",
+                            "playai",
+                            "orpheus",
+                        )
+                    )
+                else:
+                    pref = any(
+                        k in mid_lower
+                        for k in ("whisper", "transcrib", "stt", "speech-to-text")
+                    )
+                models.append(
+                    {
+                        "id": mid,
+                        "label": mid,
+                        "capability": "preferred" if pref else "other",
+                    }
+                )
+            # Sort: preferred first
+            models.sort(key=lambda m: 0 if m["capability"] == "preferred" else 1)
+            recommended = models[0]["id"] if models else ""
+            return {
+                "ok": True,
+                "models": models,
+                "recommendedModel": recommended,
+                "errorSummary": "",
+                "manualModelAllowed": True,
+                "normalizedBaseUrl": base_url,
+            }
+    except urllib.error.HTTPError as e:
+        return {
+            "ok": False,
+            "models": [],
+            "recommendedModel": "",
+            "errorSummary": f"HTTP {e.code}",
+            "errorDetails": e.read().decode("utf-8", errors="replace")[:500],
+            "manualModelAllowed": True,
+            "normalizedBaseUrl": base_url,
+        }
+    except Exception as e:
+        return {
+            "ok": False,
+            "models": [],
+            "recommendedModel": "",
+            "errorSummary": str(e)[:200],
+            "manualModelAllowed": True,
+            "normalizedBaseUrl": base_url,
+        }

+ 510 - 0
hermes-plugins/yaoyao/dashboard/poller.py

@@ -0,0 +1,510 @@
+"""Background poller that archives files observed in hermes state.db messages.
+
+This is the "save everything that passes between agent and user" mechanism.
+It reads each profile's ``state.db`` read-only (WAL safe, ``query_only=1``)
+and copies any ``MEDIA:<path>`` referenced in assistant/user messages, plus
+any local file paths in tool results (``image_generate``, ``tts_tool``, ...)
+into the plugin's content-addressed store.
+
+Profile isolation
+-----------------
+Each profile (agent) - ``default``, ``yaoyao``, ``yaoer``, ``gril``,
+``succubus`` ... - has its OWN ``state.db`` and its OWN plugin data dir
+(``<profile_home>/plugins/yaoyao/data``). The poller keeps one watcher state
+per profile: a separate RO connection to that profile's state.db, its own
+mtime watermark, and its own waterline. Files are ingested into that
+profile's store with ``owner_profile=<profile name>``.
+
+Profiles are discovered via ``profiles_to_serve(multiplex=True)`` and
+re-scanned periodically so newly created profiles are picked up.
+
+Lightweight polling (no full-content scan every cycle)
+------------------------------------------------------
+Each cycle, per profile:
+  1. ``stat(state.db)`` - if mtime unchanged since last cycle, SKIP entirely
+     (no SQL). Near-zero cost. [unchanged from the original poller]
+  2. ``SELECT id FROM messages WHERE id > <waterline> LIMIT N`` - reads only
+     the rowid, never the content blob. Cheap even on a huge messages table.
+  3. Drop ids already in the plugin's ``processed_messages`` side table
+     (in-process set lookup) so a replayed/re-flushed row isn't re-read.
+  4. Only for the remaining NEW ids: ``SELECT id, session_id, role, content,
+     tool_name FROM messages WHERE id IN (...)``. This is the only place we
+     touch message content, and only for genuinely new rows.
+
+Why a side table instead of state.db triggers: we never modify state.db
+(cross-process safety; state.db is owned by the gateway). The
+``processed_messages`` table lives in the plugin's OWN index.sqlite3, which
+the dashboard process owns exclusively.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import sqlite3
+import threading
+import time
+from pathlib import Path
+from typing import Optional
+
+import sys
+
+# When loaded by the dashboard plugin loader (web_server.py:17256) this file is
+# imported by path as a standalone module, so __package__ is None and relative
+# imports fail. Make this directory importable so `import store` works both
+# here and from plugin_api.py.
+_THIS_DIR = str(Path(__file__).resolve().parent)
+if _THIS_DIR not in sys.path:
+    sys.path.insert(0, _THIS_DIR)
+
+import store  # noqa: E402
+
+log = logging.getLogger("yaoyao.poller")
+
+POLL_INTERVAL = 2.0  # seconds between cycles (per profile: stat-first, so cheap)
+BATCH_LIMIT = 1000
+PROFILE_RESCAN_EVERY = 30  # cycles between profile re-discovery passes
+# Bump this whenever extraction learns a new persisted message shape.  Each
+# profile then replays its complete history once; Store deduplication keeps the
+# archive idempotent while newly-recognized references are added.
+SCANNER_CONTRACT_VERSION = 1
+
+# MEDIA paths are normally bare tokens.  Quoted paths additionally support
+# spaces.  A quote terminates the bare form so MEDIA references embedded in a
+# GROUP_CONTEXT_JSON string do not swallow the following JSON fields.
+MEDIA_RE = re.compile(
+    r"MEDIA:\s*(?:\"(?P<double>[^\"]+)\"|'(?P<single>[^']+)'|"
+    r"(?P<plain>[^\s\"'\\]+))"
+)
+
+# Tools whose result JSON carries a local file path we should archive.
+# Keys are the tool_name; values are the candidate JSON fields that may hold
+# a local path (URLs are skipped - only real local files get archived).
+MEDIA_TOOL_FIELDS = {
+    "image_generate": ("image", "host_image", "agent_visible_image", "outputpath", "filepath"),
+    "video_generate": ("video", "outputpath", "filepath", "path"),
+    "text_to_speech": ("audio", "outputpath", "filepath", "path"),
+    "tts_tool": ("audio", "outputpath", "filepath", "path"),
+    "write_file": (
+        "path",
+        "filepath",
+        "file_path",
+        "output_path",
+        "resolved_path",
+    ),
+}
+
+# Roles we inspect. 'session_meta'/'system' carry no user-visible files.
+TRACKED_ROLES = ("assistant", "tool", "user")
+
+
+# ---------------------------------------------------------------------------
+# state.db resolution
+# ---------------------------------------------------------------------------
+
+def _profile_state_db(profile_home: Path) -> Path:
+    """Path to a profile's state.db (profile_home / state.db)."""
+    return profile_home / "state.db"
+
+
+def _open_state_db(path: Path) -> Optional[sqlite3.Connection]:
+    """Open a read-only connection. Returns None if the db doesn't exist."""
+    if not path.is_file():
+        return None
+    try:
+        conn = sqlite3.connect(
+            f"file:{path}?mode=ro", uri=True, timeout=5.0, check_same_thread=False
+        )
+        conn.row_factory = sqlite3.Row
+        conn.execute("PRAGMA query_only=1")
+        conn.execute("PRAGMA busy_timeout=5000")
+        return conn
+    except sqlite3.Error as e:
+        log.warning("poller: cannot open %s: %s", path, e)
+        return None
+
+
+# ---------------------------------------------------------------------------
+# Path extraction (profile-independent)
+# ---------------------------------------------------------------------------
+
+def _is_local_path(s: str) -> bool:
+    """True if s looks like a real local file path (not a URL, not empty)."""
+    if not s or len(s) < 2:
+        return False
+    if "://" in s[:12]:  # http:// https:// file:// oss url etc.
+        return False
+    if s.startswith("data:"):
+        return False
+    # Must have a path-ish shape: absolute, or has an extension.
+    if s.startswith("/"):
+        return True
+    if "." in os.path.basename(s):
+        return True
+    return False
+
+
+def _paths_from_assistant(content: str) -> list[str]:
+    """MEDIA: tags in assistant/user text content."""
+    found: list[str] = []
+    for match in MEDIA_RE.finditer(content):
+        path = next((value for value in match.groups() if value is not None), "")
+        if _is_local_path(path):
+            found.append(path)
+    return found
+
+
+def _paths_from_tool(content: str, tool_name: str) -> list[str]:
+    """Local paths buried in a tool result JSON body."""
+    fields = MEDIA_TOOL_FIELDS.get(tool_name)
+    if not fields:
+        return []
+    try:
+        data = json.loads(content)
+    except (json.JSONDecodeError, TypeError):
+        return []
+    found: list[str] = []
+    if isinstance(data, dict):
+        for f in fields:
+            v = data.get(f)
+            if isinstance(v, str) and _is_local_path(v):
+                found.append(v)
+            elif isinstance(v, list):
+                for item in v:
+                    if isinstance(item, str) and _is_local_path(item):
+                        found.append(item)
+            elif isinstance(v, dict):
+                for sub in v.values():
+                    if isinstance(sub, str) and _is_local_path(sub):
+                        found.append(sub)
+    return found
+
+
+# ---------------------------------------------------------------------------
+# Profile discovery
+# ---------------------------------------------------------------------------
+
+def _discover_profiles() -> list[tuple[str, Path]]:
+    """Return ``(profile_name, profile_home)`` for every profile whose
+    ``state.db`` exists.
+
+    Always includes ``default`` (-> ``~/.hermes``) when its state.db exists.
+    Named profiles come from ``profiles_to_serve(multiplex=True)``; if that
+    import is unavailable (e.g. stripped-down runtime), falls back to a
+    directory scan of ``~/.hermes/profiles/``.
+    """
+    try:
+        from hermes_cli.profiles import profiles_to_serve  # type: ignore
+        pairs = profiles_to_serve(multiplex=True)
+    except Exception:
+        # Fallback: directory scan of ~/.hermes/profiles/ + default.
+        try:
+            env = os.environ.get("HERMES_HOME", "").strip()
+            root = Path(env) if env else Path.home() / ".hermes"
+        except Exception:
+            root = Path.home() / ".hermes"
+        pairs = [("default", root)]
+        prof_root = root / "profiles"
+        if prof_root.is_dir():
+            for entry in sorted(prof_root.iterdir()):
+                if entry.is_dir() and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", entry.name):
+                    pairs.append((entry.name, entry))
+
+    # Keep only profiles whose state.db exists (a profile dir may exist but
+    # never have been started -> no state.db -> nothing to watch).
+    out: list[tuple[str, Path]] = []
+    for name, home in pairs:
+        try:
+            home = Path(home)
+        except Exception:
+            continue
+        if _profile_state_db(home).is_file():
+            out.append((name, home))
+    if not out:
+        log.debug("poller: no profiles with an existing state.db found")
+    return out
+
+
+# ---------------------------------------------------------------------------
+# Per-profile watcher state
+# ---------------------------------------------------------------------------
+
+class _ProfileWatcher:
+    """State for one profile: RO state.db conn + waterline + mtime."""
+
+    __slots__ = ("profile", "home", "db_path", "data_root", "store",
+                 "conn", "last_id", "last_mtime", "max_known_id")
+
+    def __init__(self, profile: str, home: Path):
+        self.profile = profile
+        self.home = Path(home)
+        self.db_path = _profile_state_db(self.home)
+        self.data_root = store.data_root_for_profile(profile)
+        self.store = store.get_store(self.data_root)
+        self.conn: Optional[sqlite3.Connection] = None
+        self.last_id = 0
+        self.last_mtime: int = -1
+        # Highest message id known to exist at connect time. While last_id <
+        # max_known_id we're still backfilling history and must NOT short-circuit
+        # on mtime (the db isn't changing during a historical catch-up, but
+        # there's still work to do).
+        self.max_known_id: int = 0
+
+    def ensure_connection(self) -> Optional[sqlite3.Connection]:
+        if self.conn is not None:
+            return self.conn
+        conn = _open_state_db(self.db_path)
+        if conn is None:
+            return None
+        self.conn = conn
+        self._establish_baseline()
+        log.info("poller[%s]: connected to %s, waterline=%d",
+                 self.profile, self.db_path, self.last_id)
+        return conn
+
+    def _establish_baseline(self) -> None:
+        """Set the starting waterline for this profile.
+
+        On a fresh profile we start at id=0 so the ENTIRE history is archived
+        - existing files referenced by old messages are exactly what users
+        expect to see in the library ("谁的文件" includes files exchanged in
+        past conversations). The ``processed_messages`` side table keeps this
+        idempotent: a one-time backfill, after which each cycle only touches
+        genuinely new ids.
+
+        If we already scanned some messages (resumed after a restart), the
+        waterline is taken from ``processed_messages`` so we never re-read
+        content for already-archived rows.
+        """
+        if self.store.prepare_scanner_version(SCANNER_CONTRACT_VERSION):
+            log.info(
+                "poller[%s]: scanner contract v%d requires a full-session replay",
+                self.profile,
+                SCANNER_CONTRACT_VERSION,
+            )
+        wl = self.store.processed_waterline()
+        if wl > 0:
+            self.last_id = wl
+            # Even on resume we need max_known_id to detect catch-up. Probe it
+            # lazily on first poll if it's still 0.
+            try:
+                row = self.conn.execute("SELECT MAX(id) FROM messages").fetchone()
+                self.max_known_id = int(row[0] or 0)
+            except Exception:
+                self.max_known_id = 0
+            log.info("poller[%s]: resumed at message id=%d (from processed_messages, max=%d)",
+                     self.profile, wl, self.max_known_id)
+            return
+        # Fresh: start from 0 to backfill history.
+        try:
+            row = self.conn.execute("SELECT MAX(id) FROM messages").fetchone()
+            total = int(row[0] or 0)
+        except Exception:
+            total = 0
+        self.last_id = 0
+        self.max_known_id = total
+        log.info("poller[%s]: backfill from id=0 (history has %d messages)",
+                 self.profile, total)
+
+    def close(self) -> None:
+        if self.conn is not None:
+            try:
+                self.conn.close()
+            except Exception:
+                pass
+            self.conn = None
+
+
+# ---------------------------------------------------------------------------
+# Poll loop (one cycle, one profile)
+# ---------------------------------------------------------------------------
+
+def _process_rows(watcher: _ProfileWatcher, rows: list[sqlite3.Row]) -> None:
+    """Extract + ingest every file path in ``rows``; mark them processed."""
+    profile = watcher.profile
+    st = watcher.store
+    for row in rows:
+        mid = int(row["id"])
+        role = row["role"]
+        content = row["content"] or ""
+        session_id = row["session_id"]
+        tool_name = row["tool_name"]
+
+        if role == "tool":
+            paths = _paths_from_tool(content, tool_name or "")
+            sender = "agent"
+            kind = "tool_result"
+        else:
+            paths = _paths_from_assistant(content)
+            sender = "user" if role == "user" else "agent"
+            kind = "media_tag"
+
+        for p in paths:
+            try:
+                st.ingest(
+                    p,
+                    sender=sender,
+                    source_kind=kind,
+                    source_message_id=mid,
+                    session_id=session_id,
+                    tool_name=tool_name,
+                    display_name=os.path.basename(p) or Path(p).name,
+                    owner_profile=profile,
+                )
+            except Exception as e:
+                log.debug("poller[%s]: ingest %s failed: %s", profile, p, e)
+        # Mark processed even if no paths were found, so we never re-read it.
+        st.mark_processed(mid)
+
+
+def _poll_profile(watcher: _ProfileWatcher) -> None:
+    """One poll cycle for one profile. Lightweight: stat -> id-only probe ->
+    fetch content only for genuinely new, unprocessed rows."""
+    conn = watcher.ensure_connection()
+    if conn is None:
+        return
+
+    # mtime short-circuit: if state.db hasn't changed since last cycle, skip
+    # the SQL entirely. This is the common steady-state case (no new messages).
+    # BUT only when we've finished the historical backfill (last_id >=
+    # max_known_id). While catching up on history the db isn't changing, yet
+    # there's still work to do, so we must keep probing.
+    try:
+        mtime = int(watcher.db_path.stat().st_mtime)
+    except OSError:
+        return
+    backfilling = watcher.last_id < watcher.max_known_id
+    if not backfilling and mtime == watcher.last_mtime:
+        return
+
+    # id-only probe: read just rowids for messages past the waterline. Never
+    # touches the content blob, so it's cheap even on a huge messages table.
+    try:
+        id_rows = conn.execute(
+            "SELECT id FROM messages "
+            "WHERE id > ? AND role IN ('assistant','tool','user') "
+            "ORDER BY id LIMIT ?",
+            (watcher.last_id, BATCH_LIMIT),
+        ).fetchall()
+    except sqlite3.Error as e:
+        log.warning("poller[%s]: id probe failed: %s; reopening", watcher.profile, e)
+        watcher.close()
+        return
+
+    if not id_rows:
+        watcher.last_mtime = mtime
+        return
+
+    new_ids = [int(r["id"]) for r in id_rows]
+    # Drop ids we've already scanned (replayed / re-flushed rows).
+    already = watcher.store.processed_set(new_ids)
+    fresh = [i for i in new_ids if i not in already]
+    if not fresh:
+        # All already processed; just advance waterline + mtime.
+        watcher.last_id = max(new_ids)
+        watcher.last_mtime = mtime
+        return
+
+    # Fetch content ONLY for genuinely new rows.
+    placeholders = ",".join("?" * len(fresh))
+    try:
+        rows = conn.execute(
+            f"SELECT id, session_id, role, content, tool_name FROM messages "
+            f"WHERE id IN ({placeholders})",
+            fresh,
+        ).fetchall()
+    except sqlite3.Error as e:
+        log.warning("poller[%s]: content fetch failed: %s", watcher.profile, e)
+        return
+
+    _process_rows(watcher, rows)
+    watcher.last_id = max(new_ids)
+    watcher.last_mtime = mtime
+    log.debug("poller[%s]: processed %d new messages, waterline %d",
+              watcher.profile, len(fresh), watcher.last_id)
+
+
+# ---------------------------------------------------------------------------
+# Lifecycle
+# ---------------------------------------------------------------------------
+
+_lock = threading.Lock()
+_watchers: dict[str, _ProfileWatcher] = {}
+_cycle_count = 0
+_stop_event: Optional[threading.Event] = None
+_thread: Optional[threading.Thread] = None
+
+
+def _refresh_profiles() -> None:
+    """Reconcile the watcher set with the on-disk profiles.
+
+    Adds watchers for newly-seen profiles; leaves removed profiles' watchers
+    in place (their state.db still exists on disk - we keep reading it until
+    the dir is gone, then they just never get new data).
+    """
+    discovered = _discover_profiles()
+    for name, home in discovered:
+        if name not in _watchers:
+            try:
+                w = _ProfileWatcher(name, home)
+                # init the store so the data dir exists immediately
+                w.store.init()
+                _watchers[name] = w
+                log.info("poller[%s]: watching %s", name, w.db_path)
+            except Exception as e:
+                log.warning("poller[%s]: failed to set up watcher: %s", name, e)
+
+
+def poll_now() -> None:
+    """Run one poll cycle across all profiles. Idempotent / thread-safe."""
+    global _cycle_count
+    with _lock:
+        _cycle_count += 1
+        if _cycle_count % PROFILE_RESCAN_EVERY == 0:
+            _refresh_profiles()
+        elif not _watchers:
+            _refresh_profiles()
+        for name, watcher in list(_watchers.items()):
+            try:
+                _poll_profile(watcher)
+            except Exception as e:
+                log.debug("poller[%s]: cycle error: %s", name, e)
+
+
+def _loop(stop_event: threading.Event) -> None:
+    log.info("poller: started (interval=%.1fs)", POLL_INTERVAL)
+    while not stop_event.is_set():
+        try:
+            poll_now()
+        except Exception as e:
+            log.exception("poller: unexpected error: %s", e)
+        stop_event.wait(POLL_INTERVAL)
+    # Clean up connections on stop.
+    for watcher in _watchers.values():
+        watcher.close()
+    _watchers.clear()
+    log.info("poller: stopped")
+
+
+def start() -> None:
+    """Start the poller daemon thread. Idempotent."""
+    global _stop_event, _thread
+    if _thread is not None and _thread.is_alive():
+        return
+    with _lock:
+        _refresh_profiles()
+    _stop_event = threading.Event()
+    _thread = threading.Thread(
+        target=_loop, args=(_stop_event,), name="yaoyao-poller", daemon=True
+    )
+    _thread.start()
+    log.info("poller: starting (profiles=%s)", sorted(_watchers.keys()))
+
+
+def stop() -> None:
+    """Signal the poller to stop (for tests / clean shutdown)."""
+    global _stop_event
+    if _stop_event is not None:
+        _stop_event.set()

+ 984 - 0
hermes-plugins/yaoyao/dashboard/store.py

@@ -0,0 +1,984 @@
+"""Content-addressed file store + SQLite index for the yaoyao file library.
+
+One row in ``attachments`` == one file observed in one message. Identical
+file bodies are deduplicated via the ``objects`` table (keyed by sha256), so
+storing the same image N times costs one copy on disk.
+
+Profile isolation
+-----------------
+Each profile (agent) gets its OWN data directory resolved from the profile's
+HERMES_HOME: ``<profile_home>/plugins/yaoyao/data/{index.sqlite3, objects/}``.
+The default profile uses ``~/.hermes/plugins/yaoyao/data/`` (back-compat with
+the pre-profile layout). A ``Store`` instance owns one connection per
+``data_root``; the module keeps a small cache keyed by resolved path so
+callers passing the same ``data_root`` share a connection.
+
+This store never touches the hermes ``state.db`` - it is fully isolated.
+"""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import logging
+import mimetypes
+import os
+import sqlite3
+import threading
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+try:
+    from .group_protocol import is_reserved_mention_alias
+except ImportError:  # Loaded by the Dashboard plugin loader as a top-level module.
+    from group_protocol import is_reserved_mention_alias
+
+log = logging.getLogger("yaoyao.store")
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+PLUGIN_ROOT = Path(__file__).resolve().parent.parent  # ~/.hermes/plugins/yaoyao
+# Default profile's data dir (back-compat: <plugin>/data). Named profiles use
+# <profile_home>/plugins/yaoyao/data, resolved at call time.
+DEFAULT_DATA_ROOT = PLUGIN_ROOT / "data"
+
+# Subpath of a profile home where this plugin's per-profile data lives.
+PLUGIN_DATA_SUBPATH = Path("plugins") / "yaoyao" / "data"
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS objects (
+    sha256        TEXT PRIMARY KEY,
+    storage_name  TEXT NOT NULL,
+    byte_count    INTEGER NOT NULL,
+    content_type  TEXT,
+    created_at    INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS attachments (
+    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
+    object_sha256      TEXT NOT NULL REFERENCES objects(sha256),
+    display_name       TEXT NOT NULL,
+    content_type       TEXT,
+    byte_count         INTEGER NOT NULL,
+    sender             TEXT NOT NULL,
+    source_kind        TEXT NOT NULL,
+    source_message_id  INTEGER,
+    session_id         TEXT,
+    tool_name          TEXT,
+    owner_profile      TEXT NOT NULL DEFAULT 'default',
+    discovered_at      INTEGER NOT NULL,
+    UNIQUE(source_message_id, display_name, object_sha256)
+);
+
+CREATE INDEX IF NOT EXISTS idx_att_sender     ON attachments(sender);
+CREATE INDEX IF NOT EXISTS idx_att_session    ON attachments(session_id);
+CREATE INDEX IF NOT EXISTS idx_att_discovered ON attachments(discovered_at);
+CREATE INDEX IF NOT EXISTS idx_att_kind       ON attachments(source_kind);
+
+-- Outbox-style side table: which message ids have already been scanned.
+-- Lets the poller do a cheap `SELECT id FROM messages WHERE id > ?` against
+-- state.db and skip rows already here, instead of re-reading content for
+-- every message on each cycle. Lives in THIS db (the plugin's own), never in
+-- state.db.
+CREATE TABLE IF NOT EXISTS processed_messages (
+    message_id    INTEGER PRIMARY KEY,
+    processed_at  INTEGER NOT NULL
+);
+
+-- Version the extraction contract separately from the database schema.  When
+-- the poller learns how to recognize a new file-reference shape, it can clear
+-- only the cheap processed-message waterline and replay history without
+-- deleting archived objects or attachment metadata.
+CREATE TABLE IF NOT EXISTS scanner_metadata (
+    key    TEXT PRIMARY KEY,
+    value  TEXT NOT NULL
+);
+"""
+
+# Additive migrations run once per data_root at init time. Each is idempotent:
+# ALTER TABLE ... ADD COLUMN errors if the column exists, so we catch it.
+#
+# These run BEFORE the owner_profile INDEX is created (below), so an existing
+# DB that predates the column gets it added before we try to index it.
+_MIGRATIONS = [
+    "ALTER TABLE attachments ADD COLUMN owner_profile TEXT NOT NULL DEFAULT 'default'",
+]
+
+
+def _dedup_attachments(conn: sqlite3.Connection) -> None:
+    """Remove duplicate attachment rows, keeping the lowest-id row per
+    (owner_profile, display_name, object_sha256).
+
+    Runs once per DB after the owner_profile column exists. Idempotent: if no
+    duplicates remain it's a no-op. Needed because the original dedup key was
+    (source_message_id, ...) so a file referenced by N messages left N rows;
+    the new dedup key is (owner_profile, display_name, sha256) so the same
+    file is archived once regardless of how many messages reference it.
+    """
+    try:
+        conn.execute(
+            "DELETE FROM attachments WHERE id IN ("
+            "  SELECT a.id FROM attachments a "
+            "  JOIN ("
+            "    SELECT owner_profile, display_name, object_sha256, MIN(id) AS keep_id "
+            "    FROM attachments "
+            "    GROUP BY owner_profile, display_name, object_sha256"
+            "  ) k ON a.owner_profile=k.owner_profile "
+            "       AND a.display_name=k.display_name "
+            "       AND a.object_sha256=k.object_sha256 "
+            "  WHERE a.id <> k.keep_id"
+            ")"
+        )
+        conn.commit()
+    except sqlite3.Error as e:
+        log.warning("dedup_attachments failed: %s", e)
+
+
+# Indexes that depend on a migrated column. Created after migrations so the
+# column is guaranteed present on both fresh and pre-existing DBs. The unique
+# index enforces the new dedup key (owner_profile, display_name, sha256) so the
+# same file is never archived twice in one profile.
+_POST_MIGRATION_SCHEMA = """
+CREATE INDEX IF NOT EXISTS idx_att_profile ON attachments(owner_profile);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_att_dedup
+    ON attachments(owner_profile, display_name, object_sha256);
+"""
+
+
+def _ensure_dirs(data_root: Optional[Path] = None) -> None:
+    """Ensure the data dir + objects/ exist. Defaults to the default profile."""
+    root = data_root if data_root is not None else DEFAULT_DATA_ROOT
+    root.mkdir(parents=True, exist_ok=True)
+    (root / "objects").mkdir(parents=True, exist_ok=True)
+
+
+# Back-compat: the pre-profile module exposed a module-level DATA_ROOT. Some
+# companions (voice_store) still import it. Keep it pointing at the default
+# profile's data dir.
+DATA_ROOT = DEFAULT_DATA_ROOT
+
+
+def _connect(data_root: Path) -> sqlite3.Connection:
+    """Open a read-write connection to a profile's own index database.
+
+    This is NOT the hermes state.db - it is a separate SQLite file owned by
+    this plugin. WAL mode + a short busy_timeout so the poller thread and the
+    FastAPI request threads can interleave reads/writes without blocking.
+    """
+    _ensure_dirs(data_root)
+    db_path = data_root / "index.sqlite3"
+    conn = sqlite3.connect(str(db_path), timeout=5.0, check_same_thread=False)
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA journal_mode=WAL")
+    conn.execute("PRAGMA busy_timeout=5000")
+    conn.execute("PRAGMA synchronous=NORMAL")
+    conn.executescript(_SCHEMA)
+    for stmt in _MIGRATIONS:
+        try:
+            conn.execute(stmt)
+        except sqlite3.OperationalError:
+            pass  # column already exists
+    # Dedup BEFORE creating the unique index (the index would fail to build on
+    # a DB that still has the old per-message duplicates).
+    _dedup_attachments(conn)
+    conn.executescript(_POST_MIGRATION_SCHEMA)
+    return conn
+
+
+# ---------------------------------------------------------------------------
+# Per-data_root Store
+# ---------------------------------------------------------------------------
+# A single process (the dashboard) may talk to several profiles' data dirs.
+# Keep one connection per resolved data_root, guarded by a per-root lock so
+# the poller thread and request threads interleave cleanly.
+
+class Store:
+    """Content-addressed store bound to one ``data_root`` (one profile).
+
+    Holds one SQLite connection (WAL) + a threading lock. Safe to call from
+    multiple threads within the owning process. Each profile gets its own
+    Store instance; the module caches them by resolved path.
+    """
+
+    __slots__ = ("data_root", "objects_dir", "_conn", "_lock")
+
+    def __init__(self, data_root: Path):
+        self.data_root = Path(data_root).resolve()
+        self.objects_dir = self.data_root / "objects"
+        self._conn: Optional[sqlite3.Connection] = None
+        self._lock = threading.Lock()
+
+    @property
+    def state_file(self) -> Path:
+        return self.data_root / "state.json"
+
+    def init(self) -> sqlite3.Connection:
+        """Lazily create the DB + tables. Returns the connection. Idempotent."""
+        if self._conn is None:
+            self._conn = _connect(self.data_root)
+        return self._conn
+
+    # -- content-type + kind inference -------------------------------------
+
+    def ingest(
+        self,
+        src_path: str,
+        *,
+        sender: str,
+        source_kind: str,
+        source_message_id: Optional[int],
+        session_id: Optional[str],
+        tool_name: Optional[str] = None,
+        display_name: Optional[str] = None,
+        owner_profile: str = "default",
+        discovered_at: Optional[int] = None,
+    ) -> Optional[int]:
+        """Archive one file. Returns the attachment id, or None on skip/failure.
+
+        Skips (returns None) when:
+          - the source file does not exist (agent wrote to /tmp then it was cleaned)
+          - it's not a regular file (dir / socket / fifo)
+          - it's already archived for this (message_id, display_name, sha256)
+
+        The body is content-addressed: identical bytes are stored once.
+        """
+        p = Path(src_path)
+        try:
+            if not p.is_file():
+                log.debug("ingest: skip non-file %s", src_path)
+                return None
+        except OSError as e:
+            log.debug("ingest: cannot stat %s: %s", src_path, e)
+            return None
+
+        name = display_name or p.name
+        try:
+            sha256, size = _sha256_of_file(p)
+        except OSError as e:
+            log.warning("ingest: cannot read %s: %s", src_path, e)
+            return None
+
+        content_type = guess_content_type(src_path, name)
+        ts = int(discovered_at if discovered_at is not None else time.time())
+
+        conn = self.init()
+        try:
+            with self._lock:
+                cur = conn.execute(
+                    "SELECT 1 FROM objects WHERE sha256=?", (sha256,)
+                )
+                if cur.fetchone() is None:
+                    storage_name = _copy_to_objects(p, sha256, name, self.objects_dir)
+                    conn.execute(
+                        "INSERT INTO objects(sha256, storage_name, byte_count, content_type, created_at) "
+                        "VALUES(?,?,?,?,?)",
+                        (sha256, storage_name, size, content_type, ts),
+                    )
+                conn.commit()
+        except Exception as e:
+            log.warning("ingest: object insert failed: %s", e)
+            return None
+
+        try:
+            with self._lock:
+                # Dedup by (owner_profile, display_name, sha256): the SAME file
+                # (same bytes + same name in the same profile) is archived ONCE,
+                # no matter how many messages reference it. This keeps the list
+                # free of duplicates when an agent resends/replays an image
+                # across multiple messages. The first occurrence wins; later
+                # references just return the existing attachment id.
+                cur = conn.execute(
+                    "SELECT id FROM attachments "
+                    "WHERE owner_profile=? AND display_name=? AND object_sha256=?",
+                    (owner_profile, name, sha256),
+                )
+                existing = cur.fetchone()
+                if existing:
+                    return existing["id"]
+
+                cur = conn.execute(
+                    "INSERT INTO attachments("
+                    "object_sha256, display_name, content_type, byte_count, sender, "
+                    "source_kind, source_message_id, session_id, tool_name, "
+                    "owner_profile, discovered_at"
+                    ") VALUES(?,?,?,?,?,?,?,?,?,?,?)",
+                    (sha256, name, content_type, size, sender,
+                     source_kind, source_message_id, session_id, tool_name,
+                     owner_profile, ts),
+                )
+                conn.commit()
+                return cur.lastrowid
+        except sqlite3.IntegrityError:
+            # UNIQUE race - fetch existing
+            cur = conn.execute(
+                "SELECT id FROM attachments "
+                "WHERE owner_profile=? AND display_name=? AND object_sha256=?",
+                (owner_profile, name, sha256),
+            )
+            row = cur.fetchone()
+            return row["id"] if row else None
+        except Exception as e:
+            log.warning("ingest: attachment insert failed: %s", e)
+            return None
+
+    # -- processed-messages side table -------------------------------------
+
+    def mark_processed(self, message_id: int) -> None:
+        conn = self.init()
+        with self._lock:
+            conn.execute(
+                "INSERT OR IGNORE INTO processed_messages(message_id, processed_at) "
+                "VALUES(?,?)",
+                (message_id, int(time.time())),
+            )
+            conn.commit()
+
+    def processed_set(self, message_ids: list[int]) -> set[int]:
+        """Return the subset of ``message_ids`` already in processed_messages."""
+        if not message_ids:
+            return set()
+        conn = self.init()
+        placeholders = ",".join("?" * len(message_ids))
+        rows = conn.execute(
+            f"SELECT message_id FROM processed_messages WHERE message_id IN ({placeholders})",
+            message_ids,
+        ).fetchall()
+        return {int(r["message_id"]) for r in rows}
+
+    def processed_waterline(self) -> int:
+        """Highest message_id we've already scanned (0 if none)."""
+        conn = self.init()
+        row = conn.execute(
+            "SELECT MAX(message_id) AS m FROM processed_messages"
+        ).fetchone()
+        return int(row["m"]) if row and row["m"] is not None else 0
+
+    def prepare_scanner_version(self, version: int) -> bool:
+        """Prepare ``processed_messages`` for the current extraction contract.
+
+        Returns ``True`` exactly once for each new version.  The archived file
+        rows and content-addressed objects stay intact; only the scan waterline
+        is reset so every historical session is replayed with the new rules.
+        """
+        if version < 1:
+            raise ValueError("scanner version must be positive")
+        conn = self.init()
+        requested = str(version)
+        with self._lock:
+            row = conn.execute(
+                "SELECT value FROM scanner_metadata WHERE key='contract_version'"
+            ).fetchone()
+            if row is not None and row["value"] == requested:
+                return False
+            conn.execute("DELETE FROM processed_messages")
+            conn.execute(
+                "INSERT INTO scanner_metadata(key, value) VALUES('contract_version', ?) "
+                "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+                (requested,),
+            )
+            conn.commit()
+        return True
+
+    # -- queries -----------------------------------------------------------
+
+    def get_attachment(self, attachment_id: int) -> Optional[dict[str, Any]]:
+        conn = self.init()
+        row = conn.execute(
+            "SELECT * FROM attachments WHERE id=?", (attachment_id,)
+        ).fetchone()
+        return _row_to_attachment(row) if row else None
+
+    def get_object_storage_name(self, sha256: str) -> Optional[str]:
+        conn = self.init()
+        row = conn.execute(
+            "SELECT storage_name FROM objects WHERE sha256=?", (sha256,)
+        ).fetchone()
+        return row["storage_name"] if row else None
+
+    def query_attachments(
+        self,
+        *,
+        sender: Optional[str] = None,
+        kind: Optional[str] = None,
+        session_id: Optional[str] = None,
+        search: Optional[str] = None,
+        limit: int = 50,
+        cursor: Optional[int] = None,
+    ) -> tuple[list[dict[str, Any]], Optional[int], int]:
+        """Page over filtered attachments newest-first.
+
+        ``cursor`` is the last returned id. ``total`` is the full filtered
+        count and therefore does not shrink as callers move through pages.
+        """
+        conn = self.init()
+        where, params = _attachment_filter_sql(
+            sender=sender,
+            kind=kind,
+            session_id=session_id,
+            search=search,
+        )
+        where_sql = (" WHERE " + " AND ".join(where)) if where else ""
+        total = int(
+            conn.execute(
+                "SELECT COUNT(*) FROM attachments" + where_sql,
+                params,
+            ).fetchone()[0]
+        )
+
+        page_where = list(where)
+        page_params = list(params)
+        if cursor:
+            page_where.append("id < ?")
+            page_params.append(cursor)
+        page_where_sql = (
+            " WHERE " + " AND ".join(page_where) if page_where else ""
+        )
+        page_limit = max(1, int(limit))
+        page_params.append(page_limit + 1)
+        rows = conn.execute(
+            "SELECT * FROM attachments"
+            + page_where_sql
+            + " ORDER BY id DESC LIMIT ?",
+            page_params,
+        ).fetchall()
+
+        has_more = len(rows) > page_limit
+        items = [_row_to_attachment(row) for row in rows[:page_limit]]
+        next_cursor = items[-1]["id"] if has_more and items else None
+        return items, next_cursor, total
+
+    def query_message_files(self, message_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
+        """Group attachments by source_message_id."""
+        if not message_ids:
+            return {}
+        conn = self.init()
+        placeholders = ",".join("?" * len(message_ids))
+        rows = conn.execute(
+            f"SELECT * FROM attachments WHERE source_message_id IN ({placeholders}) "
+            "ORDER BY source_message_id, id",
+            message_ids,
+        ).fetchall()
+        out: dict[int, list[dict[str, Any]]] = {}
+        for r in rows:
+            out.setdefault(r["source_message_id"], []).append(_row_to_attachment(r))
+        return out
+
+    def stats(self) -> dict[str, Any]:
+        conn = self.init()
+        total_att = conn.execute("SELECT COUNT(*) FROM attachments").fetchone()[0]
+        total_obj = conn.execute("SELECT COUNT(*) FROM objects").fetchone()[0]
+        total_bytes = conn.execute("SELECT COALESCE(SUM(byte_count),0) FROM objects").fetchone()[0]
+        by_sender = {
+            r["sender"]: r["c"]
+            for r in conn.execute(
+                "SELECT sender, COUNT(*) c FROM attachments GROUP BY sender"
+            ).fetchall()
+        }
+        by_profile = {
+            r["owner_profile"]: r["c"]
+            for r in conn.execute(
+                "SELECT owner_profile, COUNT(*) c FROM attachments GROUP BY owner_profile"
+            ).fetchall()
+        }
+        return {
+            "totalAttachments": total_att,
+            "totalObjects": total_obj,
+            "totalBytes": total_bytes,
+            "bySender": by_sender,
+            "byProfile": by_profile,
+        }
+
+
+# ---------------------------------------------------------------------------
+# Module-level cache of per-data_root Store instances
+# ---------------------------------------------------------------------------
+_STORE_LOCK = threading.Lock()
+_STORES: dict[str, Store] = {}
+
+
+def get_store(data_root: Optional[Path] = None) -> Store:
+    """Return the (cached) Store for ``data_root`` (default profile if None)."""
+    root = Path(data_root) if data_root is not None else DEFAULT_DATA_ROOT
+    key = str(root.resolve())
+    with _STORE_LOCK:
+        st = _STORES.get(key)
+        if st is None:
+            st = Store(root)
+            _STORES[key] = st
+        return st
+
+
+def data_root_for_profile(profile: Optional[str]) -> Path:
+    """Resolve the plugin data dir for a profile name.
+
+    ``default`` (or None) -> ``~/.hermes/plugins/yaoyao/data`` (back-compat).
+    Named profile -> ``<profile_home>/plugins/yaoyao/data``.
+    """
+    if not profile or profile == "default":
+        return DEFAULT_DATA_ROOT
+    try:
+        from hermes_constants import get_default_hermes_root  # type: ignore
+    except Exception:
+        get_default_hermes_root = None
+    try:
+        from hermes_cli.profiles import get_profile_dir  # type: ignore
+        home = get_profile_dir(profile)
+    except Exception:
+        # Fallback: ~/.hermes/profiles/<name> (matches get_profile_dir's logic)
+        try:
+            if get_default_hermes_root is not None:
+                base = get_default_hermes_root()
+            else:
+                env = os.environ.get("HERMES_HOME", "").strip()
+                base = Path(env) if env else Path.home() / ".hermes"
+        except Exception:
+            base = Path.home() / ".hermes"
+        home = base / "profiles" / profile
+    return home / PLUGIN_DATA_SUBPATH
+
+
+# ---------------------------------------------------------------------------
+# Back-compat module-level functions (default profile).
+#
+# Keep the old call surface working for the duplex-voice section below and any
+# stragglers. New code should use get_store(data_root).ingest(...).
+# ---------------------------------------------------------------------------
+
+def _default_store() -> Store:
+    return get_store(DEFAULT_DATA_ROOT)
+
+
+def init() -> sqlite3.Connection:
+    """Back-compat: init the DEFAULT profile's store. Returns its connection."""
+    return _default_store().init()
+
+
+def ingest(
+    src_path: str,
+    *,
+    sender: str,
+    source_kind: str,
+    source_message_id: Optional[int],
+    session_id: Optional[str],
+    tool_name: Optional[str] = None,
+    display_name: Optional[str] = None,
+    owner_profile: str = "default",
+    discovered_at: Optional[int] = None,
+) -> Optional[int]:
+    """Back-compat: ingest into the DEFAULT profile's store."""
+    return _default_store().ingest(
+        src_path,
+        sender=sender,
+        source_kind=source_kind,
+        source_message_id=source_message_id,
+        session_id=session_id,
+        tool_name=tool_name,
+        display_name=display_name,
+        owner_profile=owner_profile,
+        discovered_at=discovered_at,
+    )
+
+
+def get_attachment(attachment_id: int) -> Optional[dict[str, Any]]:
+    return _default_store().get_attachment(attachment_id)
+
+
+def get_object_storage_name(sha256: str) -> Optional[str]:
+    return _default_store().get_object_storage_name(sha256)
+
+
+def query_attachments(
+    *,
+    sender: Optional[str] = None,
+    kind: Optional[str] = None,
+    session_id: Optional[str] = None,
+    search: Optional[str] = None,
+    limit: int = 50,
+    cursor: Optional[int] = None,
+) -> tuple[list[dict[str, Any]], Optional[int], int]:
+    return _default_store().query_attachments(
+        sender=sender, kind=kind, session_id=session_id,
+        search=search, limit=limit, cursor=cursor,
+    )
+
+
+def query_message_files(message_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
+    return _default_store().query_message_files(message_ids)
+
+
+def stats() -> dict[str, Any]:
+    return _default_store().stats()
+
+
+# ---------------------------------------------------------------------------
+# Content-type + kind inference (module-level, data_root-independent)
+# ---------------------------------------------------------------------------
+
+_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".tiff", ".tif"}
+_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".flv"}
+_TEXT_EXTS = {".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".log", ".py", ".js", ".ts", ".html", ".xml", ".rst"}
+
+
+def _attachment_kind_sql() -> str:
+    def suffixes(values: set[str]) -> str:
+        return " OR ".join(
+            f"lower(display_name) LIKE '%{extension}'"
+            for extension in sorted(values)
+        )
+
+    image = (
+        "lower(COALESCE(content_type, '')) LIKE 'image/%' OR "
+        f"({suffixes(_IMAGE_EXTS)})"
+    )
+    video = (
+        "lower(COALESCE(content_type, '')) LIKE 'video/%' OR "
+        f"({suffixes(_VIDEO_EXTS)})"
+    )
+    text = (
+        "lower(COALESCE(content_type, '')) LIKE 'text/%' OR "
+        f"({suffixes(_TEXT_EXTS)})"
+    )
+    return (
+        "CASE "
+        f"WHEN ({image}) THEN 'image' "
+        f"WHEN ({video}) THEN 'video' "
+        f"WHEN ({text}) THEN 'text' "
+        "ELSE 'file' END"
+    )
+
+
+def _attachment_filter_sql(
+    *,
+    sender: Optional[str],
+    kind: Optional[str],
+    session_id: Optional[str],
+    search: Optional[str],
+) -> tuple[list[str], list[Any]]:
+    where: list[str] = []
+    params: list[Any] = []
+    if sender:
+        where.append("sender = ?")
+        params.append(sender)
+    if session_id:
+        where.append("session_id = ?")
+        params.append(session_id)
+    if search:
+        where.append("instr(lower(display_name), lower(?)) > 0")
+        params.append(search)
+    if kind:
+        where.append(f"({_attachment_kind_sql()}) = ?")
+        params.append(kind)
+    return where, params
+
+
+def guess_content_type(path: str, display_name: str = "") -> str:
+    name = display_name or os.path.basename(path)
+    ct, _ = mimetypes.guess_type(name)
+    if ct:
+        return ct
+    ext = Path(name).suffix.lower()
+    if ext in _IMAGE_EXTS:
+        return "image/png" if ext == ".png" else "application/octet-stream"
+    return "application/octet-stream"
+
+
+def infer_kind(display_name: str, content_type: str) -> str:
+    """Return one of: image / video / text / file."""
+    ext = Path(display_name).suffix.lower()
+    if content_type.startswith("image/") or ext in _IMAGE_EXTS:
+        return "image"
+    if content_type.startswith("video/") or ext in _VIDEO_EXTS:
+        return "video"
+    if content_type.startswith("text/") or ext in _TEXT_EXTS:
+        return "text"
+    return "file"
+
+
+# ---------------------------------------------------------------------------
+# Content-addressed helpers (module-level, take objects_dir)
+# ---------------------------------------------------------------------------
+
+def _sha256_of_file(path: Path) -> tuple[str, int]:
+    h = hashlib.sha256()
+    size = 0
+    with path.open("rb") as f:
+        for chunk in iter(lambda: f.read(1 << 20), b""):
+            h.update(chunk)
+            size += len(chunk)
+    return h.hexdigest(), size
+
+
+def _storage_name(sha256: str, ext: str) -> str:
+    return f"{sha256}{ext}"
+
+
+def _copy_to_objects(src: Path, sha256: str, display_name: str, objects_dir: Path) -> str:
+    """Copy ``src`` into objects/ as <sha256><ext>. Returns storage_name."""
+    ext = Path(display_name).suffix.lower() or Path(src).suffix.lower()
+    storage_name = _storage_name(sha256, ext)
+    dest = objects_dir / storage_name
+    if not dest.exists():
+        tmp = dest.with_suffix(dest.suffix + ".tmp")
+        with src.open("rb") as r, tmp.open("wb") as w:
+            while True:
+                chunk = r.read(1 << 20)
+                if not chunk:
+                    break
+                w.write(chunk)
+        os.replace(tmp, dest)
+    return storage_name
+
+
+def _row_to_attachment(row: sqlite3.Row) -> dict[str, Any]:
+    return {
+        "id": row["id"],
+        "objectSha256": row["object_sha256"],
+        "displayName": row["display_name"],
+        "contentType": row["content_type"] or "application/octet-stream",
+        "byteCount": row["byte_count"],
+        "sender": row["sender"],
+        "sourceKind": row["source_kind"],
+        "sourceMessageId": row["source_message_id"],
+        "sessionId": row["session_id"],
+        "toolName": row["tool_name"],
+        "ownerProfile": row["owner_profile"] if "owner_profile" in row.keys() else "default",
+        "discoveredAt": row["discovered_at"],
+    }
+
+
+# ---------------------------------------------------------------------------
+# Agent display-name settings
+# ---------------------------------------------------------------------------
+# Names are isolated by Hermes profile and kept next to that profile's Yaoyao
+# data.  The value is presentation-only: profile ids remain the stable routing
+# identity everywhere else.
+
+_AGENT_SETTINGS_FILE = "agent_settings.json"
+_AGENT_SETTINGS_LOCK = threading.Lock()
+MAX_AGENT_NAME_LENGTH = 100
+
+
+def _agent_settings_path(profile: Optional[str]) -> Path:
+    return data_root_for_profile(profile) / _AGENT_SETTINGS_FILE
+
+
+def load_agent_settings(profile: Optional[str] = None) -> dict[str, Any]:
+    """Read the configured display name for one Hermes profile."""
+    profile_name = str(profile or "default").strip() or "default"
+    result = {
+        "profile": profile_name,
+        "agentName": "",
+        "updatedAt": 0,
+    }
+    path = _agent_settings_path(profile_name)
+    try:
+        if path.is_file():
+            saved = json.loads(path.read_text(encoding="utf-8"))
+            if isinstance(saved, dict):
+                name = str(saved.get("agentName") or "").strip()
+                if len(name) <= MAX_AGENT_NAME_LENGTH and not any(
+                    ord(character) < 32 or ord(character) == 127
+                    for character in name
+                ) and not is_reserved_mention_alias(name):
+                    result["agentName"] = name
+                result["updatedAt"] = int(saved.get("updatedAt") or 0)
+    except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
+        log.warning("agent settings load failed for %s: %s", profile_name, error)
+    return result
+
+
+def save_agent_settings(
+    profile: Optional[str],
+    input_data: dict[str, Any],
+) -> dict[str, Any]:
+    """Set or clear one profile's display name and return the saved value."""
+    if not isinstance(input_data, dict) or "agentName" not in input_data:
+        raise ValueError("agentName is required")
+    candidate = input_data["agentName"]
+    if not isinstance(candidate, str):
+        raise ValueError("agentName must be a string")
+    agent_name = candidate.strip()
+    if len(agent_name) > MAX_AGENT_NAME_LENGTH:
+        raise ValueError("agentName is too long")
+    if any(ord(character) < 32 or ord(character) == 127 for character in agent_name):
+        raise ValueError("agentName contains control characters")
+    if is_reserved_mention_alias(agent_name):
+        raise ValueError("agentName is reserved for group mentions")
+
+    profile_name = str(profile or "default").strip() or "default"
+    now = int(time.time() * 1000)
+    saved = {
+        "profile": profile_name,
+        "agentName": agent_name,
+        "updatedAt": now,
+    }
+    path = _agent_settings_path(profile_name)
+    _ensure_dirs(path.parent)
+    temporary = path.with_name(
+        f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
+    )
+    with _AGENT_SETTINGS_LOCK:
+        try:
+            temporary.write_text(
+                json.dumps(saved, indent=2, ensure_ascii=False) + "\n",
+                encoding="utf-8",
+            )
+            os.replace(temporary, path)
+        finally:
+            temporary.unlink(missing_ok=True)
+    return saved
+
+
+# ---------------------------------------------------------------------------
+# iOS duplex voice settings (mirrors yaoyao-webui's ios-duplex-voice contract)
+# ---------------------------------------------------------------------------
+# The iOS app connects directly to the TTS provider (e.g. Volcano/doubao) for
+# low-latency duplex voice. This plugin stores the single shared API key +
+# the voice list + the currently selected voice, exactly matching the
+# IosDuplexVoiceSettings schema from yaoyao-webui so the iOS client can talk
+# to either backend interchangeably.
+#
+# These settings are GLOBAL (not per-profile): one shared duplex-voice config
+# for the whole installation. Kept in the default profile's data dir.
+
+_DEFAULT_DUPLEX_VOICES: list[dict[str, str]] = [
+    {"id": "zh_female_xiaohe_uranus_bigtts", "name": "小何 2.0"},
+    {"id": "zh_female_vv_uranus_bigtts", "name": "Vivi 2.0"},
+    {"id": "zh_male_m191_uranus_bigtts", "name": "云舟 2.0"},
+    {"id": "zh_male_taocheng_uranus_bigtts", "name": "小天 2.0"},
+    {"id": "zh_female_qingxinnvsheng_uranus_bigtts", "name": "清新女声 2.0"},
+    {"id": "zh_female_cancan_uranus_bigtts", "name": "知性灿灿 2.0"},
+    {"id": "zh_male_ruyayichen_uranus_bigtts", "name": "儒雅逸辰 2.0"},
+    {"id": "en_female_dacey_uranus_bigtts", "name": "Dacey 2.0"},
+]
+
+_DUPLEX_VOICE_FILE = DEFAULT_DATA_ROOT / "duplex_voice.json"
+
+_DUPLEX_LOCK = threading.Lock()
+
+MAX_DUPLEX_VOICES = 100
+MAX_DUPLEX_VOICE_TEXT = 200
+MAX_DUPLEX_API_KEY = 4000
+
+
+def _duplex_default() -> dict[str, Any]:
+    return {
+        "apiKey": "",
+        "voices": [dict(v) for v in _DEFAULT_DUPLEX_VOICES],
+        "currentVoiceId": _DEFAULT_DUPLEX_VOICES[0]["id"],
+        "updatedAt": 0,
+    }
+
+
+def _normalize_duplex_voices(raw: Any) -> list[dict[str, str]]:
+    """Validate + normalize a voice list (raises ValueError on bad input)."""
+    if not isinstance(raw, list) or len(raw) == 0 or len(raw) > MAX_DUPLEX_VOICES:
+        raise ValueError("voices must contain between 1 and 100 items")
+    seen: set[str] = set()
+    out: list[dict[str, str]] = []
+    for item in raw:
+        if not isinstance(item, dict):
+            raise ValueError("invalid voice")
+        vid = str(item.get("id", "")).strip()
+        name = str(item.get("name", "")).strip()
+        if not vid or len(vid) > MAX_DUPLEX_VOICE_TEXT:
+            raise ValueError("invalid voice id")
+        if not name or len(name) > MAX_DUPLEX_VOICE_TEXT:
+            raise ValueError("invalid voice name")
+        if vid in seen:
+            raise ValueError(f"duplicate voice id: {vid}")
+        seen.add(vid)
+        out.append({"id": vid, "name": name})
+    return out
+
+
+def load_duplex_voice() -> dict[str, Any]:
+    """Read duplex voice settings, deep-merged over defaults."""
+    cfg = _duplex_default()
+    try:
+        if _DUPLEX_VOICE_FILE.is_file():
+            saved = json.loads(_DUPLEX_VOICE_FILE.read_text(encoding="utf-8"))
+            if isinstance(saved, dict):
+                if "apiKey" in saved:
+                    cfg["apiKey"] = str(saved["apiKey"])
+                if "voices" in saved:
+                    cfg["voices"] = _normalize_duplex_voices(saved["voices"])
+                if "currentVoiceId" in saved:
+                    cfg["currentVoiceId"] = str(saved["currentVoiceId"])
+                if "updatedAt" in saved:
+                    cfg["updatedAt"] = int(saved["updatedAt"])
+    except Exception as e:
+        log.warning("duplex voice load failed: %s", e)
+    # Ensure currentVoiceId is valid
+    if not any(v["id"] == cfg["currentVoiceId"] for v in cfg["voices"]):
+        cfg["currentVoiceId"] = cfg["voices"][0]["id"] if cfg["voices"] else ""
+    return cfg
+
+
+def save_duplex_voice(input_data: dict[str, Any]) -> dict[str, Any]:
+    """Partial update of duplex voice settings. Returns the full settings."""
+    current = load_duplex_voice()
+
+    voices = current["voices"]
+    if "voices" in input_data:
+        voices = _normalize_duplex_voices(input_data["voices"])
+
+    current_voice_id = current["currentVoiceId"]
+    if "currentVoiceId" in input_data:
+        current_voice_id = str(input_data["currentVoiceId"]).strip()
+        if not current_voice_id or len(current_voice_id) > MAX_DUPLEX_VOICE_TEXT:
+            raise ValueError("invalid current voice id")
+    # If current voice was removed, fall back to first
+    if not any(v["id"] == current_voice_id for v in voices):
+        current_voice_id = voices[0]["id"] if voices else ""
+
+    api_key = current["apiKey"]
+    if "apiKey" in input_data:
+        candidate = input_data["apiKey"]
+        if not isinstance(candidate, str):
+            raise ValueError("apiKey must be a string")
+        candidate = candidate.strip()
+        if candidate and candidate != "[stored]":
+            if len(candidate) > MAX_DUPLEX_API_KEY:
+                raise ValueError("apiKey is too long")
+            api_key = candidate
+
+    import time as _time
+    now = int(_time.time() * 1000)
+    merged = {
+        "apiKey": api_key,
+        "voices": voices,
+        "currentVoiceId": current_voice_id,
+        "updatedAt": now,
+    }
+    _ensure_dirs(DEFAULT_DATA_ROOT)
+    with _DUPLEX_LOCK:
+        _DUPLEX_VOICE_FILE.write_text(
+            json.dumps(merged, indent=2, ensure_ascii=False), encoding="utf-8"
+        )
+    return merged
+
+
+def public_duplex_voice() -> dict[str, Any]:
+    """Settings for the dashboard UI (apiKey masked as hasApiKey boolean)."""
+    s = load_duplex_voice()
+    return {
+        "hasApiKey": bool(s["apiKey"]),
+        "voices": s["voices"],
+        "currentVoiceId": s["currentVoiceId"],
+        "updatedAt": s["updatedAt"],
+    }
+
+
+def runtime_duplex_voice() -> dict[str, Any]:
+    """Settings for the iOS app runtime (includes the real apiKey)."""
+    s = load_duplex_voice()
+    return {
+        "apiKey": s["apiKey"],
+        "voices": s["voices"],
+        "currentVoiceId": s["currentVoiceId"],
+        "updatedAt": s["updatedAt"],
+    }

+ 158 - 0
hermes-plugins/yaoyao/dashboard/tests/test_file_library_contract.py

@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+if str(DASHBOARD_DIR) not in sys.path:
+    sys.path.insert(0, str(DASHBOARD_DIR))
+
+import poller  # noqa: E402
+import store  # noqa: E402
+
+with mock.patch.object(poller, "start", return_value=None):
+    import plugin_api  # noqa: E402
+
+
+class FileLibraryContractTests(unittest.TestCase):
+    def test_search_and_kind_filter_before_cursor_pagination(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            archive = store.Store(root / "library")
+            old_cover = self._ingest(
+                archive,
+                root,
+                display_name="cover-old.png",
+                body=b"old-cover",
+                message_id=1,
+            )
+            new_cover = self._ingest(
+                archive,
+                root,
+                display_name="cover-new.png",
+                body=b"new-cover",
+                message_id=2,
+            )
+            self._ingest(
+                archive,
+                root,
+                display_name="newest-notes.md",
+                body=b"newest-notes",
+                message_id=3,
+            )
+
+            first, next_cursor, total = archive.query_attachments(
+                search="COVER",
+                limit=1,
+            )
+            self.assertEqual([item["id"] for item in first], [new_cover])
+            self.assertEqual(next_cursor, new_cover)
+            self.assertEqual(total, 2)
+
+            second, final_cursor, second_total = archive.query_attachments(
+                search="cover",
+                limit=1,
+                cursor=next_cursor,
+            )
+            self.assertEqual([item["id"] for item in second], [old_cover])
+            self.assertIsNone(final_cursor)
+            self.assertEqual(second_total, 2)
+
+            images, image_cursor, image_total = archive.query_attachments(
+                kind="image",
+                limit=10,
+            )
+            self.assertEqual(
+                [item["displayName"] for item in images],
+                ["cover-new.png", "cover-old.png"],
+            )
+            self.assertIsNone(image_cursor)
+            self.assertEqual(image_total, 2)
+
+    def test_profile_list_payload_and_download_route_contract(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            archive = store.Store(root / "yaoer-library")
+            item_id = self._ingest(
+                archive,
+                root,
+                display_name="cover.png",
+                body=b"profile-cover",
+                message_id=41,
+            )
+            other = store.Store(root / "other-library")
+            self._ingest(
+                other,
+                root,
+                display_name="other.txt",
+                body=b"other-profile",
+                message_id=42,
+            )
+            requested_profiles: list[str | None] = []
+
+            def resolve(profile: str | None):
+                requested_profiles.append(profile)
+                return archive if profile == "yaoer" else other
+
+            with mock.patch.object(plugin_api, "_store_for", side_effect=resolve):
+                payload = plugin_api.list_files(
+                    sender=None,
+                    kind=None,
+                    session_id=None,
+                    search=None,
+                    profile="yaoer",
+                    limit=50,
+                    cursor=None,
+                )
+                response = plugin_api.download_item(item_id, profile="yaoer")
+
+            self.assertEqual(requested_profiles, ["yaoer", "yaoer"])
+            self.assertEqual(payload["profile"], "yaoer")
+            self.assertEqual(payload["total"], 1)
+            self.assertIsNone(payload["nextCursor"])
+            self.assertEqual([item["name"] for item in payload["items"]], ["cover.png"])
+            self.assertEqual(
+                payload["items"][0]["path"],
+                f"yaoyao-file-library://archive/{item_id}?name=cover.png",
+            )
+            self.assertEqual(Path(response.path).read_bytes(), b"profile-cover")
+            self.assertIn(
+                "cover.png",
+                response.headers.get("content-disposition", ""),
+            )
+            paths = {getattr(route, "path", "") for route in plugin_api.router.routes}
+            self.assertIn("/files", paths)
+            self.assertIn("/{item_id}/download", paths)
+
+    @staticmethod
+    def _ingest(
+        archive: store.Store,
+        root: Path,
+        *,
+        display_name: str,
+        body: bytes,
+        message_id: int,
+    ) -> int:
+        source = root / f"source-{message_id}"
+        source.write_bytes(body)
+        item_id = archive.ingest(
+            str(source),
+            sender="agent",
+            source_kind="message",
+            source_message_id=message_id,
+            session_id="session-a",
+            display_name=display_name,
+            owner_profile="yaoer",
+            discovered_at=message_id,
+        )
+        if item_id is None:
+            raise AssertionError("fixture was not archived")
+        return item_id
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 27 - 0
hermes-plugins/yaoyao/dashboard/tests/test_frontend_contract.py

@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+
+class DashboardFrontendContractTests(unittest.TestCase):
+    def test_sdk_selects_use_value_callback(self) -> None:
+        source = (
+            Path(__file__).resolve().parents[1] / "dist" / "index.js"
+        ).read_text(encoding="utf-8")
+        select_blocks = re.findall(r"h\(Select, \{(.*?)\},", source, flags=re.S)
+
+        self.assertGreater(len(select_blocks), 0)
+        self.assertFalse(
+            [block for block in select_blocks if "onChange:" in block],
+            "Hermes SDK Select requires onValueChange(value), not onChange(event)",
+        )
+        self.assertTrue(
+            all("onValueChange:" in block for block in select_blocks),
+            "Every Hermes SDK Select must expose its value callback",
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 280 - 0
hermes-plugins/yaoyao/dashboard/tests/test_group_merge_contract.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import textwrap
+import unittest
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+
+
+class GroupMergeContractTests(unittest.TestCase):
+    def test_group_routes_share_yaoyao_plugin_and_lifespan(self) -> None:
+        probe = textwrap.dedent(
+            """
+            import json
+            from pathlib import Path
+            import sys
+            import types
+
+            from fastapi import FastAPI
+            from fastapi.testclient import TestClient
+
+            dashboard_dir = Path(sys.argv[1])
+            sys.path.insert(0, str(dashboard_dir))
+
+            poller = types.ModuleType("poller")
+            poller.start = lambda: None
+            sys.modules["poller"] = poller
+            unrelated_group_api = types.ModuleType("group_plugin_api")
+            sys.modules["group_plugin_api"] = unrelated_group_api
+
+            import plugin_api
+            from hermes_cli import plugins_cmd, web_server
+
+            lifecycle = []
+
+            class FakeOrchestrator:
+                async def start(self):
+                    lifecycle.append("start")
+
+                async def shutdown(self):
+                    lifecycle.append("shutdown")
+
+            class FakeStore:
+                def initialize(self):
+                    return None
+
+                def journal_epoch(self):
+                    return "11111111-1111-4111-8111-111111111111"
+
+                def latest_cursor(self):
+                    return 0
+
+            group_api = plugin_api.group_plugin_api
+            original_plugins = web_server._get_dashboard_plugins
+            original_enabled = plugins_cmd._get_enabled_set
+            original_disabled = plugins_cmd._get_disabled_set
+            web_server._get_dashboard_plugins = lambda: [
+                {"name": "yaoyao", "source": "user"}
+            ]
+            plugins_cmd._get_enabled_set = lambda: {"yaoyao"}
+            plugins_cmd._get_disabled_set = lambda: set()
+            enabled_state = group_api._plugin_runtime_enabled()
+            plugins_cmd._get_disabled_set = lambda: {"yaoyao"}
+            disabled_state = group_api._plugin_runtime_enabled()
+            web_server._get_dashboard_plugins = original_plugins
+            plugins_cmd._get_enabled_set = original_enabled
+            plugins_cmd._get_disabled_set = original_disabled
+
+            group_api.set_store_for_testing(FakeStore())
+            group_api.set_orchestrator_factory_for_testing(
+                lambda _store: FakeOrchestrator()
+            )
+
+            app = FastAPI()
+            app.include_router(
+                plugin_api.router,
+                prefix="/api/plugins/yaoyao",
+            )
+            with TestClient(app) as client:
+                existing_files_status = client.get(
+                    "/api/plugins/yaoyao/files"
+                ).status_code
+                existing_voice_status = client.get(
+                    "/api/plugins/yaoyao/voice/settings"
+                ).status_code
+                capabilities_status = client.get(
+                    "/api/plugins/yaoyao/v1/capabilities"
+                ).status_code
+                lifecycle.append("ready")
+
+            stream = group_api._stream_module
+            stream.PLUGIN_STATE_POLL_SECONDS = 0
+            epoch = "11111111-1111-4111-8111-111111111111"
+
+            class StreamStore:
+                def journal_epoch(self):
+                    return epoch
+
+                def cursor_status(self, _epoch, _cursor):
+                    return "ok"
+
+                def events_after(self, _cursor, _limit):
+                    return []
+
+            class StreamSocket:
+                query_params = {"epoch": epoch, "cursor": "0"}
+
+                def __init__(self):
+                    self.frames = []
+                    self.close_codes = []
+
+                async def accept(self):
+                    return None
+
+                async def send_json(self, frame):
+                    self.frames.append(frame)
+
+                async def close(self, code, reason=None):
+                    self.close_codes.append(code)
+
+                async def receive(self):
+                    import asyncio
+                    await asyncio.Future()
+
+            availability = iter([True, False])
+            socket = StreamSocket()
+            import asyncio
+            asyncio.run(
+                stream.stream_authorized_group_events(
+                    socket,
+                    lambda: StreamStore(),
+                    availability_provider=lambda: next(availability, False),
+                )
+            )
+
+            orchestrator_module = __import__(
+                f"{group_api._LOCAL_PACKAGE}.group_orchestrator",
+                fromlist=["GroupOrchestrator"],
+            )
+            orchestrator_module._WORK_DISABLED_RETRY_SECONDS = 0.01
+
+            class SchedulerStore:
+                def __init__(self):
+                    self.cascade_calls = 0
+                    self.claim_calls = 0
+
+                def initialize(self):
+                    return None
+
+                def recover_after_restart(self):
+                    return []
+
+                def list_pending_cascades(self, *, limit):
+                    self.cascade_calls += 1
+                    return {"items": [], "nextCursor": None}
+
+                def claim_next_runnable_run(self):
+                    self.claim_calls += 1
+                    return None
+
+            class SchedulerTransport:
+                def drain(self, _timeout):
+                    return None
+
+            class SchedulerGateway:
+                transport = SchedulerTransport()
+
+                def shutdown(self):
+                    return None
+
+            async def exercise_scheduler_gate():
+                scheduler_store = SchedulerStore()
+                work_enabled = [False]
+                orchestrator = orchestrator_module.GroupOrchestrator(
+                    scheduler_store,
+                    gateway_factory=lambda _callback: SchedulerGateway(),
+                    work_enabled=lambda: work_enabled[0],
+                )
+                await orchestrator.start()
+                await asyncio.sleep(0.04)
+                disabled_calls = [
+                    scheduler_store.cascade_calls,
+                    scheduler_store.claim_calls,
+                ]
+                work_enabled[0] = True
+                for _ in range(100):
+                    if (
+                        scheduler_store.cascade_calls > 0
+                        and scheduler_store.claim_calls > 0
+                    ):
+                        break
+                    await asyncio.sleep(0.005)
+                enabled_calls = [
+                    scheduler_store.cascade_calls,
+                    scheduler_store.claim_calls,
+                ]
+                await orchestrator.shutdown()
+                return disabled_calls, enabled_calls
+
+            disabled_calls, enabled_calls = asyncio.run(exercise_scheduler_gate())
+
+            class DisabledSocket:
+                def __init__(self):
+                    self.close_codes = []
+
+                async def close(self, code, reason=None):
+                    self.close_codes.append(code)
+
+            original_upgrade_allowed = stream.websocket_upgrade_allowed
+            original_runtime_enabled = group_api._plugin_runtime_enabled
+            stream.websocket_upgrade_allowed = lambda _websocket: (True, 1000)
+            group_api._plugin_runtime_enabled = lambda: False
+            disabled_socket = DisabledSocket()
+            asyncio.run(group_api.stream_events(disabled_socket))
+            stream.websocket_upgrade_allowed = original_upgrade_allowed
+            group_api._plugin_runtime_enabled = original_runtime_enabled
+
+            print(json.dumps({
+                "paths": sorted(
+                    getattr(route, "path", "")
+                    for route in plugin_api.router.routes
+                ),
+                "lifecycle": lifecycle,
+                "storePath": str(group_api.GroupStore.from_environment().path),
+                "moduleIsPrivate": group_api is not unrelated_group_api,
+                "pluginGate": [enabled_state, disabled_state],
+                "streamFrames": socket.frames,
+                "streamCloseCodes": socket.close_codes,
+                "disabledWorkCalls": disabled_calls,
+                "enabledWorkCalls": enabled_calls,
+                "disabledConnectCloseCodes": disabled_socket.close_codes,
+                "statuses": [
+                    existing_files_status,
+                    existing_voice_status,
+                    capabilities_status,
+                ],
+            }))
+            """
+        )
+
+        with tempfile.TemporaryDirectory() as hermes_home:
+            environment = dict(os.environ)
+            environment["HERMES_HOME"] = hermes_home
+            result = subprocess.run(
+                [sys.executable, "-c", probe, str(DASHBOARD_DIR)],
+                check=True,
+                capture_output=True,
+                text=True,
+                env=environment,
+            )
+
+        payload = json.loads(result.stdout.strip().splitlines()[-1])
+        self.assertIn("/files", payload["paths"])
+        self.assertIn("/voice/settings", payload["paths"])
+        self.assertIn("/v1/capabilities", payload["paths"])
+        self.assertIn("/v1/events", payload["paths"])
+        self.assertTrue(payload["moduleIsPrivate"])
+        self.assertEqual(payload["pluginGate"], [True, False])
+        self.assertEqual(payload["streamFrames"][0]["type"], "group.ready")
+        self.assertEqual(payload["streamCloseCodes"], [4404])
+        self.assertEqual(payload["disabledWorkCalls"], [0, 0])
+        self.assertGreater(payload["enabledWorkCalls"][0], 0)
+        self.assertGreater(payload["enabledWorkCalls"][1], 0)
+        self.assertEqual(payload["disabledConnectCloseCodes"], [4404])
+        self.assertEqual(payload["statuses"], [200, 200, 200])
+        self.assertEqual(payload["lifecycle"], ["start", "ready", "shutdown"])
+        self.assertEqual(
+            payload["storePath"],
+            str(Path(hermes_home) / "plugins" / "yaoyao" / "data" / "group-chat.db"),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 1570 - 0
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -0,0 +1,1570 @@
+from __future__ import annotations
+
+import importlib
+import asyncio
+import hashlib
+import json
+import os
+from pathlib import Path
+import sqlite3
+import sys
+import subprocess
+import tempfile
+import textwrap
+import unittest
+import uuid
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+if str(DASHBOARD_DIR) not in sys.path:
+    sys.path.insert(0, str(DASHBOARD_DIR))
+
+import group_plugin_api  # noqa: E402
+
+
+PROTOCOL = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_protocol")
+STORE_MODULE = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_store")
+ORCHESTRATOR = importlib.import_module(
+    f"{group_plugin_api._LOCAL_PACKAGE}.group_orchestrator"
+)
+GATEWAY = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_gateway")
+GroupStore = STORE_MODULE.GroupStore
+
+
+def request_id() -> str:
+    return str(uuid.uuid4())
+
+
+class GroupSettingsContractTests(unittest.TestCase):
+    def test_gateway_session_create_receives_agent_configuration(self) -> None:
+        captured: dict[str, object] = {}
+        adapter = GATEWAY.GroupGatewayAdapter(dispatcher=lambda *_: None)
+
+        def request(method: str, params: dict[str, object]) -> dict[str, object]:
+            captured["method"] = method
+            captured["params"] = params
+            return {
+                "session_id": "runtime-1",
+                "stored_session_id": "stored-1",
+                "running": False,
+                "info": {"profile_name": "yaoyao"},
+            }
+
+        adapter.request = request
+        try:
+            adapter.create_session(
+                "yaoyao",
+                "群聊",
+                "",
+                [],
+                {
+                    "model": "gpt-5.6-sol",
+                    "provider": "openai-codex",
+                    "reasoning_effort": "high",
+                    "fast": True,
+                },
+            )
+        finally:
+            adapter.shutdown()
+
+        self.assertEqual(captured["method"], "session.create")
+        params = captured["params"]
+        self.assertEqual(params["model"], "gpt-5.6-sol")
+        self.assertEqual(params["provider"], "openai-codex")
+        self.assertEqual(params["reasoning_effort"], "high")
+        self.assertIs(params["fast"], True)
+
+    def test_agent_configuration_round_trips_and_rotates_on_next_run(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "配置群",
+                    "cwd": "",
+                    "agents": [{
+                        "profile": "yaoyao",
+                        "model": "gpt-5.6-sol",
+                        "provider": "openai-codex",
+                        "reasoningEffort": "high",
+                        "fastMode": True,
+                    }],
+                }
+            )
+            [agent] = room["agents"]
+            self.assertEqual(agent["model"], "gpt-5.6-sol")
+            self.assertEqual(agent["provider"], "openai-codex")
+            self.assertEqual(agent["reasoningEffort"], "high")
+            self.assertIs(agent["fastMode"], True)
+
+            with store.connection() as connection:
+                connection.execute(
+                    """UPDATE group_agents
+                    SET stored_session_id = 'stored-old',
+                        last_context_message_seq = 9,
+                        session_config_json = '{}'
+                    WHERE id = ?""",
+                    (agent["id"],),
+                )
+            store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="开始",
+                mention_agent_ids=[agent["id"]],
+            )
+            claimed = store.claim_next_runnable_run()
+            configuration = {
+                "model": "gpt-5.6-sol",
+                "provider": "openai-codex",
+                "reasoning_effort": "high",
+                "fast": True,
+            }
+            self.assertTrue(
+                store.prepare_run_session_configuration(claimed["id"], configuration)
+            )
+            refreshed = store.get_room(room["id"])["agents"][0]
+            self.assertIsNone(refreshed["storedSessionId"])
+            self.assertEqual(refreshed["lastContextMessageSeq"], 0)
+            self.assertFalse(
+                store.prepare_run_session_configuration(claimed["id"], configuration)
+            )
+
+            cleared = store.update_agent(
+                room["id"],
+                agent["id"],
+                {
+                    "requestId": request_id(),
+                    "model": None,
+                    "provider": None,
+                    "reasoningEffort": None,
+                    "fastMode": None,
+                },
+            )
+            self.assertIsNone(cleared["model"])
+            self.assertIsNone(cleared["provider"])
+            self.assertIsNone(cleared["reasoningEffort"])
+            self.assertIsNone(cleared["fastMode"])
+
+        with self.assertRaisesRegex(ValueError, "model and provider"):
+            PROTOCOL.AddAgentRequest.model_validate({
+                "requestId": request_id(),
+                "profile": "broken",
+                "model": "gpt-5.6-sol",
+            })
+        with self.assertRaisesRegex(ValueError, "model and provider"):
+            PROTOCOL.UpdateAgentRequest.model_validate({
+                "requestId": request_id(),
+                "model": None,
+            })
+
+    def test_run_prompt_accepts_empty_agent_description(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "无职责描述群",
+                    "cwd": "",
+                    "agents": [{"profile": "default"}],
+                }
+            )
+            [agent] = room["agents"]
+            store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="请回复",
+                mention_agent_ids=[agent["id"]],
+            )
+            claimed = store.claim_next_runnable_run()
+            self.assertIsNotNone(claimed)
+            projection = store.read_run_projection(claimed["id"])
+
+            prompt = ORCHESTRATOR.build_run_prompt(
+                projection,
+                store.get_room(room["id"])["agents"],
+            )
+
+            envelope = json.loads(prompt.rsplit("GROUP_CONTEXT_JSON=", 1)[1])
+            self.assertEqual(envelope["agent"]["description"], "")
+
+            invalid_projection = dict(projection)
+            invalid_agent = dict(projection["agent"])
+            invalid_agent["description"] = 1
+            invalid_projection["agent"] = invalid_agent
+            with self.assertRaisesRegex(
+                ORCHESTRATOR.GroupOrchestratorError,
+                "projection.agent.description must be a string",
+            ):
+                ORCHESTRATOR.build_run_prompt(
+                    invalid_projection,
+                    store.get_room(room["id"])["agents"],
+                )
+
+    def test_protocol_v3_advertises_reply_round_contract(self) -> None:
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 3)
+        self.assertEqual(
+            PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
+        )
+        self.assertEqual(
+            PROTOCOL.limits_payload()["unlimitedReplyRoundsValue"], -1
+        )
+        self.assertEqual(
+            PROTOCOL.limits_payload()["maxAgentDisplayNameLength"], 100
+        )
+
+        create = PROTOCOL.CreateRoomRequest.model_validate(
+            {
+                "requestId": request_id(),
+                "name": "研发群",
+                "agents": [{"profile": "planner"}],
+            }
+        )
+        self.assertEqual(create.max_reply_rounds, 3)
+        self.assertIsNone(create.agents[0].display_name)
+        self.assertFalse(create.agents[0].reply_without_mention)
+        for invalid in (0, -2, 101, True, "3"):
+            with self.subTest(invalid=invalid), self.assertRaises(ValueError):
+                PROTOCOL.CreateRoomRequest.model_validate(
+                    {
+                        "requestId": request_id(),
+                        "name": "研发群",
+                        "maxReplyRounds": invalid,
+                        "agents": [{"profile": "planner"}],
+                    }
+                )
+
+    def test_reserved_all_aliases_cannot_be_agent_display_names(self) -> None:
+        model_payloads = (
+            (
+                PROTOCOL.CreateRoomRequest,
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "agents": [{"profile": "planner", "displayName": "所有人"}],
+                },
+            ),
+            (
+                PROTOCOL.AddAgentRequest,
+                {
+                    "requestId": request_id(),
+                    "profile": "planner",
+                    "displayName": " all ",
+                },
+            ),
+            (
+                PROTOCOL.UpdateAgentRequest,
+                {"requestId": request_id(), "displayName": "所有人"},
+            ),
+        )
+        for model, payload in model_payloads:
+            with self.subTest(model=model.__name__), self.assertRaisesRegex(
+                ValueError, "reserved"
+            ):
+                model.model_validate(payload)
+
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(
+                Path(directory) / "group.db",
+                agent_name_resolver=lambda _profile: "所有人",
+            )
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "agents": [{"profile": "planner"}],
+                }
+            )
+            [planner] = room["agents"]
+            self.assertEqual(planner["displayName"], "planner")
+
+            with self.assertRaisesRegex(ValueError, "reserved"):
+                store.add_agent(
+                    room["id"],
+                    {
+                        "requestId": request_id(),
+                        "profile": "coder",
+                        "displayName": "所有人",
+                    },
+                )
+            with self.assertRaisesRegex(ValueError, "reserved"):
+                store.update_agent(
+                    room["id"],
+                    planner["id"],
+                    {"requestId": request_id(), "displayName": "所有人"},
+                )
+
+    def test_agent_name_api_rejects_and_filters_reserved_all_aliases(self) -> None:
+        probe = textwrap.dedent(
+            """
+            import json
+            from pathlib import Path
+            import sys
+            import types
+
+            from fastapi import FastAPI
+            from fastapi.testclient import TestClient
+
+            dashboard = Path(sys.argv[1])
+            settings_path = Path(sys.argv[2]) / "agent_settings.json"
+            sys.path.insert(0, str(dashboard))
+            poller = types.ModuleType("poller")
+            poller.start = lambda: None
+            sys.modules["poller"] = poller
+
+            import plugin_api
+            plugin_api._store_for = lambda _profile: object()
+            plugin_api.store._agent_settings_path = lambda _profile: settings_path
+
+            app = FastAPI()
+            app.include_router(plugin_api.router)
+            with TestClient(app) as client:
+                rejected = client.put(
+                    "/agent/settings",
+                    json={"agentName": "所有人"},
+                )
+                accepted = client.put(
+                    "/agent/settings",
+                    json={"agentName": "策划"},
+                )
+                settings_path.write_text(
+                    json.dumps(
+                        {"profile": "default", "agentName": "all", "updatedAt": 1}
+                    ),
+                    encoding="utf-8",
+                )
+                loaded = client.get("/agent/settings")
+            print(json.dumps({
+                "rejectedStatus": rejected.status_code,
+                "acceptedStatus": accepted.status_code,
+                "loadedStatus": loaded.status_code,
+                "loaded": loaded.json(),
+            }, ensure_ascii=False))
+            """
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            result = subprocess.run(
+                [sys.executable, "-c", probe, str(DASHBOARD_DIR), directory],
+                check=True,
+                capture_output=True,
+                text=True,
+            )
+        payload = json.loads(result.stdout.strip().splitlines()[-1])
+        self.assertEqual(payload["rejectedStatus"], 400)
+        self.assertEqual(payload["acceptedStatus"], 200)
+        self.assertEqual(payload["loadedStatus"], 200)
+        self.assertEqual(payload["loaded"]["agentName"], "")
+
+    def test_configured_name_then_profile_fallback_and_all_mention_aliases(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(
+                Path(directory) / "group.db",
+                agent_name_resolver=lambda profile: "策划" if profile == "planner" else "",
+            )
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "planner", "description": "规划"},
+                        {"profile": "coder", "description": "开发"},
+                        {"profile": "other", "displayName": "planner"},
+                    ],
+                }
+            )
+            by_profile = {agent["profile"]: agent for agent in room["agents"]}
+            planner, coder = by_profile["planner"], by_profile["coder"]
+            self.assertEqual(planner["displayName"], "策划")
+            self.assertEqual(coder["displayName"], "coder")
+
+            with store.connection() as connection:
+                rows = connection.execute(
+                    "SELECT * FROM group_agents WHERE room_id = ? ORDER BY created_at, id",
+                    (room["id"],),
+                ).fetchall()
+            for token, expected in (
+                ("@策划", planner["id"]),
+                ("@planner", planner["id"]),
+                (f"@{planner['id']}", planner["id"]),
+                ("@coder", coder["id"]),
+            ):
+                targets, warnings = store._plan_cascade_mentions(
+                    token, rows, source_agent_id=str(uuid.uuid4())
+                )
+                self.assertEqual(targets, [expected])
+                self.assertEqual(warnings, [])
+
+    def test_initialize_syncs_only_legacy_fallback_names_once(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            legacy = GroupStore(path)
+            legacy.initialize()
+            room = legacy.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "旧群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "planner"},
+                        {"profile": "coder", "displayName": "代码员"},
+                        {"profile": "reviewer"},
+                    ],
+                }
+            )
+            before = legacy.latest_cursor()
+
+            configured_names = {
+                "planner": "策划",
+                "coder": "后端",
+                "reviewer": "代码员",
+            }
+            current = GroupStore(
+                path,
+                agent_name_resolver=lambda profile: configured_names.get(profile, ""),
+            )
+            current.initialize()
+
+            refreshed = current.get_room(room["id"])
+            names = {
+                agent["profile"]: agent["displayName"]
+                for agent in refreshed["agents"]
+            }
+            self.assertEqual(
+                names,
+                {
+                    "planner": "策划",
+                    "coder": "代码员",
+                    "reviewer": "reviewer",
+                },
+            )
+            events = current.events_after(before, 20)
+            self.assertEqual(
+                [event["eventType"] for event in events],
+                ["agent.updated", "room.updated"],
+            )
+            planner = next(
+                agent for agent in refreshed["agents"] if agent["profile"] == "planner"
+            )
+            self.assertEqual(events[0]["payload"], planner)
+            self.assertEqual(
+                events[1]["payload"],
+                current.list_rooms(limit=100, cursor=None).items[0],
+            )
+
+            after_first_initialize = current.latest_cursor()
+            current.initialize()
+            self.assertEqual(current.latest_cursor(), after_first_initialize)
+            self.assertEqual(current.get_room(room["id"]), refreshed)
+
+    def test_reserved_all_mention_precedes_profile_alias_for_human_and_cascade(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "全员群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "all", "displayName": "全能"},
+                        {"profile": "所有人", "displayName": "中文全能"},
+                        {"profile": "planner"},
+                        {"profile": "coder"},
+                    ],
+                }
+            )
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            all_ids = {agent["id"] for agent in room["agents"]}
+
+            for token in ("@all 请处理", "@所有人 请处理"):
+                with self.subTest(token=token):
+                    human_all = store.create_human_message(
+                        room["id"],
+                        request_id=request_id(),
+                        client_message_id=request_id(),
+                        content=token,
+                        mention_agent_ids=[],
+                    )
+                    self.assertEqual(
+                        {run["agentId"] for run in human_all["runs"]}, all_ids
+                    )
+                    self.assertTrue(
+                        all(
+                            run["replyMode"] == "mentioned"
+                            for run in human_all["runs"]
+                        )
+                    )
+
+            for token, expected in (
+                ("@planner", agents["planner"]["id"]),
+                (f"@{agents['all']['id']}", agents["all"]["id"]),
+            ):
+                with self.subTest(token=token):
+                    created = store.create_human_message(
+                        room["id"],
+                        request_id=request_id(),
+                        client_message_id=request_id(),
+                        content=token,
+                        mention_agent_ids=[],
+                    )
+                    self.assertEqual(
+                        [run["agentId"] for run in created["runs"]], [expected]
+                    )
+
+            for token in ("@all 继续", "@所有人 继续"):
+                with self.subTest(cascade=token):
+                    source_run_id = self._insert_completed_source(
+                        store,
+                        room["id"],
+                        agents["planner"]["id"],
+                        content=token,
+                        depth=0,
+                    )
+                    cascade = store.complete_cascade(source_run_id)
+                    self.assertEqual(
+                        {
+                            store.get_run(run_id)["agentId"]
+                            for run_id in cascade["runIds"]
+                        },
+                        all_ids - {agents["planner"]["id"]},
+                    )
+
+    def test_implicit_agent_run_is_hidden_and_explicit_wins(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "maxReplyRounds": 3,
+                    "agents": [
+                        {
+                            "profile": "planner",
+                            "replyWithoutMention": True,
+                        },
+                        {
+                            "profile": "coder",
+                            "replyWithoutMention": True,
+                        },
+                    ],
+                }
+            )
+            by_profile = {agent["profile"]: agent for agent in room["agents"]}
+            planner, coder = by_profile["planner"], by_profile["coder"]
+            created = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="请看一下",
+                mention_agent_ids=[planner["id"]],
+            )
+            modes = {run["agentId"]: run["replyMode"] for run in created["runs"]}
+            self.assertEqual(modes, {planner["id"]: "mentioned", coder["id"]: "automatic"})
+            listed = store.list_messages(room["id"])
+            self.assertEqual([message["senderId"] for message in listed], ["human", planner["id"]])
+
+    def test_v1_database_migrates_atomically_and_preserves_rows(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            self._create_v1_database(path)
+            store = GroupStore(path)
+            store.initialize()
+            self.assertEqual(store.schema_version(), 3)
+            self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
+            with store.connection() as connection:
+                room = connection.execute("SELECT * FROM group_rooms").fetchone()
+                agent = connection.execute("SELECT * FROM group_agents").fetchone()
+                run = connection.execute("SELECT * FROM group_agent_runs").fetchone()
+                message = connection.execute("SELECT * FROM group_messages").fetchone()
+                ledger = connection.execute("SELECT * FROM group_idempotency").fetchone()
+            self.assertEqual(room["max_reply_rounds"], 3)
+            self.assertEqual(agent["reply_without_mention"], 0)
+            self.assertEqual(run["reply_mode"], "mentioned")
+            self.assertEqual(message["visible"], 1)
+            self.assertEqual(ledger["response_json"], "{}")
+
+    def test_automatic_no_reply_token_is_internal(self) -> None:
+        self.assertTrue(ORCHESTRATOR._is_no_reply_content("  [[YAOYAO_NO_REPLY_V1]]\n"))
+        self.assertFalse(ORCHESTRATOR._is_no_reply_content("正常回复"))
+
+    def test_v2_schema_with_wrong_defaults_fails_closed(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            self._create_v1_database(path)
+            connection = sqlite3.connect(path)
+            connection.execute(
+                "ALTER TABLE group_rooms ADD COLUMN max_reply_rounds INTEGER NOT NULL DEFAULT 4"
+            )
+            connection.execute(
+                "ALTER TABLE group_agents ADD COLUMN reply_without_mention INTEGER NOT NULL DEFAULT 0"
+            )
+            connection.execute(
+                "ALTER TABLE group_messages ADD COLUMN visible INTEGER NOT NULL DEFAULT 1"
+            )
+            connection.execute(
+                "ALTER TABLE group_agent_runs ADD COLUMN reply_mode TEXT NOT NULL DEFAULT 'mentioned'"
+            )
+            connection.execute(
+                "UPDATE group_meta SET value='2' WHERE key='schema_version'"
+            )
+            connection.commit()
+            connection.close()
+            with self.assertRaises(STORE_MODULE.GroupStoreError):
+                GroupStore(path).initialize()
+
+    def test_v2_room_round_value_corruption_fails_closed(self) -> None:
+        self._assert_v2_value_corruption_fails_closed(
+            "UPDATE group_rooms SET max_reply_rounds = 0"
+        )
+
+    def test_v2_agent_reply_flag_corruption_fails_closed(self) -> None:
+        self._assert_v2_value_corruption_fails_closed(
+            "UPDATE group_agents SET reply_without_mention = 2"
+        )
+
+    def test_v2_message_visibility_corruption_fails_closed(self) -> None:
+        self._assert_v2_value_corruption_fails_closed(
+            "UPDATE group_messages SET visible = 2"
+        )
+
+    def test_v2_run_reply_mode_corruption_fails_closed(self) -> None:
+        self._assert_v2_value_corruption_fails_closed(
+            "UPDATE group_agent_runs SET reply_mode = 'invalid'"
+        )
+
+    def test_v1_create_and_add_ledgers_replay_after_v2_route_dump(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            self._create_v1_database(path)
+            create_id, add_id = request_id(), request_id()
+            legacy_create = {
+                "requestId": create_id,
+                "name": "旧客户端群",
+                "cwd": "",
+                "agents": [
+                    {"profile": "legacy", "displayName": None, "description": ""}
+                ],
+            }
+            connection = sqlite3.connect(path)
+            room_id = connection.execute("SELECT id FROM group_rooms").fetchone()[0]
+            legacy_add = {
+                "requestId": add_id,
+                "profile": "legacy-added",
+                "displayName": None,
+                "description": "",
+            }
+            create_response = {"replayed": "create-v1"}
+            add_response = {"replayed": "add-v1"}
+            ledgers = (
+                (create_id, "room.created", legacy_create, create_response),
+                (
+                    add_id,
+                    "agent.created",
+                    {"body": legacy_add, "roomId": room_id},
+                    add_response,
+                ),
+            )
+            for ledger_id, operation, payload, response in ledgers:
+                canonical_payload = GroupStore._canonical_json(payload)
+                connection.execute(
+                    """INSERT INTO group_idempotency
+                    (request_id, operation, request_hash, response_json, created_at)
+                    VALUES (?, ?, ?, ?, 1)""",
+                    (
+                        ledger_id,
+                        operation,
+                        hashlib.sha256(canonical_payload.encode()).hexdigest(),
+                        GroupStore._canonical_json(response),
+                    ),
+                )
+            connection.commit()
+            connection.close()
+
+            store = GroupStore(path)
+            store.initialize()
+            implicit_create = PROTOCOL.CreateRoomRequest.model_validate(legacy_create)
+            implicit_add = PROTOCOL.AddAgentRequest.model_validate(legacy_add)
+            create_command = group_plugin_api._create_room_command(implicit_create)
+            add_command = group_plugin_api._add_agent_command(implicit_add)
+            self.assertNotIn("maxReplyRounds", create_command)
+            self.assertNotIn("replyWithoutMention", create_command["agents"][0])
+            self.assertNotIn("replyWithoutMention", add_command)
+            self.assertEqual(store.create_room(create_command), create_response)
+            self.assertEqual(store.add_agent(room_id, add_command), add_response)
+
+            explicit_create = PROTOCOL.CreateRoomRequest.model_validate(
+                {**legacy_create, "requestId": request_id(), "maxReplyRounds": 3,
+                 "agents": [{**legacy_create["agents"][0], "replyWithoutMention": False}]}
+            )
+            explicit_add = PROTOCOL.AddAgentRequest.model_validate(
+                {**legacy_add, "requestId": request_id(), "replyWithoutMention": False}
+            )
+            self.assertEqual(
+                group_plugin_api._create_room_command(explicit_create)["maxReplyRounds"],
+                3,
+            )
+            self.assertFalse(
+                group_plugin_api._create_room_command(explicit_create)["agents"][0][
+                    "replyWithoutMention"
+                ]
+            )
+            self.assertFalse(
+                group_plugin_api._add_agent_command(explicit_add)["replyWithoutMention"]
+            )
+
+    def test_plugin_loader_injects_existing_agent_name_setting(self) -> None:
+        probe = textwrap.dedent(
+            """
+            import json
+            from pathlib import Path
+            import sys
+            import types
+
+            dashboard = Path(sys.argv[1])
+            sys.path.insert(0, str(dashboard))
+            poller = types.ModuleType("poller")
+            poller.start = lambda: None
+            sys.modules["poller"] = poller
+            import plugin_api
+            plugin_api.store.load_agent_settings = lambda profile: {
+                "profile": profile,
+                "agentName": "现有名称",
+                "updatedAt": 1,
+            }
+            group = plugin_api.group_plugin_api
+            group.set_store_for_testing(None)
+            instance = group._store_instance()
+            instance.initialize()
+            room = instance.create_room({
+                "requestId": "11111111-1111-4111-8111-111111111111",
+                "name": "群",
+                "cwd": "",
+                "agents": [{"profile": "default"}],
+            })
+            print(json.dumps(room, ensure_ascii=False))
+            """
+        )
+        with tempfile.TemporaryDirectory() as hermes_home:
+            environment = dict(os.environ)
+            environment["HERMES_HOME"] = hermes_home
+            result = subprocess.run(
+                [sys.executable, "-c", probe, str(DASHBOARD_DIR)],
+                check=True,
+                capture_output=True,
+                text=True,
+                env=environment,
+            )
+        room = json.loads(result.stdout.strip().splitlines()[-1])
+        self.assertEqual(room["agents"][0]["displayName"], "现有名称")
+
+    def test_cascade_freezes_reply_modes_across_rename_and_setting_change(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "source"},
+                        {"profile": "reviewer", "replyWithoutMention": True},
+                        {"profile": "coder", "displayName": "开发"},
+                    ],
+                }
+            )
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            source_run_id = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["source"]["id"],
+                content="@开发 请继续",
+                depth=0,
+            )
+            store.update_agent(
+                room["id"],
+                agents["coder"]["id"],
+                {"requestId": request_id(), "displayName": "新开发"},
+            )
+            store.update_agent(
+                room["id"],
+                agents["reviewer"]["id"],
+                {"requestId": request_id(), "replyWithoutMention": False},
+            )
+            result = store.complete_cascade(source_run_id)
+            modes = {
+                store.get_run(run_id)["agentId"]: store.get_run(run_id)["replyMode"]
+                for run_id in result["runIds"]
+            }
+            self.assertEqual(
+                modes,
+                {
+                    agents["coder"]["id"]: "mentioned",
+                    agents["reviewer"]["id"]: "automatic",
+                },
+            )
+
+    def test_round_limit_and_unlimited_mode(self) -> None:
+        for max_rounds, depth, expected in ((3, 2, 0), (-1, 20, 1)):
+            with self.subTest(max_rounds=max_rounds), tempfile.TemporaryDirectory() as directory:
+                store = GroupStore(Path(directory) / "group.db")
+                store.initialize()
+                room = store.create_room(
+                    {
+                        "requestId": request_id(),
+                        "name": "研发群",
+                        "cwd": "",
+                        "maxReplyRounds": max_rounds,
+                        "agents": [{"profile": "source"}, {"profile": "target"}],
+                    }
+                )
+                agents = {agent["profile"]: agent for agent in room["agents"]}
+                source_run_id = self._insert_completed_source(
+                    store,
+                    room["id"],
+                    agents["source"]["id"],
+                    content="@target",
+                    depth=depth,
+                )
+                result = store.complete_cascade(source_run_id)
+                self.assertEqual(result["runCount"], expected)
+
+    def test_legacy_v1_pending_cascade_replays_after_schema_migration(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "agents": [{"profile": "source"}, {"profile": "target"}],
+                }
+            )
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            source_run_id = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["source"]["id"],
+                content="@target",
+                depth=0,
+            )
+            with store.write_transaction() as connection:
+                ledger = connection.execute(
+                    "SELECT response_json FROM group_idempotency WHERE request_id = ?",
+                    (store._cascade_request_id(source_run_id),),
+                ).fetchone()
+                current = json.loads(ledger["response_json"])
+                legacy = {
+                    "parseVersion": 1,
+                    "sourceMessageId": current["sourceMessageId"],
+                    "sourceRunId": source_run_id,
+                    "state": "pending",
+                    "targetAgentIds": [agents["target"]["id"]],
+                    "version": 1,
+                    "warnings": [],
+                }
+                connection.execute(
+                    """UPDATE group_idempotency SET request_hash = ?, response_json = ?
+                    WHERE request_id = ?""",
+                    (
+                        store._cascade_request_hash(source_run_id, parse_version=1),
+                        store._canonical_json(legacy),
+                        store._cascade_request_id(source_run_id),
+                    ),
+                )
+            first = store.complete_cascade(source_run_id)
+            second = store.complete_cascade(source_run_id)
+            self.assertEqual(first, second)
+            self.assertEqual(first["runCount"], 1)
+            self.assertEqual(store.get_run(first["runIds"][0])["replyMode"], "mentioned")
+
+    def test_automatic_no_reply_buffers_and_never_publishes(self) -> None:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(self, _run_id: str, **kwargs: object) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+        async def exercise() -> list[dict[str, object]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+            )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.delta",
+                    payload={"text": "[[YAOYAO_"},
+                ),
+            )
+            self.assertEqual(store.upserts, [])
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={"status": "complete", "text": "[[YAOYAO_NO_REPLY_V1]]"},
+                ),
+            )
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return store.upserts
+
+        [upsert] = asyncio.run(exercise())
+        self.assertEqual(upsert["content"], "")
+        self.assertEqual(upsert["reasoning"], "")
+        self.assertEqual(upsert["tool_state"], [])
+        self.assertEqual(upsert["status"], "completed")
+        self.assertFalse(upsert["publish"])
+
+    def test_automatic_promotion_never_publishes_no_reply_marker(self) -> None:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(
+                self, _run_id: str, **kwargs: object
+            ) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+            def create_gateway_interaction(
+                self, _run_id: str, **_kwargs: object
+            ) -> dict[str, object]:
+                return {"id": str(uuid.uuid4())}
+
+        async def exercise(
+            content: str,
+            reasoning: str,
+            event_type: str,
+            payload: dict[str, object],
+        ) -> list[dict[str, object]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+                content=content,
+                reasoning=reasoning,
+                stream_dirty=True,
+            )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type=event_type,
+                    payload=payload,
+                ),
+            )
+            return store.upserts
+
+        promotions = (
+            ("tool.start", {"tool_id": "tool-1", "name": "检索"}),
+            ("approval.request", {"choices": ["once", "deny"]}),
+            ("clarify.request", {"request_id": "clarify-1", "question": "请确认"}),
+        )
+        marker_cases = (
+            ("[[YAOYAO_NO_REPLY_V1]]", ""),
+            ("正文[[YAOYAO_NO_REPLY_V1]]尾", "正文尾"),
+            ("正文[[YAOYAO_", "正文"),
+            ("正文[[YAOYAO_尾", "正文尾"),
+        )
+        for event_type, payload in promotions:
+            for buffered, expected in marker_cases:
+                with self.subTest(event_type=event_type, buffered=buffered):
+                    upserts = asyncio.run(exercise(buffered, "", event_type, payload))
+                    self.assertTrue(upserts)
+                    self.assertTrue(
+                        all("[[YAOYAO" not in item["content"] for item in upserts)
+                    )
+                    self.assertTrue(
+                        all(item["content"].strip() == expected for item in upserts)
+                    )
+
+            for buffered, expected in marker_cases:
+                reasoning = buffered.replace("正文", "推理")
+                expected_reasoning = expected.replace("正文", "推理")
+                with self.subTest(event_type=event_type, reasoning=reasoning):
+                    upserts = asyncio.run(
+                        exercise("正常正文", reasoning, event_type, payload)
+                    )
+                    self.assertTrue(upserts)
+                    self.assertTrue(
+                        all("[[YAOYAO" not in item["reasoning"] for item in upserts)
+                    )
+                    self.assertTrue(
+                        all(
+                            item["reasoning"].strip() == expected_reasoning
+                            for item in upserts
+                        )
+                    )
+
+            normal = asyncio.run(exercise("正常正文", "正常推理", event_type, payload))
+            self.assertTrue(normal)
+            self.assertTrue(all(item["content"] == "正常正文" for item in normal))
+            self.assertTrue(all(item["reasoning"] == "正常推理" for item in normal))
+            self.assertTrue(all(item["publish"] for item in normal))
+
+    def test_automatic_terminal_never_publishes_reasoning_marker(self) -> None:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(
+                self, _run_id: str, **kwargs: object
+            ) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+        async def exercise(
+            status: str, reasoning: str
+        ) -> list[dict[str, object]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+                automatic_published=status != "complete",
+                content="正常正文",
+                reasoning=reasoning,
+                stream_dirty=True,
+            )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={
+                        "status": status,
+                        "text": "正常正文",
+                        "reasoning": reasoning,
+                        "error": "失败",
+                    },
+                ),
+            )
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return store.upserts
+
+        marker_cases = (
+            ("[[YAOYAO_NO_REPLY_V1]]", ""),
+            ("推理[[YAOYAO_NO_REPLY_V1]]尾", "推理尾"),
+            ("推理[[YAOYAO_", "推理"),
+            ("推理[[YAOYAO_尾", "推理尾"),
+        )
+        for status in ("complete", "error", "interrupted"):
+            for reasoning, expected in marker_cases:
+                with self.subTest(status=status, reasoning=reasoning):
+                    upserts = asyncio.run(exercise(status, reasoning))
+                    self.assertTrue(upserts)
+                    self.assertTrue(
+                        all("[[YAOYAO" not in item["reasoning"] for item in upserts)
+                    )
+                    self.assertTrue(
+                        all(item["reasoning"].strip() == expected for item in upserts)
+                    )
+
+    def test_mentioned_reply_preserves_no_reply_text_verbatim(self) -> None:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(
+                self, _run_id: str, **kwargs: object
+            ) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+        async def exercise() -> list[dict[str, object]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            content = "正文[[YAOYAO_尾"
+            reasoning = "推理[[YAOYAO_NO_REPLY_V1]]尾"
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="mentioned",
+            )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={
+                        "status": "complete",
+                        "text": content,
+                        "reasoning": reasoning,
+                    },
+                ),
+            )
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return store.upserts
+
+        upserts = asyncio.run(exercise())
+        self.assertTrue(upserts)
+        self.assertEqual(upserts[-1]["content"], "正文[[YAOYAO_尾")
+        self.assertEqual(upserts[-1]["reasoning"], "推理[[YAOYAO_NO_REPLY_V1]]尾")
+
+    def test_unpublished_automatic_failure_discards_buffer_without_message_event(self) -> None:
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(
+                self, _run_id: str, **kwargs: object
+            ) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+        async def exercise(status: str, content: str) -> tuple[object, list[dict[str, object]]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+                content=content,
+                reasoning="内部推理",
+                tool_state=[{"id": "internal", "status": "running"}],
+                stream_dirty=True,
+            )
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={"status": status, "text": content, "error": "失败"},
+                ),
+            )
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return state, store.upserts
+
+        for status in ("error", "interrupted"):
+            for content in ("未完成正文", "正文[[YAOYAO_NO_REPLY_V1]]尾", "[[YAOYAO_"):
+                with self.subTest(status=status, content=content):
+                    state, upserts = asyncio.run(exercise(status, content))
+                    self.assertFalse(state.automatic_published)
+                    self.assertEqual(len(upserts), 1)
+                    self.assertEqual(upserts[0]["content"], "")
+                    self.assertEqual(upserts[0]["reasoning"], "")
+                    self.assertEqual(upserts[0]["tool_state"], [])
+                    self.assertFalse(upserts[0]["publish"])
+
+    def test_hidden_queued_message_blocks_context_watermark_until_terminal(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "乱序群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "slow", "replyWithoutMention": True},
+                        {"profile": "fast", "replyWithoutMention": True},
+                        {"profile": "observer"},
+                    ],
+                }
+            )
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            first = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="并行开始",
+                mention_agent_ids=[],
+            )
+            self.assertEqual(len(first["runs"]), 2)
+            with store.connection() as connection:
+                connection.execute(
+                    "UPDATE group_agents SET reply_without_mention = 0 WHERE room_id = ?",
+                    (room["id"],),
+                )
+                response_seqs = {
+                    run["id"]: int(
+                        connection.execute(
+                            "SELECT seq FROM group_messages WHERE id = ?",
+                            (run["responseMessageId"],),
+                        ).fetchone()["seq"]
+                    )
+                    for run in first["runs"]
+                }
+            slow_id, fast_id = sorted(response_seqs, key=response_seqs.get)
+            claimed = {
+                run["id"]: run
+                for _ in range(2)
+                if (run := store.claim_next_runnable_run()) is not None
+            }
+            self.assertEqual(set(claimed), {slow_id, fast_id})
+
+            def bind(run_id: str, label: str, expected: str | None = None) -> tuple[str, str]:
+                stored, runtime = f"{label}-stored", f"{label}-runtime"
+                store.bind_run_session(
+                    run_id,
+                    expected_stored_session_id=expected,
+                    stored_session_id=stored,
+                    runtime_session_id=runtime,
+                )
+                return stored, runtime
+
+            slow_stored, slow_runtime = bind(slow_id, "slow")
+            fast_stored, fast_runtime = bind(fast_id, "fast")
+            store.upsert_agent_message(
+                fast_id,
+                content="先到正文",
+                reasoning="",
+                tool_state=[],
+                status="completed",
+                publish=True,
+            )
+            store.settle_run(
+                fast_id,
+                runtime_session_id=fast_runtime,
+                expected_stored_session_id=fast_stored,
+                stored_session_id=fast_stored,
+                outcome="completed",
+            )
+
+            observer_first = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="第一次检查",
+                mention_agent_ids=[agents["observer"]["id"]],
+            )["runs"][0]
+            claimed_observer = store.claim_next_runnable_run()
+            self.assertEqual(claimed_observer["id"], observer_first["id"])
+            observer_stored, observer_runtime = bind(observer_first["id"], "observer-1")
+            projection = store.read_run_projection(observer_first["id"])
+            self.assertEqual(projection["throughSeq"], 1)
+            store.advance_run_context(
+                observer_first["id"],
+                runtime_session_id=observer_runtime,
+                expected_context_seq=0,
+                through_seq=projection["throughSeq"],
+            )
+            store.settle_run(
+                observer_first["id"],
+                runtime_session_id=observer_runtime,
+                expected_stored_session_id=observer_stored,
+                stored_session_id=observer_stored,
+                outcome="failed",
+                error="测试结束",
+            )
+
+            store.upsert_agent_message(
+                slow_id,
+                content="迟到正文",
+                reasoning="",
+                tool_state=[],
+                status="completed",
+                publish=True,
+            )
+            store.settle_run(
+                slow_id,
+                runtime_session_id=slow_runtime,
+                expected_stored_session_id=slow_stored,
+                stored_session_id=slow_stored,
+                outcome="completed",
+            )
+
+            observer_second = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="第二次检查",
+                mention_agent_ids=[agents["observer"]["id"]],
+            )["runs"][0]
+            claimed_observer = store.claim_next_runnable_run()
+            self.assertEqual(claimed_observer["id"], observer_second["id"])
+            bind(
+                observer_second["id"],
+                "observer-2",
+                expected=observer_stored,
+            )
+            future = store.read_run_projection(observer_second["id"])
+            self.assertIn("迟到正文", [message["content"] for message in future["messages"]])
+
+    def test_hidden_automatic_failure_emits_run_only_and_replays_exactly(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "研发群",
+                    "cwd": "",
+                    "agents": [
+                        {"profile": "reviewer", "replyWithoutMention": True}
+                    ],
+                }
+            )
+            created = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="普通消息",
+                mention_agent_ids=[],
+            )
+            [run] = created["runs"]
+            before = store.latest_cursor()
+            first = store.transition_run(run["id"], "failed", error="失败")
+            second = store.transition_run(run["id"], "failed", error="失败")
+            self.assertEqual(first, second)
+            self.assertEqual(first["replyMode"], "automatic")
+            self.assertNotIn("bufferedResponse", first)
+            events = store.events_after(before, 20)
+            self.assertTrue(
+                any(event["eventType"] == "run.updated" for event in events)
+            )
+            self.assertFalse(
+                any(event["eventType"] == "message.upsert" for event in events)
+            )
+            self.assertEqual(
+                [message["senderKind"] for message in store.list_messages(room["id"])],
+                ["human"],
+            )
+
+    def test_automatic_consumer_waits_without_zero_timeout_and_accepts_terminal(self) -> None:
+        class CountingQueue(asyncio.Queue):
+            def __init__(self) -> None:
+                super().__init__()
+                self.get_calls = 0
+
+            async def get(self):  # type: ignore[override]
+                self.get_calls += 1
+                return await super().get()
+
+        class FakeStore:
+            def __init__(self) -> None:
+                self.upserts: list[dict[str, object]] = []
+
+            def upsert_agent_message(self, _run_id: str, **kwargs: object) -> dict[str, object]:
+                self.upserts.append(dict(kwargs))
+                return dict(kwargs)
+
+        async def exercise() -> tuple[int, list[dict[str, object]]]:
+            store = FakeStore()
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(store)
+            queue = CountingQueue()
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=str(uuid.uuid4()),
+                room_id=str(uuid.uuid4()),
+                agent_id=str(uuid.uuid4()),
+                runtime_id="runtime",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored",
+                expected_context_seq=0,
+                through_seq=0,
+                reply_mode="automatic",
+                pending_events=queue,
+                prompt_committed=True,
+            )
+            orchestrator._runtime_states[state.runtime_id] = (state.generation, state)
+            state.prompt_ready.set()
+            task = asyncio.create_task(orchestrator._consume_runtime_events(state))
+            await queue.put(
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.delta",
+                    payload={"text": "正常"},
+                )
+            )
+            await asyncio.sleep(0.12)
+            calls_after_idle = queue.get_calls
+            await queue.put(
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id="runtime",
+                    generation=1,
+                    event_type="message.complete",
+                    payload={"status": "complete", "text": "正常回复"},
+                )
+            )
+            for _ in range(100):
+                if store.upserts:
+                    break
+                await asyncio.sleep(0.002)
+            state.finished.set()
+            await queue.put(None)
+            await asyncio.wait_for(task, timeout=1)
+            if state.grace_task is not None:
+                state.grace_task.cancel()
+                await asyncio.gather(state.grace_task, return_exceptions=True)
+            return calls_after_idle, store.upserts
+
+        calls, upserts = asyncio.run(exercise())
+        self.assertLessEqual(calls, 4)
+        self.assertEqual(upserts[-1]["content"], "正常回复")
+        self.assertTrue(upserts[-1]["publish"])
+
+    @staticmethod
+    def _insert_completed_source(
+        store: GroupStore,
+        room_id: str,
+        agent_id: str,
+        *,
+        content: str,
+        depth: int,
+    ) -> str:
+        message_id, run_id = str(uuid.uuid4()), str(uuid.uuid4())
+        with store.write_transaction() as connection:
+            now = 10.0
+            connection.execute(
+                """INSERT INTO group_messages
+                (id, room_id, sender_kind, sender_id, sender_name, root_message_id,
+                 reply_to_message_id, client_message_id, content, reasoning,
+                 tool_state_json, status, error, visible, created_at, updated_at)
+                VALUES (?, ?, 'agent', ?, 'source', ?, NULL, NULL, ?, '', '[]',
+                        'completed', '', 1, ?, ?)""",
+                (message_id, room_id, agent_id, message_id, content, now, now),
+            )
+            connection.execute(
+                """INSERT INTO group_agent_runs
+                (id, room_id, agent_id, trigger_message_id, response_message_id,
+                 root_message_id, depth, reply_mode, status, runtime_session_id,
+                 error, created_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, 'mentioned', 'completed', NULL, '', ?, ?)""",
+                (
+                    run_id,
+                    room_id,
+                    agent_id,
+                    message_id,
+                    message_id,
+                    message_id,
+                    depth,
+                    now,
+                    now,
+                ),
+            )
+            store._record_cascade_plan(
+                connection,
+                source=store._run_row(connection, run_id),
+                source_message=store._message_row(connection, message_id),
+                created_at=now,
+            )
+        return run_id
+
+    def _assert_v2_value_corruption_fails_closed(self, mutation: str) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            store = GroupStore(path)
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "值域门禁",
+                    "cwd": "",
+                    "agents": [{"profile": "default"}],
+                }
+            )
+            [agent] = room["agents"]
+            store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="触发",
+                mention_agent_ids=[agent["id"]],
+            )
+            with store.connection() as connection:
+                connection.execute(mutation)
+            with self.assertRaises(STORE_MODULE.GroupStoreError):
+                GroupStore(path).initialize()
+
+    @staticmethod
+    def _create_v1_database(path: Path) -> None:
+        connection = sqlite3.connect(path)
+        statements = (
+            "CREATE TABLE group_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
+            "CREATE TABLE group_rooms (id TEXT PRIMARY KEY, name TEXT NOT NULL, cwd TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL, archived INTEGER NOT NULL DEFAULT 0)",
+            "CREATE TABLE group_agents (id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE, profile TEXT NOT NULL, display_name TEXT NOT NULL, display_name_key TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', stored_session_id TEXT, last_context_message_seq INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at REAL NOT NULL, updated_at REAL NOT NULL, UNIQUE(room_id, profile), UNIQUE(room_id, display_name_key))",
+            "CREATE TABLE group_messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE, sender_kind TEXT NOT NULL, sender_id TEXT NOT NULL, sender_name TEXT NOT NULL, root_message_id TEXT NOT NULL, reply_to_message_id TEXT, client_message_id TEXT UNIQUE, content TEXT NOT NULL DEFAULT '', reasoning TEXT NOT NULL DEFAULT '', tool_state_json TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL, error TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL)",
+            "CREATE TABLE group_agent_runs (id TEXT PRIMARY KEY, room_id TEXT NOT NULL, agent_id TEXT NOT NULL, trigger_message_id TEXT NOT NULL, response_message_id TEXT NOT NULL, root_message_id TEXT NOT NULL, depth INTEGER NOT NULL, status TEXT NOT NULL, runtime_session_id TEXT, error TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL)",
+            "CREATE TABLE group_interactions (id TEXT PRIMARY KEY, room_id TEXT NOT NULL, agent_id TEXT NOT NULL, run_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, status TEXT NOT NULL, created_at REAL NOT NULL, resolved_at REAL)",
+            "CREATE TABLE group_events (cursor INTEGER PRIMARY KEY AUTOINCREMENT, epoch TEXT NOT NULL, room_id TEXT, event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at REAL NOT NULL)",
+            "CREATE TABLE group_idempotency (request_id TEXT PRIMARY KEY, operation TEXT NOT NULL, request_hash TEXT NOT NULL, response_json TEXT NOT NULL, created_at REAL NOT NULL)",
+        )
+        for statement in statements:
+            connection.execute(statement)
+        room_id, agent_id, message_id, run_id = (str(uuid.uuid4()) for _ in range(4))
+        connection.executemany(
+            "INSERT INTO group_meta(key,value) VALUES (?,?)",
+            (("schema_version", "1"), ("journal_epoch", "11111111-1111-4111-8111-111111111111")),
+        )
+        connection.execute("INSERT INTO group_rooms VALUES (?,?,?,?,?,?)", (room_id, "旧群", "", 1.0, 1.0, 0))
+        connection.execute("INSERT INTO group_agents VALUES (?,?,?,?,?,?,?,?,?,?,?)", (agent_id, room_id, "default", "旧名", "旧名", "", None, 0, 1, 1.0, 1.0))
+        connection.execute("INSERT INTO group_messages VALUES (NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (message_id, room_id, "human", "human", "你", message_id, None, str(uuid.uuid4()), "旧消息", "", "[]", "completed", "", 1.0, 1.0))
+        connection.execute("INSERT INTO group_agent_runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, room_id, agent_id, message_id, message_id, message_id, 0, "completed", None, "", 1.0, 1.0))
+        connection.execute("INSERT INTO group_idempotency VALUES (?,?,?,?,?)", (str(uuid.uuid4()), "test", "hash", json.dumps({}), 1.0))
+        connection.commit()
+        connection.close()
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 104 - 0
hermes-plugins/yaoyao/dashboard/tests/test_poller_contract.py

@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import json
+import sqlite3
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+DASHBOARD_DIR = Path(__file__).resolve().parents[1]
+if str(DASHBOARD_DIR) not in sys.path:
+    sys.path.insert(0, str(DASHBOARD_DIR))
+
+import poller  # noqa: E402
+import store  # noqa: E402
+
+
+class PollerContractTests(unittest.TestCase):
+    def test_scans_file_references_from_every_session(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            profile_home = root / "profile"
+            profile_home.mkdir()
+            files = root / "files"
+            files.mkdir()
+            first = files / "first.docx"
+            second = files / "second.xlsx"
+            spaced = files / "third file.md"
+            generated = files / "generated.txt"
+            for index, path in enumerate((first, second, spaced, generated), 1):
+                path.write_text(f"file {index}", encoding="utf-8")
+
+            conn = sqlite3.connect(profile_home / "state.db")
+            conn.execute(
+                "CREATE TABLE messages ("
+                "id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, "
+                "content TEXT, tool_name TEXT)"
+            )
+            conn.executemany(
+                "INSERT INTO messages(id, session_id, role, content, tool_name) "
+                "VALUES(?,?,?,?,?)",
+                (
+                    (1, "session-a", "assistant", f"MEDIA:{first}", None),
+                    (
+                        2,
+                        "session-b",
+                        "user",
+                        'GROUP_CONTEXT_JSON={"messages":[{"content":"'
+                        f"MEDIA:{second}"
+                        '","reasoning":"done"}]}',
+                        None,
+                    ),
+                    (3, "session-c", "user", f'MEDIA:"{spaced}"', None),
+                    (
+                        4,
+                        "session-d",
+                        "tool",
+                        json.dumps({"resolved_path": str(generated)}),
+                        "write_file",
+                    ),
+                ),
+            )
+            conn.commit()
+            conn.close()
+
+            watcher = poller._ProfileWatcher("contract-test", profile_home)
+            watcher.data_root = root / "library"
+            watcher.store = store.Store(watcher.data_root)
+
+            with mock.patch.object(poller, "BATCH_LIMIT", 2):
+                for _ in range(3):
+                    poller._poll_profile(watcher)
+
+            items, _, _ = watcher.store.query_attachments(limit=20)
+            self.assertEqual(
+                {item["sessionId"] for item in items},
+                {"session-a", "session-b", "session-c", "session-d"},
+            )
+            self.assertEqual(
+                {item["displayName"] for item in items},
+                {path.name for path in (first, second, spaced, generated)},
+            )
+            watcher.close()
+
+    def test_scanner_version_replays_history_only_once(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            archive = store.Store(Path(directory) / "library")
+            archive.mark_processed(42)
+
+            self.assertTrue(archive.prepare_scanner_version(7))
+            self.assertEqual(archive.processed_waterline(), 0)
+
+            archive.mark_processed(84)
+            self.assertFalse(archive.prepare_scanner_version(7))
+            self.assertEqual(archive.processed_waterline(), 84)
+
+            self.assertTrue(archive.prepare_scanner_version(8))
+            self.assertEqual(archive.processed_waterline(), 0)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 486 - 0
hermes-plugins/yaoyao/dashboard/voice_store.py

@@ -0,0 +1,486 @@
+"""TTS + STT provider settings store for the yaoyao plugin.
+
+Mirrors yaoyao-webui's tts-settings-store.ts and stt-settings-store.ts:
+- Multiple providers per profile, each with settings (baseUrl/model/voice/...)
+  + secrets (apiKey).
+- An "active provider" selection.
+- baseUrl presets (recently used base URLs per provider).
+- Secrets are masked as "[stored]" in public responses.
+
+Storage: a single JSON file at data/voice_providers.json (not SQLite - the data
+is small and write frequency is low). The structure matches yaoyao-webui's
+DB rows so the data is interchangeable.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import logging
+import os
+import threading
+import time
+from typing import Any, Optional
+
+from store import DATA_ROOT, _ensure_dirs
+
+log = logging.getLogger("yaoyao.voice_store")
+
+_VOICE_PROVIDERS_FILE = DATA_ROOT / "voice_providers.json"
+_VOICE_LOCK = threading.Lock()
+
+STORED_MARKER = "[stored]"
+MAX_TEXT_LENGTH = 2000
+MAX_BASE_URL_PRESETS = 20
+MAX_PROMPT_LENGTH = 1000
+MAX_API_KEY_LENGTH = 4000
+
+# ---------------------------------------------------------------------------
+# Provider registries
+# ---------------------------------------------------------------------------
+
+TTS_PROVIDERS = ["edge", "openai", "custom", "mimo", "doubao"]
+TTS_PROVIDER_LABELS = {
+    "edge": "Edge TTS",
+    "openai": "OpenAI TTS",
+    "custom": "Custom TTS",
+    "mimo": "MiMo TTS",
+    "doubao": "Doubao TTS",
+}
+TTS_SETTINGS_KEYS = [
+    "baseUrl", "baseUrlPresets", "model", "voice", "rate", "pitch",
+    "authMode", "voiceMode", "voiceDesignDesc", "voiceCloneFormat", "stylePrompt",
+]
+TTS_SECRET_KEYS = ["apiKey"]
+
+STT_STORED_PROVIDERS = ["openai", "custom", "doubao"]
+STT_ACTIVE_PROVIDERS = ["browser"] + STT_STORED_PROVIDERS
+STT_PROVIDER_LABELS = {
+    "openai": "OpenAI STT",
+    "custom": "Custom STT",
+    "doubao": "Doubao STT",
+    "browser": "Browser STT",
+}
+STT_SETTINGS_KEYS = ["baseUrl", "baseUrlPresets", "model", "language", "prompt", "audioTranscode"]
+STT_SECRET_KEYS = ["apiKey"]
+
+# Default provider configurations (used when no saved config exists).
+_DEFAULT_TTS_PROVIDER_CONFIGS: dict[str, dict[str, Any]] = {
+    "edge": {"voice": "zh-CN-XiaoxiaoNeural", "rate": "+0%", "pitch": "+0Hz"},
+    "openai": {"baseUrl": "https://api.openai.com/v1", "model": "tts-1", "voice": "alloy"},
+    "custom": {"baseUrl": "", "model": "tts-1", "voice": "alloy"},
+    "mimo": {"baseUrl": "", "model": "mimo-v2.5-tts", "voice": "冰糖"},
+    "doubao": {"baseUrl": "https://openspeech.bytedance.com/api/v3/tts/unidirectional",
+               "model": "seed-tts-2.0", "voice": "zh_female_xiaohe_uranus_bigtts"},
+}
+
+_DEFAULT_STT_PROVIDER_CONFIGS: dict[str, dict[str, Any]] = {
+    "openai": {"baseUrl": "https://api.openai.com/v1", "model": "whisper-1", "language": ""},
+    "custom": {"baseUrl": "", "model": "whisper-1", "language": ""},
+    "doubao": {"baseUrl": "", "model": "", "language": ""},
+}
+
+# ---------------------------------------------------------------------------
+# File-level read / write
+# ---------------------------------------------------------------------------
+
+def _default_data() -> dict[str, Any]:
+    return {
+        "tts": {
+            "activeProvider": "edge",
+            "providers": {},
+        },
+        "stt": {
+            "activeProvider": "browser",
+            "providers": {},
+        },
+    }
+
+
+def _load_all() -> dict[str, Any]:
+    data = _default_data()
+    try:
+        if _VOICE_PROVIDERS_FILE.is_file():
+            saved = json.loads(_VOICE_PROVIDERS_FILE.read_text(encoding="utf-8"))
+            if isinstance(saved, dict):
+                _deep_merge(data, saved)
+    except Exception as e:
+        log.warning("voice providers load failed: %s", e)
+    return data
+
+
+def _save_all(data: dict[str, Any]) -> None:
+    _ensure_dirs()
+    with _VOICE_LOCK:
+        _VOICE_PROVIDERS_FILE.write_text(
+            json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
+        )
+
+
+def _deep_merge(base: dict, overlay: dict) -> None:
+    for k, v in overlay.items():
+        if k in base and isinstance(base[k], dict) and isinstance(v, dict):
+            _deep_merge(base[k], v)
+        else:
+            base[k] = v
+
+
+# ---------------------------------------------------------------------------
+# Settings / secrets sanitization
+# ---------------------------------------------------------------------------
+
+def _sanitize_settings(settings: Any, allowed_keys: list[str]) -> dict[str, Any]:
+    if not isinstance(settings, dict):
+        return {}
+    out: dict[str, Any] = {}
+    for k in allowed_keys:
+        if k not in settings:
+            continue
+        v = settings[k]
+        if k == "baseUrlPresets":
+            if isinstance(v, list):
+                presets: list[str] = []
+                seen: set[str] = set()
+                for url in v:
+                    if isinstance(url, str) and url not in seen:
+                        presets.append(url)
+                        seen.add(url)
+                    if len(presets) >= MAX_BASE_URL_PRESETS:
+                        break
+                out[k] = presets
+            continue
+        if not isinstance(v, str):
+            continue
+        max_len = MAX_PROMPT_LENGTH if k == "prompt" else MAX_TEXT_LENGTH
+        v = v.strip()
+        if v and len(v) <= max_len:
+            out[k] = v
+    return out
+
+
+def _sanitize_secrets(secrets: Any, allowed_keys: list[str]) -> dict[str, str]:
+    if not isinstance(secrets, dict):
+        return {}
+    out: dict[str, str] = {}
+    for k in allowed_keys:
+        v = secrets.get(k)
+        if isinstance(v, str):
+            v = v.strip()
+            if v and v != STORED_MARKER and len(v) <= MAX_API_KEY_LENGTH:
+                out[k] = v
+    return out
+
+
+def _mask_secrets(secrets: dict[str, str]) -> dict[str, str]:
+    return {k: (STORED_MARKER if v else "") for k, v in secrets.items()}
+
+
+def _merge_settings(existing: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]:
+    merged = dict(existing)
+    for k, v in new.items():
+        if k == "baseUrlPresets":
+            presets = list(merged.get("baseUrlPresets", []))
+            if isinstance(v, list):
+                for url in v:
+                    if isinstance(url, str) and url not in presets:
+                        presets.insert(0, url)
+                    if len(presets) >= MAX_BASE_URL_PRESETS:
+                        break
+            merged["baseUrlPresets"] = presets[:MAX_BASE_URL_PRESETS]
+        else:
+            merged[k] = v
+    # If baseUrl is set and not in presets, add it.
+    base_url = merged.get("baseUrl", "")
+    if base_url and base_url not in merged.get("baseUrlPresets", []):
+        presets = merged.get("baseUrlPresets", [])
+        presets.insert(0, base_url)
+        merged["baseUrlPresets"] = presets[:MAX_BASE_URL_PRESETS]
+    return merged
+
+
+def _merge_secrets(existing: dict[str, str], new: dict[str, str]) -> dict[str, str]:
+    merged = dict(existing)
+    for k, v in new.items():
+        merged[k] = v
+    return merged
+
+
+# ---------------------------------------------------------------------------
+# Provider row helpers
+# ---------------------------------------------------------------------------
+
+def _provider_row(kind: str, provider: str, data: dict, include_secrets: bool) -> dict[str, Any]:
+    """Build a provider row for the API response."""
+    section = data[kind]
+    providers = section.get("providers", {})
+    stored = providers.get(provider, {})
+    defaults = _DEFAULT_TTS_PROVIDER_CONFIGS if kind == "tts" else _DEFAULT_STT_PROVIDER_CONFIGS
+    default_cfg = defaults.get(provider, {})
+
+    settings = dict(default_cfg)
+    settings.update(stored.get("settings", {}))
+
+    raw_secrets = stored.get("secrets", {})
+    secrets = _mask_secrets(raw_secrets) if not include_secrets else dict(raw_secrets)
+
+    now = int(time.time() * 1000)
+    return {
+        "profile": "default",
+        "provider": provider,
+        "settings": settings,
+        "secrets": secrets,
+        "createdAt": stored.get("createdAt", 0),
+        "updatedAt": stored.get("updatedAt", now),
+    }
+
+
+def _list_provider_rows(kind: str, data: dict, include_secrets: bool) -> list[dict[str, Any]]:
+    providers = data[kind].get("providers", {})
+    provider_list = TTS_PROVIDERS if kind == "tts" else STT_STORED_PROVIDERS
+    rows: list[dict[str, Any]] = []
+    for p in provider_list:
+        if p in providers:
+            rows.append(_provider_row(kind, p, data, include_secrets))
+    return rows
+
+
+# ---------------------------------------------------------------------------
+# TTS settings API
+# ---------------------------------------------------------------------------
+
+def tts_list_settings() -> dict[str, Any]:
+    data = _load_all()
+    active = data["tts"].get("activeProvider", "edge")
+    # If only one non-edge provider is configured, prefer it.
+    configured = list(data["tts"].get("providers", {}).keys())
+    if active == "edge" and len(configured) == 1 and configured[0] != "edge":
+        active = configured[0]
+    return {
+        "settings": _list_provider_rows("tts", data, include_secrets=False),
+        "activeProvider": active,
+    }
+
+
+def tts_get_active() -> str:
+    return _load_all()["tts"].get("activeProvider", "edge")
+
+
+def tts_set_active(provider: str) -> str:
+    if provider not in TTS_PROVIDERS:
+        raise ValueError(f"unknown TTS provider: {provider}")
+    data = _load_all()
+    data["tts"]["activeProvider"] = provider
+    _save_all(data)
+    return provider
+
+
+def tts_get_provider(provider: str, *, include_secrets: bool = False) -> Optional[dict[str, Any]]:
+    if provider not in TTS_PROVIDERS:
+        return None
+    data = _load_all()
+    return _provider_row("tts", provider, data, include_secrets)
+
+
+def tts_save_provider(provider: str, settings: Any, secrets: Any) -> dict[str, Any]:
+    if provider not in TTS_PROVIDERS:
+        raise ValueError(f"unknown TTS provider: {provider}")
+    data = _load_all()
+    providers = data["tts"].setdefault("providers", {})
+    existing = providers.get(provider, {})
+    existing_settings = dict(existing.get("settings", {}))
+    existing_settings.update(_DEFAULT_TTS_PROVIDER_CONFIGS.get(provider, {}))
+    clean_settings = _sanitize_settings(settings, TTS_SETTINGS_KEYS)
+    merged_settings = _merge_settings(existing_settings, clean_settings)
+    clean_secrets = _sanitize_secrets(secrets, TTS_SECRET_KEYS)
+    merged_secrets = _merge_secrets(existing.get("secrets", {}), clean_secrets)
+
+    now = int(time.time() * 1000)
+    providers[provider] = {
+        "settings": merged_settings,
+        "secrets": merged_secrets,
+        "createdAt": existing.get("createdAt", now),
+        "updatedAt": now,
+    }
+    _save_all(data)
+    return _provider_row("tts", provider, data, include_secrets=False)
+
+
+def tts_delete_provider(provider: str) -> bool:
+    if provider == "edge":
+        raise ValueError("edge provider cannot be deleted")
+    data = _load_all()
+    providers = data["tts"].get("providers", {})
+    if provider not in providers:
+        return False
+    del providers[provider]
+    if data["tts"].get("activeProvider") == provider:
+        data["tts"]["activeProvider"] = "edge"
+    _save_all(data)
+    return True
+
+
+def tts_delete_base_url_preset(provider: str, url: str) -> Optional[dict[str, Any]]:
+    data = _load_all()
+    providers = data["tts"].get("providers", {})
+    stored = providers.get(provider, {})
+    settings = stored.get("settings", {})
+    presets = settings.get("baseUrlPresets", [])
+    settings["baseUrlPresets"] = [u for u in presets if u != url]
+    if settings.get("baseUrl") == url:
+        settings["baseUrl"] = ""
+    stored["settings"] = settings
+    providers[provider] = stored
+    _save_all(data)
+    return _provider_row("tts", provider, data, include_secrets=False)
+
+
+def tts_clear_secret(provider: str, secret_name: str) -> Optional[dict[str, Any]]:
+    if secret_name != "apiKey":
+        raise ValueError(f"unknown secret: {secret_name}")
+    data = _load_all()
+    providers = data["tts"].get("providers", {})
+    stored = providers.get(provider, {})
+    secrets = stored.get("secrets", {})
+    secrets.pop(secret_name, None)
+    stored["secrets"] = secrets
+    providers[provider] = stored
+    _save_all(data)
+    return _provider_row("tts", provider, data, include_secrets=False)
+
+
+# ---------------------------------------------------------------------------
+# STT settings API
+# ---------------------------------------------------------------------------
+
+def stt_list_settings() -> dict[str, Any]:
+    data = _load_all()
+    active = data["stt"].get("activeProvider", "browser")
+    return {
+        "settings": _list_provider_rows("stt", data, include_secrets=False),
+        "activeProvider": active,
+    }
+
+
+def stt_get_active() -> str:
+    return _load_all()["stt"].get("activeProvider", "browser")
+
+
+def stt_set_active(provider: str) -> str:
+    if provider not in STT_ACTIVE_PROVIDERS:
+        raise ValueError(f"unknown STT provider: {provider}")
+    data = _load_all()
+    data["stt"]["activeProvider"] = provider
+    _save_all(data)
+    return provider
+
+
+def stt_get_provider(provider: str, *, include_secrets: bool = False) -> Optional[dict[str, Any]]:
+    if provider not in STT_STORED_PROVIDERS:
+        return None
+    data = _load_all()
+    return _provider_row("stt", provider, data, include_secrets)
+
+
+def stt_save_provider(provider: str, settings: Any, secrets: Any) -> dict[str, Any]:
+    if provider not in STT_STORED_PROVIDERS:
+        raise ValueError(f"unknown STT provider: {provider}")
+    data = _load_all()
+    providers = data["stt"].setdefault("providers", {})
+    existing = providers.get(provider, {})
+    existing_settings = dict(existing.get("settings", {}))
+    existing_settings.update(_DEFAULT_STT_PROVIDER_CONFIGS.get(provider, {}))
+    clean_settings = _sanitize_settings(settings, STT_SETTINGS_KEYS)
+    # audioTranscode only accepts 'ffmpeg' or 'none'
+    if "audioTranscode" in clean_settings:
+        if clean_settings["audioTranscode"] not in ("ffmpeg", "none"):
+            del clean_settings["audioTranscode"]
+    merged_settings = _merge_settings(existing_settings, clean_settings)
+    clean_secrets = _sanitize_secrets(secrets, STT_SECRET_KEYS)
+    merged_secrets = _merge_secrets(existing.get("secrets", {}), clean_secrets)
+
+    now = int(time.time() * 1000)
+    providers[provider] = {
+        "settings": merged_settings,
+        "secrets": merged_secrets,
+        "createdAt": existing.get("createdAt", now),
+        "updatedAt": now,
+    }
+    _save_all(data)
+    return _provider_row("stt", provider, data, include_secrets=False)
+
+
+def stt_delete_provider(provider: str) -> bool:
+    data = _load_all()
+    providers = data["stt"].get("providers", {})
+    if provider not in providers:
+        return False
+    del providers[provider]
+    if data["stt"].get("activeProvider") == provider:
+        data["stt"]["activeProvider"] = "browser"
+    _save_all(data)
+    return True
+
+
+def stt_delete_base_url_preset(provider: str, url: str) -> Optional[dict[str, Any]]:
+    data = _load_all()
+    providers = data["stt"].get("providers", {})
+    stored = providers.get(provider, {})
+    settings = stored.get("settings", {})
+    presets = settings.get("baseUrlPresets", [])
+    settings["baseUrlPresets"] = [u for u in presets if u != url]
+    if settings.get("baseUrl") == url:
+        settings["baseUrl"] = ""
+    stored["settings"] = settings
+    providers[provider] = stored
+    _save_all(data)
+    return _provider_row("stt", provider, data, include_secrets=False)
+
+
+def stt_clear_secret(provider: str, secret_name: str) -> Optional[dict[str, Any]]:
+    if secret_name != "apiKey":
+        raise ValueError(f"unknown secret: {secret_name}")
+    data = _load_all()
+    providers = data["stt"].get("providers", {})
+    stored = providers.get(provider, {})
+    secrets = stored.get("secrets", {})
+    secrets.pop(secret_name, None)
+    stored["secrets"] = secrets
+    providers[provider] = stored
+    _save_all(data)
+    return _provider_row("stt", provider, data, include_secrets=False)
+
+
+# ---------------------------------------------------------------------------
+# Provider info (for the frontend provider picker)
+# ---------------------------------------------------------------------------
+
+def provider_info() -> dict[str, Any]:
+    """Static info about all providers: labels, settings keys, needs-key flags."""
+    return {
+        "tts": {
+            "providers": [
+                {
+                    "id": p,
+                    "label": TTS_PROVIDER_LABELS[p],
+                    "settingsKeys": TTS_SETTINGS_KEYS,
+                    "secretKeys": TTS_SECRET_KEYS,
+                    "needsKey": p != "edge",
+                    "defaults": _DEFAULT_TTS_PROVIDER_CONFIGS.get(p, {}),
+                }
+                for p in TTS_PROVIDERS
+            ],
+        },
+        "stt": {
+            "providers": [
+                {
+                    "id": p,
+                    "label": STT_PROVIDER_LABELS[p],
+                    "settingsKeys": STT_SETTINGS_KEYS,
+                    "secretKeys": STT_SECRET_KEYS,
+                    "needsKey": p != "browser",
+                    "defaults": _DEFAULT_STT_PROVIDER_CONFIGS.get(p, {}),
+                }
+                for p in STT_ACTIVE_PROVIDERS if p in STT_PROVIDER_LABELS
+            ],
+        },
+    }