Просмотр исходного кода

完善多服务器支持与会话管理

samien 2 месяцев назад
Родитель
Сommit
3538f16992
35 измененных файлов с 4123 добавлено и 88 удалено
  1. 18 1
      README.md
  2. 3 3
      docs/agent-install.md
  3. 268 0
      hermes-plugins/yaoyao/dashboard/group_node_registry.py
  4. 341 0
      hermes-plugins/yaoyao/dashboard/group_node_worker.py
  5. 50 13
      hermes-plugins/yaoyao/dashboard/group_orchestrator.py
  6. 265 1
      hermes-plugins/yaoyao/dashboard/group_plugin_api.py
  7. 38 1
      hermes-plugins/yaoyao/dashboard/group_protocol.py
  8. 488 0
      hermes-plugins/yaoyao/dashboard/group_remote_gateway.py
  9. 91 8
      hermes-plugins/yaoyao/dashboard/group_store.py
  10. 1 1
      hermes-plugins/yaoyao/dashboard/manifest.json
  11. 3 3
      hermes-plugins/yaoyao/dashboard/tests/test_group_host_contract.py
  12. 451 3
      hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py
  13. 13 4
      hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py
  14. 331 3
      package-lock.json
  15. 2 0
      package.json
  16. 29 1
      src/client/api/groups.ts
  17. 60 0
      src/client/api/pairing.ts
  18. 189 0
      src/client/components/app/NodePairingDialog.vue
  19. 12 0
      src/client/components/app/WorkspaceShell.vue
  20. 12 11
      src/client/components/groups/CreateGroupDialog.vue
  21. 4 3
      src/client/components/groups/GroupManager.vue
  22. 8 0
      src/client/components/groups/types.ts
  23. 1 0
      src/client/components/workspace/WorkspaceView.vue
  24. 7 3
      src/client/stores/groups.ts
  25. 4 1
      src/client/utils/normalize.ts
  26. 34 11
      src/client/views/GroupsView.vue
  27. 12 2
      src/server/app.ts
  28. 409 0
      src/server/pairing.ts
  29. 319 0
      src/server/routes.ts
  30. 208 3
      src/server/websocket.ts
  31. 19 1
      src/shared/types.ts
  32. 36 8
      tests/client/groupHost.test.ts
  33. 4 3
      tests/client/groupProtocol.test.ts
  34. 230 0
      tests/server/pairing.test.ts
  35. 163 0
      tests/server/websocket.test.ts

+ 18 - 1
README.md

@@ -5,7 +5,7 @@
 仓库同时归档了夭夭的 Hermes Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 
-当前发布版本:**Git `v0.1.0` / 夭夭 Web `0.1.0` / Hermes Dashboard 插件 `1.6.1`**。
+当前发布版本:**Git `v0.1.0` / 夭夭 Web `0.1.0` / Hermes Dashboard 插件 `1.7.0`**。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
@@ -85,6 +85,23 @@ cp .env.example .env
 npm run dev
 ```
 
+## 手机扫码与跨节点 Agent
+
+插件 `1.7.0` 与当前 Web 服务共同提供 Hermes 节点配对。每台参与设备都需要安装相同版本的夭夭插件并运行 Web 服务:
+
+1. 在目标 Hermes 的夭夭 Web 左下角打开“手机与节点”。
+2. 输入一次 Hermes 密码并生成两分钟有效、只能使用一次的二维码;密码只用于创建独立设备会话,不会保存。
+3. 在夭夭 iOS 的“设置 → 服务器与账号”中选择“扫描 Hermes 节点”。
+4. 扫描后,该节点的 Bots、普通聊天历史和 Agent Profile 会按节点身份加入手机;同名 Profile 使用 `nodeId + profile` 区分。
+
+二维码不包含账号密码、长期 Token 或 Dashboard Cookie。Web 服务会签发可撤销的设备 Token,并把代理所需的 Dashboard Cookie 加密保存在 `HERMES_YAOYAO_HOME`。iOS Token 按服务器账号保存在系统 Keychain。忘记 iOS 中的节点或在 Web 中撤销设备都会终止后续访问。
+
+当前免密扫码签发要求 Hermes 启用 `basic` 密码 provider,以便为每台手机建立彼此独立、可单独刷新的上游会话;不会复制浏览器正在轮换的 refresh Cookie。仅启用 OAuth/SSO 的安装仍可在 iOS 中使用手动“添加服务器账号”,等待 Hermes 提供适合移动回调的原生授权方式。
+
+原生群聊由房主 Hermes 保存权威房间、话题和消息。扫码时,iOS 会把远端节点以加密凭据注册到房主插件;房主通过远端 `nodeWorker` 执行 Session,并转发流式事件、审批、澄清、中断和受限群聊附件。房主工作目录不会作为远端绝对路径使用,远端 Agent 默认采用自己的工作区。
+
+免密配对和跨节点执行应使用 HTTPS/WSS 或 Tailscale。只有显式设置 `HERMES_YAOYAO_ALLOW_INSECURE_LAN=1` 时才应在可信局域网使用 HTTP;不要把二维码或配对凭据发送给不受信任的人。
+
 ## 网络暴露:9119 与 8800 分别控制
 
 默认情况下,两个端口都**不开放局域网**:Hermes Dashboard `9119` 和夭夭 Web `8800` 都只监听 `127.0.0.1`。通过本项目安装的受管服务开启局域网时,两个端口会一起开放。

+ 3 - 3
docs/agent-install.md

@@ -8,7 +8,7 @@
 | --- | --- | --- |
 | Git 发布标签 | `v0.1.0` | `git describe --tags --exact-match HEAD` |
 | 夭夭 Web | `0.1.0` | `package.json` 的 `version` |
-| Hermes Dashboard 插件 | `1.6.1` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
+| Hermes Dashboard 插件 | `1.7.0` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
 
@@ -87,7 +87,7 @@ git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
 test "$(node -p \"require('./package.json').version\")" = "0.1.0"
-test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.6.1"
+test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.0"
 ```
 
 这会进入 detached HEAD 状态,属于部署发布版本的预期行为。若 Agent 负责修改代码,应另行创建工作分支,不要在此发布检出上修改。
@@ -116,7 +116,7 @@ test -f "$PLUGIN_DIR/manifest.json"
 test -f "$PLUGIN_DIR/plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/dist/index.js"
-test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/manifest.json")" = "1.6.1"
+test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/manifest.json")" = "1.7.0"
 ```
 
 `rsync --delete` 的范围严格限于 `$PLUGIN_DIR`,不会删除其他 Hermes 插件或用户数据。若目标目录已有额外的本机定制文件,应先将其移出目录或改用不带 `--delete` 的同步命令。

+ 268 - 0
hermes-plugins/yaoyao/dashboard/group_node_registry.py

@@ -0,0 +1,268 @@
+"""Encrypted registry for Hermes nodes delegated by the iOS pairing flow."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import tempfile
+import threading
+import time
+from typing import Mapping
+from urllib.parse import urlsplit, urlunsplit
+import uuid
+
+
+class NodeRegistryError(RuntimeError):
+    pass
+
+
+class NodeNotFoundError(NodeRegistryError):
+    pass
+
+
+class PairedNodeRegistry:
+    def __init__(self, root: Path) -> None:
+        self.root = Path(root)
+        self.path = self.root / "paired-group-nodes.json"
+        self.key_path = self.root / "paired-group-nodes.key"
+        self._lock = threading.RLock()
+        self._fernet = self._load_fernet()
+
+    @classmethod
+    def from_environment(cls) -> "PairedNodeRegistry":
+        hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
+        return cls(hermes_home / "plugins" / "yaoyao" / "data")
+
+    def register(self, command: Mapping[str, object]) -> dict[str, object]:
+        allowed = {
+            "nodeId", "name", "serverUrl", "fingerprint", "accessToken",
+            "profiles",
+        }
+        if not {"nodeId", "name", "serverUrl", "fingerprint", "accessToken"} <= set(command) or not set(command) <= allowed:
+            raise ValueError("Node registration fields are invalid")
+        node_id = self._node_id(command.get("nodeId"))
+        name = self._text(command.get("name"), "name", 100)
+        server_url = self._server_url(command.get("serverUrl"))
+        fingerprint = self._text(
+            command.get("fingerprint"), "fingerprint", 256
+        )
+        token = self._text(command.get("accessToken"), "accessToken", 4096)
+        profiles = self._profiles(command.get("profiles", []))
+        now = time.time()
+        with self._lock:
+            state = self._load()
+            existing = next(
+                (item for item in state if item.get("nodeId") == node_id), None
+            )
+            created_at = (
+                float(existing.get("createdAt", now))
+                if isinstance(existing, dict)
+                else now
+            )
+            record = {
+                "nodeId": node_id,
+                "name": name,
+                "serverUrl": server_url,
+                "fingerprint": fingerprint,
+                "accessToken": self._fernet.encrypt(token.encode()).decode(),
+                "profiles": profiles,
+                "createdAt": created_at,
+                "updatedAt": now,
+            }
+            state = [item for item in state if item.get("nodeId") != node_id]
+            state.append(record)
+            self._save(state)
+            return self._public(record)
+
+    def list(self) -> list[dict[str, object]]:
+        with self._lock:
+            return sorted(
+                (self._public(record) for record in self._load()),
+                key=lambda item: (-float(item["updatedAt"]), str(item["nodeId"])),
+            )
+
+    def get(self, node_id: str) -> dict[str, object]:
+        canonical = self._node_id(node_id)
+        with self._lock:
+            record = next(
+                (
+                    item for item in self._load()
+                    if item.get("nodeId") == canonical
+                ),
+                None,
+            )
+            if record is None:
+                raise NodeNotFoundError("Paired Hermes node was not found")
+            token = record.get("accessToken")
+            if not isinstance(token, str):
+                raise NodeRegistryError("Paired Hermes node credential is corrupt")
+            try:
+                access_token = self._fernet.decrypt(token.encode()).decode()
+            except Exception as error:
+                raise NodeRegistryError(
+                    "Paired Hermes node credential is unavailable"
+                ) from error
+            return {**self._public(record), "accessToken": access_token}
+
+    def revoke(self, node_id: str) -> bool:
+        canonical = self._node_id(node_id)
+        with self._lock:
+            state = self._load()
+            retained = [
+                item for item in state if item.get("nodeId") != canonical
+            ]
+            if len(retained) == len(state):
+                return False
+            self._save(retained)
+            return True
+
+    def _load_fernet(self):
+        try:
+            from cryptography.fernet import Fernet
+        except ImportError as error:
+            raise NodeRegistryError(
+                "Hermes runtime does not provide encrypted node storage"
+            ) from error
+        self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
+        try:
+            key = self.key_path.read_bytes()
+        except FileNotFoundError:
+            key = Fernet.generate_key()
+            try:
+                descriptor = os.open(
+                    self.key_path,
+                    os.O_WRONLY | os.O_CREAT | os.O_EXCL,
+                    0o600,
+                )
+                with os.fdopen(descriptor, "wb") as stream:
+                    stream.write(key)
+            except FileExistsError:
+                key = self.key_path.read_bytes()
+        return Fernet(key)
+
+    def _load(self) -> list[dict[str, object]]:
+        try:
+            value = json.loads(self.path.read_text(encoding="utf-8"))
+        except FileNotFoundError:
+            return []
+        except (OSError, ValueError, UnicodeDecodeError) as error:
+            raise NodeRegistryError("Paired Hermes node registry is corrupt") from error
+        if not isinstance(value, dict) or value.get("version") != 1:
+            raise NodeRegistryError("Paired Hermes node registry is corrupt")
+        nodes = value.get("nodes")
+        if not isinstance(nodes, list) or not all(
+            isinstance(item, dict) for item in nodes
+        ):
+            raise NodeRegistryError("Paired Hermes node registry is corrupt")
+        return nodes
+
+    def _save(self, nodes: list[dict[str, object]]) -> None:
+        self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
+        descriptor, temporary = tempfile.mkstemp(
+            prefix="paired-group-nodes.", suffix=".tmp", dir=self.root
+        )
+        try:
+            os.fchmod(descriptor, 0o600)
+            with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
+                json.dump(
+                    {"version": 1, "nodes": nodes},
+                    stream,
+                    ensure_ascii=False,
+                    sort_keys=True,
+                    separators=(",", ":"),
+                )
+                stream.write("\n")
+                stream.flush()
+                os.fsync(stream.fileno())
+            os.replace(temporary, self.path)
+        finally:
+            try:
+                os.unlink(temporary)
+            except FileNotFoundError:
+                pass
+
+    @staticmethod
+    def _node_id(value: object) -> str:
+        if not isinstance(value, str):
+            raise ValueError("nodeId must be a canonical UUID")
+        try:
+            canonical = str(uuid.UUID(value))
+        except ValueError as error:
+            raise ValueError("nodeId must be a canonical UUID") from error
+        if canonical != value:
+            raise ValueError("nodeId must be a canonical UUID")
+        return canonical
+
+    @staticmethod
+    def _text(value: object, field: str, maximum: int) -> str:
+        if not isinstance(value, str):
+            raise ValueError(f"{field} must be a string")
+        normalized = value.strip()
+        if not normalized or len(normalized) > maximum:
+            raise ValueError(f"{field} is invalid")
+        return normalized
+
+    @staticmethod
+    def _server_url(value: object) -> str:
+        if not isinstance(value, str):
+            raise ValueError("serverUrl must be a string")
+        parsed = urlsplit(value.strip())
+        if (
+            parsed.scheme not in {"http", "https"}
+            or not parsed.hostname
+            or parsed.username is not None
+            or parsed.password is not None
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("serverUrl must be an HTTP or HTTPS node URL")
+        path = parsed.path.rstrip("/")
+        if not path.startswith("/node/"):
+            raise ValueError("serverUrl must identify a paired node path")
+        return urlunsplit((parsed.scheme, parsed.netloc.lower(), path, "", ""))
+
+    @staticmethod
+    def _public(record: Mapping[str, object]) -> dict[str, object]:
+        return {
+            "nodeId": record["nodeId"],
+            "name": record["name"],
+            "serverUrl": record["serverUrl"],
+            "fingerprint": record["fingerprint"],
+            "createdAt": record["createdAt"],
+            "updatedAt": record["updatedAt"],
+            "profiles": record.get("profiles", []),
+        }
+
+    @classmethod
+    def _profiles(cls, value: object) -> list[dict[str, str]]:
+        if not isinstance(value, list) or len(value) > 100:
+            raise ValueError("profiles must be a list of at most 100 items")
+        result: list[dict[str, str]] = []
+        seen: set[str] = set()
+        for raw in value:
+            if not isinstance(raw, Mapping):
+                raise ValueError("profiles entries must be objects")
+            name = cls._text(raw.get("name"), "profiles.name", 100)
+            if name in seen:
+                raise ValueError("profiles names must be unique")
+            seen.add(name)
+            display_name = raw.get("displayName", "")
+            model = raw.get("model", "")
+            if not isinstance(display_name, str) or len(display_name) > 100:
+                raise ValueError("profiles.displayName is invalid")
+            if not isinstance(model, str) or len(model) > 4096:
+                raise ValueError("profiles.model is invalid")
+            result.append({
+                "name": name,
+                "displayName": display_name.strip(),
+                "model": model.strip(),
+            })
+        return result
+
+
+__all__ = [
+    "NodeNotFoundError",
+    "NodeRegistryError",
+    "PairedNodeRegistry",
+]

+ 341 - 0
hermes-plugins/yaoyao/dashboard/group_node_worker.py

@@ -0,0 +1,341 @@
+"""Authenticated remote-execution facade for a paired Hermes node.
+
+The public HTTP authentication boundary lives in the YaoYao Web node proxy.
+This module deliberately exposes only the same stable Gateway operations used
+by the native group orchestrator and keeps a bounded, cursor-based event journal
+so an owning Hermes can drive a remote Agent without holding an inbound socket.
+"""
+
+from __future__ import annotations
+
+from collections import deque
+import base64
+import copy
+import threading
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from .group_gateway import GroupGatewayAdapter, SessionIdentity
+
+
+MAX_NODE_EVENTS = 4096
+MAX_NODE_EVENT_PAGE = 256
+MAX_NODE_STRING = 4096
+
+
+class NodeWorkerModel(BaseModel):
+    model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+
+class NodeWorkerOpenRequest(NodeWorkerModel):
+    profile: str = Field(min_length=1, max_length=100)
+    title: str = Field(min_length=1, max_length=500)
+    cwd: str = Field(default="", max_length=MAX_NODE_STRING)
+    stored_session_id: str | None = Field(
+        default=None, alias="storedSessionId", max_length=MAX_NODE_STRING
+    )
+    model: str | None = Field(default=None, max_length=MAX_NODE_STRING)
+    provider: str | None = Field(default=None, max_length=MAX_NODE_STRING)
+    reasoning_effort: str | None = Field(
+        default=None, alias="reasoningEffort", max_length=32
+    )
+    fast_mode: bool | None = Field(default=None, alias="fastMode")
+
+    @field_validator(
+        "profile", "title", "stored_session_id", "model", "provider",
+        "reasoning_effort",
+    )
+    @classmethod
+    def normalize_strings(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
+        normalized = value.strip()
+        if not normalized:
+            raise ValueError("string value must not be blank")
+        return normalized
+
+    @model_validator(mode="after")
+    def validate_model_pair(self) -> "NodeWorkerOpenRequest":
+        if (self.model is None) != (self.provider is None):
+            raise ValueError("model and provider must be set together")
+        return self
+
+
+class NodeWorkerPromptRequest(NodeWorkerModel):
+    text: str = Field(min_length=1, max_length=1_000_000)
+
+    @field_validator("text")
+    @classmethod
+    def normalize_text(cls, value: str) -> str:
+        if not value.strip():
+            raise ValueError("text must not be blank")
+        return value
+
+
+class NodeWorkerApprovalRequest(NodeWorkerModel):
+    choice: Literal["once", "session", "always", "deny"]
+
+
+class NodeWorkerClarificationRequest(NodeWorkerModel):
+    request_id: str = Field(alias="requestId", min_length=1, max_length=MAX_NODE_STRING)
+    answer: str = Field(min_length=1, max_length=1_000_000)
+
+
+class NodeWorkerError(RuntimeError):
+    """Raised when the worker lifecycle or cursor contract is invalid."""
+
+
+class NodeGatewayWorker:
+    """Own one local Gateway adapter and a bounded replayable event journal."""
+
+    def __init__(self, *, gateway: GroupGatewayAdapter | None = None) -> None:
+        self._condition = threading.Condition(threading.RLock())
+        self._cursor = 0
+        self._events: deque[dict[str, object]] = deque(maxlen=MAX_NODE_EVENTS)
+        self._runtime_clients: dict[str, str] = {}
+        self._clarification_clients: dict[str, str] = {}
+        self._closed = False
+        self._gateway = gateway or GroupGatewayAdapter(on_event=self._on_event)
+
+    def open_session(
+        self, request: NodeWorkerOpenRequest, client_id: str = "local"
+    ) -> dict[str, object]:
+        self._require_open()
+        configuration = {
+            "model": request.model,
+            "provider": request.provider,
+            "reasoning_effort": request.reasoning_effort,
+            "fast": request.fast_mode,
+        }
+        if request.stored_session_id is None:
+            identity = self._gateway.create_session(
+                request.profile,
+                request.title,
+                request.cwd,
+                [],
+                configuration,
+            )
+        else:
+            identity = self._gateway.resume_session(
+                request.profile, request.stored_session_id
+            )
+        with self._condition:
+            client = self._client_id(client_id)
+            self._runtime_clients[identity.runtime_id] = client
+            for event in self._events:
+                if event.get("runtimeSessionId") == identity.runtime_id:
+                    event["clientId"] = client
+                    if event.get("type") == "clarify.request":
+                        payload = event.get("payload")
+                        if isinstance(payload, dict):
+                            request_id = payload.get("request_id") or payload.get(
+                                "requestId"
+                            )
+                            if isinstance(request_id, str):
+                                self._clarification_clients[request_id] = client
+        return self._identity(identity)
+
+    def submit_prompt(
+        self,
+        runtime_id: str,
+        request: NodeWorkerPromptRequest,
+        client_id: str = "local",
+    ) -> None:
+        self._require_open()
+        self._gateway.submit_prompt(
+            self._owned_runtime(runtime_id, client_id), request.text
+        )
+
+    def interrupt(self, runtime_id: str, client_id: str = "local") -> None:
+        self._require_open()
+        self._gateway.interrupt(self._owned_runtime(runtime_id, client_id))
+
+    def close_session(self, runtime_id: str, client_id: str = "local") -> bool:
+        self._require_open()
+        return self._gateway.close(self._owned_runtime(runtime_id, client_id))
+
+    def respond_approval(
+        self,
+        runtime_id: str,
+        request: NodeWorkerApprovalRequest,
+        client_id: str = "local",
+    ) -> int:
+        self._require_open()
+        return self._gateway.respond_approval(
+            self._owned_runtime(runtime_id, client_id), request.choice
+        )
+
+    def respond_clarification(
+        self,
+        request: NodeWorkerClarificationRequest,
+        client_id: str = "local",
+    ) -> str:
+        self._require_open()
+        client = self._client_id(client_id)
+        with self._condition:
+            if self._clarification_clients.get(request.request_id) != client:
+                raise NodeWorkerError("Clarification does not belong to this client")
+        return self._gateway.respond_clarification(request.request_id, request.answer)
+
+    def attach_file(
+        self,
+        runtime_id: str,
+        *,
+        name: str,
+        mime_type: str,
+        content: bytes,
+        client_id: str = "local",
+    ) -> dict[str, object]:
+        self._require_open()
+        runtime = self._owned_runtime(runtime_id, client_id)
+        if not isinstance(name, str) or not name.strip() or len(name) > 240:
+            raise NodeWorkerError("Attachment name is invalid")
+        if not isinstance(mime_type, str) or not mime_type.strip() or len(mime_type) > 200:
+            raise NodeWorkerError("Attachment MIME type is invalid")
+        if not isinstance(content, bytes) or not content or len(content) > 25 * 1024 * 1024:
+            raise NodeWorkerError("Attachment content is invalid")
+        result = self._gateway.request(
+            "file.attach",
+            {
+                "session_id": runtime,
+                "name": name.strip(),
+                "data_url": (
+                    f"data:{mime_type.strip()};base64,"
+                    + base64.b64encode(content).decode("ascii")
+                ),
+            },
+        )
+        if not isinstance(result, dict):
+            raise NodeWorkerError("Gateway attachment response is invalid")
+        return result
+
+    def events(
+        self,
+        *,
+        after: int,
+        runtime_id: str | None,
+        limit: int,
+        client_id: str = "local",
+    ) -> dict[str, object]:
+        if isinstance(after, bool) or not isinstance(after, int) or after < 0:
+            raise NodeWorkerError("after must be a non-negative integer")
+        if isinstance(limit, bool) or not isinstance(limit, int):
+            raise NodeWorkerError("limit must be an integer")
+        bounded_limit = max(1, min(limit, MAX_NODE_EVENT_PAGE))
+        selected_runtime = (
+            None if runtime_id is None else self._runtime_id(runtime_id)
+        )
+        client = self._client_id(client_id)
+        with self._condition:
+            client_events = [
+                event for event in self._events
+                if event.get("clientId") == client
+            ]
+            oldest = (
+                int(client_events[0]["cursor"])
+                if client_events else self._cursor + 1
+            )
+            reset = bool(client_events) and after > 0 and after < oldest - 1
+            selected = [
+                copy.deepcopy(event)
+                for event in client_events
+                if int(event["cursor"]) > after
+                and (
+                    selected_runtime is None
+                    or event["runtimeSessionId"] == selected_runtime
+                )
+            ][:bounded_limit]
+            items = []
+            for event in selected:
+                event.pop("clientId", None)
+                items.append(event)
+            return {
+                "items": items,
+                "latestCursor": self._cursor,
+                "oldestCursor": oldest,
+                "reset": reset,
+            }
+
+    def shutdown(self) -> None:
+        with self._condition:
+            if self._closed:
+                return
+            self._closed = True
+        self._gateway.shutdown()
+
+    def _on_event(
+        self, runtime_id: str, event_type: str, payload: dict[str, Any]
+    ) -> None:
+        with self._condition:
+            if self._closed:
+                return
+            self._cursor += 1
+            self._events.append(
+                {
+                    "cursor": self._cursor,
+                    "runtimeSessionId": runtime_id,
+                    "type": event_type,
+                    "payload": copy.deepcopy(payload),
+                    "clientId": self._runtime_clients.get(runtime_id, "local"),
+                }
+            )
+            if event_type == "clarify.request":
+                request_id = payload.get("request_id") or payload.get("requestId")
+                if isinstance(request_id, str):
+                    self._clarification_clients[request_id] = (
+                        self._runtime_clients.get(runtime_id, "local")
+                    )
+            self._condition.notify_all()
+
+    def _require_open(self) -> None:
+        with self._condition:
+            if self._closed:
+                raise NodeWorkerError("Node worker is closed")
+
+    @staticmethod
+    def _runtime_id(value: str) -> str:
+        if not isinstance(value, str):
+            raise NodeWorkerError("runtimeSessionId must be a string")
+        normalized = value.strip()
+        if not normalized or len(normalized) > MAX_NODE_STRING:
+            raise NodeWorkerError("runtimeSessionId is invalid")
+        return normalized
+
+    @staticmethod
+    def _client_id(value: str) -> str:
+        if not isinstance(value, str):
+            raise NodeWorkerError("Node client identity is invalid")
+        normalized = value.strip().lower()
+        if not normalized or len(normalized) > 128:
+            raise NodeWorkerError("Node client identity is invalid")
+        return normalized
+
+    def _owned_runtime(self, runtime_id: str, client_id: str) -> str:
+        runtime = self._runtime_id(runtime_id)
+        client = self._client_id(client_id)
+        with self._condition:
+            if self._runtime_clients.get(runtime) != client:
+                raise NodeWorkerError("Runtime does not belong to this client")
+        return runtime
+
+    @staticmethod
+    def _identity(identity: SessionIdentity) -> dict[str, object]:
+        if not isinstance(identity, SessionIdentity):
+            raise NodeWorkerError("Gateway returned an invalid session identity")
+        return {
+            "storedSessionId": identity.stored_id,
+            "runtimeSessionId": identity.runtime_id,
+            "running": identity.running,
+        }
+
+
+__all__ = [
+    "MAX_NODE_EVENT_PAGE",
+    "NodeGatewayWorker",
+    "NodeWorkerApprovalRequest",
+    "NodeWorkerClarificationRequest",
+    "NodeWorkerError",
+    "NodeWorkerOpenRequest",
+    "NodeWorkerPromptRequest",
+]

+ 50 - 13
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -18,7 +18,8 @@ import threading
 import uuid
 from typing import Any, Awaitable, Callable, Mapping, Protocol
 
-from .group_gateway import GroupGatewayAdapter, SessionIdentity
+from .group_gateway import SessionIdentity
+from .group_remote_gateway import GroupGatewayRouter
 from .group_protocol import MAX_MESSAGE_BYTES, MAX_TOOL_STATE_BYTES
 
 
@@ -515,7 +516,7 @@ class GroupOrchestrator:
         self._gateway_factory: GatewayFactory = (
             gateway_factory
             if gateway_factory is not None
-            else lambda callback: GroupGatewayAdapter(on_event=callback)
+            else lambda callback: GroupGatewayRouter(callback)
         )
         if work_enabled is not None and not callable(work_enabled):
             raise ValueError("work_enabled must be callable")
@@ -2224,6 +2225,9 @@ class GroupOrchestrator:
             await self._fail_claim_without_projection(run_id)
             return
         profile = _required_string(agent.get("profile"), "projection.agent.profile")
+        node_id = _required_string(
+            agent.get("nodeId", "local"), "projection.agent.nodeId"
+        )
         reply_mode = _required_string(run.get("replyMode"), "projection.run.replyMode")
         if reply_mode not in {"mentioned", "automatic"}:
             raise GroupOrchestratorError("projection.run.replyMode is invalid")
@@ -2261,18 +2265,51 @@ class GroupOrchestrator:
         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,
-                )
+                if node_id == "local":
+                    identity = await asyncio.to_thread(
+                        gateway.create_session,
+                        profile,
+                        f"[群聊] {room_name} · {display_name}",
+                        cwd_value,
+                        [],
+                        configuration,
+                    )
+                else:
+                    create_remote = getattr(
+                        gateway, "create_session_for_node", None
+                    )
+                    if not callable(create_remote):
+                        raise GroupOrchestratorError(
+                            "Gateway does not support remote nodes"
+                        )
+                    identity = await asyncio.to_thread(
+                        create_remote,
+                        node_id,
+                        profile,
+                        f"[群聊] {room_name} · {display_name}",
+                        "",
+                        [],
+                        configuration,
+                    )
             else:
-                identity = await asyncio.to_thread(
-                    gateway.resume_session, profile, expected_stored_id
-                )
+                if node_id == "local":
+                    identity = await asyncio.to_thread(
+                        gateway.resume_session, profile, expected_stored_id
+                    )
+                else:
+                    resume_remote = getattr(
+                        gateway, "resume_session_for_node", None
+                    )
+                    if not callable(resume_remote):
+                        raise GroupOrchestratorError(
+                            "Gateway does not support remote nodes"
+                        )
+                    identity = await asyncio.to_thread(
+                        resume_remote,
+                        node_id,
+                        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(

+ 265 - 1
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import asyncio
+import base64
 import hashlib
 import importlib
 import inspect
@@ -101,6 +102,12 @@ _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")
+_node_worker_module = importlib.import_module(
+    f"{_LOCAL_PACKAGE}.group_node_worker"
+)
+_node_registry_module = importlib.import_module(
+    f"{_LOCAL_PACKAGE}.group_node_registry"
+)
 
 
 PROTOCOL_VERSION = _protocol.PROTOCOL_VERSION
@@ -119,6 +126,12 @@ UpdateAgentRequest = _protocol.UpdateAgentRequest
 UpdateRoomRequest = _protocol.UpdateRoomRequest
 UpdateTopicRequest = _protocol.UpdateTopicRequest
 MarkTopicReadRequest = _protocol.MarkTopicReadRequest
+NodeWorkerOpenRequest = _node_worker_module.NodeWorkerOpenRequest
+NodeWorkerPromptRequest = _node_worker_module.NodeWorkerPromptRequest
+NodeWorkerApprovalRequest = _node_worker_module.NodeWorkerApprovalRequest
+NodeWorkerClarificationRequest = _node_worker_module.NodeWorkerClarificationRequest
+NodeGatewayWorker = _node_worker_module.NodeGatewayWorker
+PairedNodeRegistry = _node_registry_module.PairedNodeRegistry
 
 GroupStore = _store_module.GroupStore
 GroupStoreError = _store_module.GroupStoreError
@@ -894,6 +907,16 @@ with _bootstrap_lock:
         _package._yaoyao_group_runtime_override = False
         _package._yaoyao_group_orchestrator_factory = _default_orchestrator_factory
         _package._yaoyao_group_runtime_manager = _RuntimeManager()
+        _package._yaoyao_node_worker_lock = threading.Lock()
+        _package._yaoyao_node_worker = None
+        _package._yaoyao_node_registry_lock = threading.Lock()
+        _package._yaoyao_node_registry = None
+    if not hasattr(_package, "_yaoyao_node_worker_lock"):
+        _package._yaoyao_node_worker_lock = threading.Lock()
+        _package._yaoyao_node_worker = None
+    if not hasattr(_package, "_yaoyao_node_registry_lock"):
+        _package._yaoyao_node_registry_lock = threading.Lock()
+        _package._yaoyao_node_registry = None
 
 
 def set_store_for_testing(store: object | None) -> None:
@@ -910,6 +933,57 @@ def set_agent_name_resolver(resolver: Callable[[str], str]) -> None:
     _agent_name_resolver = resolver
 
 
+def set_node_worker_for_testing(worker: object | None) -> None:
+    with _package._yaoyao_node_worker_lock:
+        current = _package._yaoyao_node_worker
+        if current is not None and current is not worker:
+            shutdown = getattr(current, "shutdown", None)
+            if callable(shutdown):
+                shutdown()
+        _package._yaoyao_node_worker = worker
+
+
+def _node_worker_instance() -> object:
+    with _package._yaoyao_node_worker_lock:
+        worker = _package._yaoyao_node_worker
+        if worker is None:
+            worker = NodeGatewayWorker()
+            _package._yaoyao_node_worker = worker
+        return worker
+
+
+async def _node_worker_call(method: str, *args: object, **kwargs: object) -> object:
+    worker = _node_worker_instance()
+    operation = getattr(worker, method, None)
+    if not callable(operation):
+        raise RuntimeUnavailableError()
+    return await asyncio.to_thread(operation, *args, **kwargs)
+
+
+def set_node_registry_for_testing(registry: object | None) -> None:
+    with _package._yaoyao_node_registry_lock:
+        _package._yaoyao_node_registry = registry
+
+
+def _node_registry_instance() -> object:
+    with _package._yaoyao_node_registry_lock:
+        registry = _package._yaoyao_node_registry
+        if registry is None:
+            registry = PairedNodeRegistry.from_environment()
+            _package._yaoyao_node_registry = registry
+        return registry
+
+
+async def _node_registry_call(
+    method: str, *args: object, **kwargs: object
+) -> object:
+    registry = _node_registry_instance()
+    operation = getattr(registry, method, None)
+    if not callable(operation):
+        raise RuntimeUnavailableError()
+    return await asyncio.to_thread(operation, *args, **kwargs)
+
+
 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
@@ -1007,6 +1081,10 @@ def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
                 item.pop("replyWithoutMention", None)
             if isinstance(item, dict) and "is_host" not in seed.model_fields_set:
                 item.pop("isHost", None)
+            if isinstance(item, dict) and "node_id" not in seed.model_fields_set:
+                item.pop("nodeId", None)
+            if isinstance(item, dict) and "node_label" not in seed.model_fields_set:
+                item.pop("nodeLabel", None)
             if isinstance(item, dict):
                 for field, alias in (
                     ("model", "model"),
@@ -1025,6 +1103,10 @@ def _add_agent_command(request: AddAgentRequest) -> dict[str, object]:
         command.pop("replyWithoutMention", None)
     if "is_host" not in request.model_fields_set:
         command.pop("isHost", None)
+    if "node_id" not in request.model_fields_set:
+        command.pop("nodeId", None)
+    if "node_label" not in request.model_fields_set:
+        command.pop("nodeLabel", None)
     for field, alias in (
         ("model", "model"),
         ("provider", "provider"),
@@ -1052,7 +1134,9 @@ async def capabilities() -> dict[str, object]:
         "latestCursor": latest_cursor,
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
-        "features": ["hostFlow", "roomInstructions"],
+        "features": [
+            "hostFlow", "roomInstructions", "nodeWorker", "nodeRegistry"
+        ],
     }
 
 
@@ -1274,6 +1358,145 @@ async def respond_clarification(
     )
 
 
+def _node_client_id(request: Request) -> str:
+    value = request.headers.get("x-yaoyao-node-client", "").strip().lower()
+    try:
+        canonical = str(UUID(value))
+    except ValueError as error:
+        raise GroupInvalidRequest("Paired node client identity is required") from error
+    if canonical != value:
+        raise GroupInvalidRequest("Paired node client identity is required")
+    return canonical
+
+
+async def node_worker_open_session(
+    payload: NodeWorkerOpenRequest,
+    request: Request,
+) -> object:
+    return await _node_worker_call(
+        "open_session", payload, _node_client_id(request)
+    )
+
+
+async def register_paired_node(request: Request) -> object:
+    try:
+        payload = await request.json()
+    except Exception as error:
+        raise GroupInvalidRequest("Node registration requires JSON") from error
+    if not isinstance(payload, dict):
+        raise GroupInvalidRequest("Node registration must be an object")
+    return await _node_registry_call("register", payload)
+
+
+async def list_paired_nodes() -> dict[str, object]:
+    nodes = await _node_registry_call("list")
+    return {"items": nodes}
+
+
+async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
+    removed = await _node_registry_call("revoke", str(node_id))
+    if not removed:
+        raise GroupInvalidRequest("Paired Hermes node was not found")
+    return {"ok": True}
+
+
+async def node_worker_submit_prompt(
+    runtime_session_id: str,
+    payload: NodeWorkerPromptRequest,
+    request: Request,
+) -> dict[str, object]:
+    await _node_worker_call(
+        "submit_prompt", runtime_session_id, payload, _node_client_id(request)
+    )
+    return {"status": "streaming"}
+
+
+async def node_worker_interrupt(
+    runtime_session_id: str,
+    request: Request,
+) -> dict[str, object]:
+    await _node_worker_call(
+        "interrupt", runtime_session_id, _node_client_id(request)
+    )
+    return {"status": "interrupted"}
+
+
+async def node_worker_close_session(
+    runtime_session_id: str,
+    request: Request,
+) -> dict[str, object]:
+    closed = await _node_worker_call(
+        "close_session", runtime_session_id, _node_client_id(request)
+    )
+    return {"closed": bool(closed)}
+
+
+async def node_worker_respond_approval(
+    runtime_session_id: str,
+    payload: NodeWorkerApprovalRequest,
+    request: Request,
+) -> dict[str, object]:
+    resolved = await _node_worker_call(
+        "respond_approval",
+        runtime_session_id,
+        payload,
+        _node_client_id(request),
+    )
+    return {"resolved": resolved}
+
+
+async def node_worker_respond_clarification(
+    payload: NodeWorkerClarificationRequest,
+    request: Request,
+) -> dict[str, object]:
+    status = await _node_worker_call(
+        "respond_clarification", payload, _node_client_id(request)
+    )
+    return {"status": status}
+
+
+async def node_worker_events(
+    request: Request,
+    after: int = Query(default=0, ge=0),
+    runtime_session_id: str | None = Query(
+        default=None, alias="runtimeSessionId", min_length=1, max_length=4096
+    ),
+    limit: int = Query(default=100, ge=1, le=256),
+) -> object:
+    return await _node_worker_call(
+        "events",
+        after=after,
+        runtime_id=runtime_session_id,
+        limit=limit,
+        client_id=_node_client_id(request),
+    )
+
+
+async def node_worker_attach_file(
+    runtime_session_id: str,
+    request: Request,
+) -> object:
+    encoded_name = request.headers.get("x-file-name-b64", "")
+    mime_type = request.headers.get("x-mime-type", "application/octet-stream")
+    try:
+        name = base64.urlsafe_b64decode(
+            encoded_name + "=" * (-len(encoded_name) % 4)
+        ).decode("utf-8")
+    except (ValueError, UnicodeDecodeError) as error:
+        raise GroupInvalidRequest("Attachment name header is invalid") from error
+    content = await request.body()
+    if not content or len(content) > _MAX_GROUP_UPLOAD_FILE_BYTES:
+        raise GroupInvalidRequest("Remote attachment content is invalid")
+    return await _node_worker_call(
+        "attach_file",
+        runtime_session_id,
+        name=name,
+        mime_type=mime_type,
+        content=content,
+        client_id=_node_client_id(request),
+    )
+
+
 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)
@@ -1359,6 +1582,47 @@ def _build_router() -> APIRouter:
         respond_clarification,
         methods=["POST"],
     )
+    built.add_api_route("/nodes", register_paired_node, methods=["POST"])
+    built.add_api_route("/nodes", list_paired_nodes, methods=["GET"])
+    built.add_api_route(
+        "/nodes/{node_id}", revoke_paired_node, methods=["DELETE"]
+    )
+    built.add_api_route(
+        "/node-worker/sessions", node_worker_open_session, methods=["POST"]
+    )
+    built.add_api_route(
+        "/node-worker/sessions/{runtime_session_id}/prompt",
+        node_worker_submit_prompt,
+        methods=["POST"],
+    )
+    built.add_api_route(
+        "/node-worker/sessions/{runtime_session_id}/interrupt",
+        node_worker_interrupt,
+        methods=["POST"],
+    )
+    built.add_api_route(
+        "/node-worker/sessions/{runtime_session_id}",
+        node_worker_close_session,
+        methods=["DELETE"],
+    )
+    built.add_api_route(
+        "/node-worker/sessions/{runtime_session_id}/approval",
+        node_worker_respond_approval,
+        methods=["POST"],
+    )
+    built.add_api_route(
+        "/node-worker/clarification",
+        node_worker_respond_clarification,
+        methods=["POST"],
+    )
+    built.add_api_route(
+        "/node-worker/events", node_worker_events, methods=["GET"]
+    )
+    built.add_api_route(
+        "/node-worker/sessions/{runtime_session_id}/attachments",
+        node_worker_attach_file,
+        methods=["POST"],
+    )
     built.add_api_websocket_route("/events", stream_events)
     return built
 

+ 38 - 1
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -18,7 +18,7 @@ from pydantic import (
 )
 
 
-PROTOCOL_VERSION = 10
+PROTOCOL_VERSION = 11
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -97,6 +97,8 @@ class GroupModel(BaseModel):
 
 class AgentSeed(GroupModel):
     profile: str = Field(min_length=1, max_length=100)
+    node_id: str = Field(default="local", alias="nodeId", min_length=1, max_length=64)
+    node_label: str = Field(default="", alias="nodeLabel", max_length=100)
     display_name: str | None = Field(
         default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
     )
@@ -132,6 +134,16 @@ class AgentSeed(GroupModel):
     def reject_reserved_display_name(cls, value: str | None) -> str | None:
         return validate_optional_display_name(value)
 
+    @field_validator("node_id")
+    @classmethod
+    def validate_node_id(cls, value: str) -> str:
+        return normalize_node_id(value)
+
+    @field_validator("node_label")
+    @classmethod
+    def normalize_node_label(cls, value: str) -> str:
+        return " ".join(value.split())
+
 
 class CreateRoomRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
@@ -237,6 +249,8 @@ class MarkTopicReadRequest(GroupModel):
 class AddAgentRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     profile: str = Field(min_length=1, max_length=100)
+    node_id: str = Field(default="local", alias="nodeId", min_length=1, max_length=64)
+    node_label: str = Field(default="", alias="nodeLabel", max_length=100)
     display_name: str | None = Field(
         default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
     )
@@ -272,6 +286,16 @@ class AddAgentRequest(GroupModel):
     def reject_reserved_display_name(cls, value: str | None) -> str | None:
         return validate_optional_display_name(value)
 
+    @field_validator("node_id")
+    @classmethod
+    def validate_node_id(cls, value: str) -> str:
+        return normalize_node_id(value)
+
+    @field_validator("node_label")
+    @classmethod
+    def normalize_node_label(cls, value: str) -> str:
+        return " ".join(value.split())
+
 
 class UpdateAgentRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
@@ -346,6 +370,19 @@ def normalize_optional_agent_configuration(value: str | None) -> str | None:
     return normalized
 
 
+def normalize_node_id(value: str) -> str:
+    normalized = value.strip().lower()
+    if normalized == "local":
+        return normalized
+    try:
+        canonical = str(UUID(normalized))
+    except ValueError as error:
+        raise ValueError("nodeId must be local or a canonical UUID") from error
+    if canonical != normalized:
+        raise ValueError("nodeId must be local or a canonical UUID")
+    return canonical
+
+
 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")

+ 488 - 0
hermes-plugins/yaoyao/dashboard/group_remote_gateway.py

@@ -0,0 +1,488 @@
+"""Route native group sessions to local or paired remote Hermes nodes."""
+
+from __future__ import annotations
+
+import json
+import base64
+import mimetypes
+import os
+from pathlib import Path
+import re
+import threading
+import time
+from typing import Any, Callable, Mapping
+from urllib.error import HTTPError, URLError
+from urllib.parse import quote, unquote, urlencode
+from urllib.request import build_opener, ProxyHandler, Request
+import uuid
+
+from .group_gateway import GroupGatewayAdapter, SessionIdentity
+from .group_node_registry import PairedNodeRegistry
+
+
+EventCallback = Callable[[str, str, dict[str, object]], None]
+_ATTACHMENT_PATTERN = re.compile(r"(!?\[[^\]]*\]\(<([^>]+)>\))")
+
+
+class RemoteGatewayError(RuntimeError):
+    pass
+
+
+class _DrainFacade:
+    def drain(self, timeout: float | None = None) -> bool:
+        _ = timeout
+        return True
+
+
+class RemoteNodeGatewayAdapter:
+    """Blocking Gateway-compatible client over the paired Web node proxy."""
+
+    def __init__(
+        self,
+        node: Mapping[str, object],
+        on_event: EventCallback,
+        *,
+        poll_interval: float = 0.2,
+        attachment_root: Path | None = None,
+    ) -> None:
+        server_url = node.get("serverUrl")
+        access_token = node.get("accessToken")
+        if not isinstance(server_url, str) or not isinstance(access_token, str):
+            raise RemoteGatewayError("Paired node route is invalid")
+        self.server_url = server_url.rstrip("/")
+        self.access_token = access_token
+        self._on_event = on_event
+        self._poll_interval = poll_interval
+        self._cursor = 0
+        self._condition = threading.Condition(threading.RLock())
+        self._active_runtimes: set[str] = set()
+        self._clarification_ids: set[str] = set()
+        self._closed = False
+        self._opener = build_opener(ProxyHandler({}))
+        hermes_home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
+        self._attachment_root = (
+            attachment_root
+            or hermes_home / "plugins" / "yaoyao" / "data" / "group-uploads"
+        ).resolve()
+        self.transport = _DrainFacade()
+        self._poller = threading.Thread(
+            target=self._poll_events,
+            name="yaoyao-remote-node-events",
+            daemon=True,
+        )
+        self._poller.start()
+
+    def create_session(
+        self,
+        profile: str,
+        title: str,
+        cwd: str,
+        seed_messages: list[dict[str, Any]],
+        configuration: dict[str, Any] | None = None,
+    ) -> SessionIdentity:
+        if seed_messages:
+            raise RemoteGatewayError("Remote node seed messages are unsupported")
+        body: dict[str, object] = {
+            "profile": profile,
+            "title": title,
+            "cwd": cwd,
+        }
+        configuration = configuration or {}
+        for source, target in (
+            ("model", "model"),
+            ("provider", "provider"),
+            ("reasoning_effort", "reasoningEffort"),
+            ("fast", "fastMode"),
+        ):
+            if configuration.get(source) is not None:
+                body[target] = configuration[source]
+        return self._open(body)
+
+    def resume_session(self, profile: str, stored_id: str) -> SessionIdentity:
+        return self._open(
+            {
+                "profile": profile,
+                "title": "恢复跨节点群聊",
+                "cwd": "",
+                "storedSessionId": stored_id,
+            }
+        )
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None:
+        text = self._mirror_attachments(runtime_id, text)
+        self._request(
+            "POST",
+            f"/sessions/{quote(runtime_id, safe='')}/prompt",
+            {"text": text},
+        )
+
+    def _mirror_attachments(self, runtime_id: str, text: str) -> str:
+        mirrored: dict[str, str] = {}
+
+        def replace(match: re.Match[str]) -> str:
+            raw_path = unquote(match.group(2))
+            candidate = Path(raw_path)
+            if not candidate.is_absolute():
+                return match.group(1)
+            try:
+                resolved = candidate.resolve(strict=True)
+                resolved.relative_to(self._attachment_root)
+            except (OSError, RuntimeError, ValueError):
+                return match.group(1)
+            if not resolved.is_file():
+                return match.group(1)
+            key = str(resolved)
+            if key not in mirrored:
+                if resolved.stat().st_size > 25 * 1024 * 1024:
+                    raise RemoteGatewayError("Group attachment exceeds 25 MiB")
+                self._upload_attachment(runtime_id, resolved)
+                mirrored[key] = resolved.name
+            return match.group(1).replace(
+                f"<{match.group(2)}>",
+                f"<attachment://{quote(mirrored[key])}>",
+            )
+
+        return _ATTACHMENT_PATTERN.sub(replace, text)
+
+    def _upload_attachment(self, runtime_id: str, path: Path) -> None:
+        content = path.read_bytes()
+        encoded_name = base64.urlsafe_b64encode(
+            path.name.encode("utf-8")
+        ).decode("ascii").rstrip("=")
+        mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+        request = Request(
+            self.server_url
+            + "/api/plugins/yaoyao/v1/node-worker/sessions/"
+            + quote(runtime_id, safe="")
+            + "/attachments",
+            data=content,
+            method="POST",
+            headers={
+                "Accept": "application/json",
+                "Authorization": f"Bearer {self.access_token}",
+                "Content-Type": "application/octet-stream",
+                "X-File-Name-B64": encoded_name,
+                "X-Mime-Type": mime_type,
+            },
+        )
+        try:
+            with self._opener.open(request, timeout=120) as response:
+                raw = response.read(1024 * 1024 + 1)
+        except (HTTPError, URLError, TimeoutError, OSError) as error:
+            raise RemoteGatewayError("Remote attachment transfer failed") from error
+        if len(raw) > 1024 * 1024:
+            raise RemoteGatewayError("Remote attachment response is too large")
+        try:
+            result = json.loads(raw)
+        except (ValueError, UnicodeDecodeError) as error:
+            raise RemoteGatewayError("Remote attachment response is invalid") from error
+        if not isinstance(result, dict):
+            raise RemoteGatewayError("Remote attachment response is invalid")
+
+    def interrupt(self, runtime_id: str) -> None:
+        self._request(
+            "POST", f"/sessions/{quote(runtime_id, safe='')}/interrupt", {}
+        )
+
+    def close(self, runtime_id: str) -> bool:
+        response = self._request(
+            "DELETE", f"/sessions/{quote(runtime_id, safe='')}", None
+        )
+        with self._condition:
+            self._active_runtimes.discard(runtime_id)
+        return response.get("closed") is True
+
+    def respond_approval(self, runtime_id: str, choice: str) -> int:
+        response = self._request(
+            "POST",
+            f"/sessions/{quote(runtime_id, safe='')}/approval",
+            {"choice": choice},
+        )
+        value = response.get("resolved")
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise RemoteGatewayError("Remote approval response is invalid")
+        return value
+
+    def respond_clarification(self, request_id: str, answer: str) -> str:
+        response = self._request(
+            "POST",
+            "/clarification",
+            {"requestId": request_id, "answer": answer},
+        )
+        status = response.get("status")
+        if status not in {"ok", "expired"}:
+            raise RemoteGatewayError("Remote clarification response is invalid")
+        with self._condition:
+            self._clarification_ids.discard(request_id)
+        return str(status)
+
+    def owns_clarification(self, request_id: str) -> bool:
+        with self._condition:
+            return request_id in self._clarification_ids
+
+    def shutdown(self) -> None:
+        with self._condition:
+            if self._closed:
+                return
+            self._closed = True
+            self._condition.notify_all()
+        if threading.current_thread() is not self._poller:
+            self._poller.join(timeout=5)
+
+    def _open(self, body: dict[str, object]) -> SessionIdentity:
+        response = self._request("POST", "/sessions", body)
+        stored = response.get("storedSessionId")
+        runtime = response.get("runtimeSessionId")
+        running = response.get("running", False)
+        if (
+            not isinstance(stored, str)
+            or not stored
+            or not isinstance(runtime, str)
+            or not runtime
+            or not isinstance(running, bool)
+        ):
+            raise RemoteGatewayError("Remote session identity is invalid")
+        with self._condition:
+            self._active_runtimes.add(runtime)
+            self._condition.notify_all()
+        return SessionIdentity(stored, runtime, running)
+
+    def _poll_events(self) -> None:
+        failures = 0
+        while True:
+            with self._condition:
+                while not self._active_runtimes and not self._closed:
+                    self._condition.wait()
+                if self._closed:
+                    return
+            try:
+                response = self._request(
+                    "GET",
+                    "/events?" + urlencode({"after": self._cursor, "limit": 256}),
+                    None,
+                )
+                latest = response.get("latestCursor")
+                items = response.get("items")
+                if isinstance(latest, bool) or not isinstance(latest, int):
+                    raise RemoteGatewayError("Remote event cursor is invalid")
+                if not isinstance(items, list):
+                    raise RemoteGatewayError("Remote event page is invalid")
+                if response.get("reset") is True:
+                    raise RemoteGatewayError("Remote event history was truncated")
+                for item in items:
+                    if not isinstance(item, dict):
+                        continue
+                    cursor = item.get("cursor")
+                    runtime_id = item.get("runtimeSessionId")
+                    event_type = item.get("type")
+                    payload = item.get("payload")
+                    if (
+                        isinstance(cursor, int)
+                        and not isinstance(cursor, bool)
+                        and isinstance(runtime_id, str)
+                        and isinstance(event_type, str)
+                        and isinstance(payload, dict)
+                    ):
+                        if event_type == "clarify.request":
+                            request_id = payload.get("request_id") or payload.get(
+                                "requestId"
+                            )
+                            if isinstance(request_id, str):
+                                with self._condition:
+                                    self._clarification_ids.add(request_id)
+                        self._on_event(runtime_id, event_type, payload)
+                        self._cursor = max(self._cursor, cursor)
+                self._cursor = max(self._cursor, latest)
+                failures = 0
+                time.sleep(self._poll_interval)
+            except Exception:
+                failures += 1
+                time.sleep(min(5.0, self._poll_interval * (2 ** min(failures, 5))))
+
+    def _request(
+        self,
+        method: str,
+        path: str,
+        body: dict[str, object] | None,
+    ) -> dict[str, object]:
+        if self._closed:
+            raise RemoteGatewayError("Remote node adapter is closed")
+        encoded = None if body is None else json.dumps(body).encode("utf-8")
+        request = Request(
+            self.server_url
+            + "/api/plugins/yaoyao/v1/node-worker"
+            + path,
+            data=encoded,
+            method=method,
+            headers={
+                "Accept": "application/json",
+                "Authorization": f"Bearer {self.access_token}",
+                **({"Content-Type": "application/json"} if encoded is not None else {}),
+            },
+        )
+        try:
+            with self._opener.open(request, timeout=35) as response:
+                raw = response.read(4 * 1024 * 1024 + 1)
+        except (HTTPError, URLError, TimeoutError, OSError) as error:
+            raise RemoteGatewayError("Remote Hermes node request failed") from error
+        if len(raw) > 4 * 1024 * 1024:
+            raise RemoteGatewayError("Remote Hermes node response is too large")
+        try:
+            value = json.loads(raw)
+        except (ValueError, UnicodeDecodeError) as error:
+            raise RemoteGatewayError("Remote Hermes node response is invalid") from error
+        if not isinstance(value, dict):
+            raise RemoteGatewayError("Remote Hermes node response is invalid")
+        return value
+
+
+class GroupGatewayRouter:
+    """One process-local Gateway plus lazily connected remote node adapters."""
+
+    def __init__(
+        self,
+        on_event: EventCallback,
+        *,
+        registry: PairedNodeRegistry | None = None,
+        local: GroupGatewayAdapter | None = None,
+        remote_factory: Callable[
+            [Mapping[str, object], EventCallback], RemoteNodeGatewayAdapter
+        ] | None = None,
+    ) -> None:
+        self._on_event = on_event
+        self._registry = registry
+        self._local = local or GroupGatewayAdapter(on_event=on_event)
+        self._remote_factory = remote_factory or (
+            lambda node, callback: RemoteNodeGatewayAdapter(node, callback)
+        )
+        self._lock = threading.RLock()
+        self._remotes: dict[str, RemoteNodeGatewayAdapter] = {}
+        self._runtime_routes: dict[str, tuple[str, str]] = {}
+        self.transport = self
+
+    def create_session(self, *args: object, **kwargs: object) -> SessionIdentity:
+        return self._local.create_session(*args, **kwargs)
+
+    def resume_session(self, *args: object, **kwargs: object) -> SessionIdentity:
+        return self._local.resume_session(*args, **kwargs)
+
+    def create_session_for_node(
+        self,
+        node_id: str,
+        profile: str,
+        title: str,
+        cwd: str,
+        seed_messages: list[dict[str, Any]],
+        configuration: dict[str, Any] | None = None,
+    ) -> SessionIdentity:
+        adapter = self._remote(node_id)
+        return self._adopt_remote(
+            node_id,
+            adapter.create_session(
+                profile, title, cwd, seed_messages, configuration
+            ),
+        )
+
+    def resume_session_for_node(
+        self, node_id: str, profile: str, stored_id: str
+    ) -> SessionIdentity:
+        adapter = self._remote(node_id)
+        return self._adopt_remote(
+            node_id, adapter.resume_session(profile, stored_id)
+        )
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None:
+        adapter, remote_id = self._route(runtime_id)
+        adapter.submit_prompt(remote_id, text)
+
+    def interrupt(self, runtime_id: str) -> None:
+        adapter, remote_id = self._route(runtime_id)
+        adapter.interrupt(remote_id)
+
+    def close(self, runtime_id: str) -> bool:
+        adapter, remote_id = self._route(runtime_id)
+        result = adapter.close(remote_id)
+        with self._lock:
+            self._runtime_routes.pop(runtime_id, None)
+        return result
+
+    def respond_approval(self, runtime_id: str, choice: str) -> int:
+        adapter, remote_id = self._route(runtime_id)
+        return adapter.respond_approval(remote_id, choice)
+
+    def respond_clarification(self, request_id: str, answer: str) -> str:
+        with self._lock:
+            remotes = list(self._remotes.values())
+        for remote in remotes:
+            if remote.owns_clarification(request_id):
+                return remote.respond_clarification(request_id, answer)
+        return self._local.respond_clarification(request_id, answer)
+
+    def drain(self, timeout: float | None = None) -> bool:
+        local_transport = getattr(self._local, "transport", None)
+        drain = getattr(local_transport, "drain", None)
+        return True if not callable(drain) else bool(drain(timeout=timeout))
+
+    def shutdown(self) -> None:
+        with self._lock:
+            remotes = list(self._remotes.values())
+            self._remotes.clear()
+            self._runtime_routes.clear()
+        for remote in remotes:
+            remote.shutdown()
+        self._local.shutdown()
+
+    def _remote(self, node_id: str) -> RemoteNodeGatewayAdapter:
+        with self._lock:
+            existing = self._remotes.get(node_id)
+            if existing is not None:
+                return existing
+            if self._registry is None:
+                self._registry = PairedNodeRegistry.from_environment()
+            node = self._registry.get(node_id)
+
+            def callback(
+                remote_runtime_id: str,
+                event_type: str,
+                payload: dict[str, object],
+            ) -> None:
+                with self._lock:
+                    opaque = next(
+                        (
+                            key for key, route in self._runtime_routes.items()
+                            if route == (node_id, remote_runtime_id)
+                        ),
+                        None,
+                    )
+                if opaque is not None:
+                    self._on_event(opaque, event_type, payload)
+
+            created = self._remote_factory(node, callback)
+            self._remotes[node_id] = created
+            return created
+
+    def _adopt_remote(
+        self, node_id: str, identity: SessionIdentity
+    ) -> SessionIdentity:
+        opaque = f"remote:{uuid.uuid4()}"
+        with self._lock:
+            self._runtime_routes[opaque] = (node_id, identity.runtime_id)
+        return SessionIdentity(identity.stored_id, opaque, identity.running)
+
+    def _route(self, runtime_id: str):
+        with self._lock:
+            route = self._runtime_routes.get(runtime_id)
+            if route is None:
+                return self._local, runtime_id
+            node_id, remote_id = route
+            remote = self._remotes.get(node_id)
+        if remote is None:
+            raise RemoteGatewayError("Remote runtime route is unavailable")
+        return remote, remote_id
+
+
+__all__ = [
+    "GroupGatewayRouter",
+    "RemoteGatewayError",
+    "RemoteNodeGatewayAdapter",
+]

+ 91 - 8
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -44,7 +44,7 @@ from .group_protocol import (
 )
 
 
-SCHEMA_VERSION = 12
+SCHEMA_VERSION = 13
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -137,6 +137,9 @@ _SCHEMA_STATEMENTS = (
         id TEXT PRIMARY KEY,
         room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
         profile TEXT NOT NULL,
+        node_id TEXT NOT NULL DEFAULT 'local',
+        node_label TEXT NOT NULL DEFAULT '',
+        execution_profile TEXT,
         display_name TEXT NOT NULL,
         display_name_key TEXT NOT NULL,
         description TEXT NOT NULL DEFAULT '',
@@ -499,6 +502,9 @@ class GroupStore:
             raw_version = "11"
         if raw_version == "11":
             self._migrate_v11_to_v12(connection)
+            raw_version = "12"
+        if raw_version == "12":
+            self._migrate_v12_to_v13(connection)
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
         )
@@ -1244,13 +1250,37 @@ class GroupStore:
             "UPDATE group_meta SET value = '12' WHERE key = 'schema_version'"
         )
 
+    @staticmethod
+    def _migrate_v12_to_v13(connection: sqlite3.Connection) -> None:
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_agents)")
+        }
+        if "node_id" not in columns:
+            connection.execute(
+                "ALTER TABLE group_agents ADD COLUMN node_id "
+                "TEXT NOT NULL DEFAULT 'local'"
+            )
+        if "execution_profile" not in columns:
+            connection.execute(
+                "ALTER TABLE group_agents ADD COLUMN execution_profile TEXT"
+            )
+        if "node_label" not in columns:
+            connection.execute(
+                "ALTER TABLE group_agents ADD COLUMN node_label "
+                "TEXT NOT NULL DEFAULT ''"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '13' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
             "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
-            "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
+            "group_agents": {"id", "room_id", "profile", "node_id", "node_label", "execution_profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
             "group_messages": {"seq", "id", "room_id", "topic_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", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "required_reply", "status", "runtime_session_id", "requested_model", "requested_provider", "requested_reasoning_effort", "requested_fast_mode", "actual_model", "actual_provider", "actual_reasoning_effort", "actual_fast_mode", "error", "created_at", "updated_at"},
@@ -1264,6 +1294,8 @@ class GroupStore:
             ("group_rooms", "instructions"): ("TEXT", 1, "''"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
+            ("group_agents", "node_id"): ("TEXT", 1, "'local'"),
+            ("group_agents", "node_label"): ("TEXT", 1, "''"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
             ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
             ("group_agent_runs", "required_reply"): ("INTEGER", 1, "0"),
@@ -1316,6 +1348,15 @@ class GroupStore:
             """SELECT 1 FROM group_agents
                 WHERE typeof(is_host) != 'integer' OR is_host NOT IN (0, 1)
                 LIMIT 1""",
+            """SELECT 1 FROM group_agents
+                WHERE typeof(node_id) != 'text'
+                   OR typeof(node_label) != 'text'
+                   OR length(node_label) > 100
+                   OR (node_id = 'local' AND execution_profile IS NOT NULL)
+                   OR (node_id != 'local' AND
+                       (execution_profile IS NULL
+                        OR profile != 'node:' || node_id || ':' || execution_profile))
+                LIMIT 1""",
             """SELECT 1 FROM group_rooms AS room
                 LEFT JOIN group_agents AS agent ON agent.room_id = room.id
                 GROUP BY room.id
@@ -6687,11 +6728,21 @@ class GroupStore:
             command,
             {
                 "profile", "displayName", "description", "replyWithoutMention",
-                "isHost",
+                "isHost", "nodeId", "nodeLabel",
                 "model", "provider", "reasoningEffort", "fastMode",
             },
         )
-        profile = self._profile(command.get("profile"))
+        execution_profile = self._profile(command.get("profile"))
+        node_id = self._node_id(command.get("nodeId", "local"))
+        profile = (
+            execution_profile
+            if node_id == "local"
+            else f"node:{node_id}:{execution_profile}"
+        )
+        node_label = command.get("nodeLabel", "")
+        if not isinstance(node_label, str) or len(node_label) > 100:
+            raise ValueError("nodeLabel must be a string up to 100 characters")
+        node_label = " ".join(node_label.split())
         raw_name = command.get("displayName")
         explicit_name = (
             raw_name.strip()
@@ -6699,7 +6750,13 @@ class GroupStore:
             else ""
         )
         display_name, display_name_key = normalize_display_name(
-            explicit_name or self._resolved_agent_name(profile) or profile
+            explicit_name
+            or (
+                self._resolved_agent_name(execution_profile)
+                if node_id == "local"
+                else ""
+            )
+            or execution_profile
         )
         reply_without_mention = command.get("replyWithoutMention", False)
         if not isinstance(reply_without_mention, bool):
@@ -6714,6 +6771,11 @@ class GroupStore:
             raise ValueError("model and provider must both be set or both be null")
         return {
             "profile": profile,
+            "node_id": node_id,
+            "node_label": node_label,
+            "execution_profile": (
+                None if node_id == "local" else execution_profile
+            ),
             "display_name": display_name,
             "display_name_key": display_name_key,
             "description": self._description(command.get("description", "")),
@@ -6767,14 +6829,18 @@ class GroupStore:
         agent_id = str(uuid.uuid4())
         connection.execute(
             """INSERT INTO group_agents
-            (id, room_id, profile, display_name, display_name_key, description,
+            (id, room_id, profile, node_id, node_label, execution_profile,
+             display_name, display_name_key, description,
              reply_without_mention, is_host, model_override, provider_override,
              reasoning_effort_override, fast_mode_override, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
             (
                 agent_id,
                 room_id,
                 agent["profile"],
+                agent["node_id"],
+                agent["node_label"],
+                agent["execution_profile"],
                 agent["display_name"],
                 agent["display_name_key"],
                 agent["description"],
@@ -6881,7 +6947,9 @@ class GroupStore:
         return {
             "id": row["id"],
             "roomId": row["room_id"],
-            "profile": row["profile"],
+            "profile": row["execution_profile"] or row["profile"],
+            "nodeId": row["node_id"],
+            "nodeLabel": row["node_label"],
             "displayName": row["display_name"],
             "description": row["description"],
             "storedSessionId": row["stored_session_id"],
@@ -7063,6 +7131,21 @@ class GroupStore:
             raise ValueError("profile must contain 1 to 100 characters")
         return profile
 
+    @staticmethod
+    def _node_id(value: object) -> str:
+        if not isinstance(value, str):
+            raise ValueError("nodeId must be a string")
+        normalized = value.strip().lower()
+        if normalized == "local":
+            return normalized
+        try:
+            canonical = str(uuid.UUID(normalized))
+        except ValueError as error:
+            raise ValueError("nodeId must be local or a canonical UUID") from error
+        if canonical != normalized:
+            raise ValueError("nodeId must be local or a canonical UUID")
+        return canonical
+
     @staticmethod
     def _description(value: object) -> str:
         if not isinstance(value, str):

+ 1 - 1
hermes-plugins/yaoyao/dashboard/manifest.json

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

+ 3 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_host_contract.py

@@ -34,7 +34,7 @@ def new_id() -> str:
 
 class GroupHostContractTests(unittest.TestCase):
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": new_id(),
             "name": "兼容群",
@@ -496,7 +496,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertTrue(
                 all(
                     room["orchestrationMode"] == "free"
@@ -881,7 +881,7 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertEqual(
                 migrated.get_room(room["id"])["orchestrationMode"], "free"
             )

+ 451 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -2,7 +2,9 @@ from __future__ import annotations
 
 import importlib
 import asyncio
+import base64
 import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 import json
 import os
 from pathlib import Path
@@ -11,6 +13,8 @@ import sys
 import subprocess
 import tempfile
 import textwrap
+import threading
+import time
 import unittest
 import uuid
 
@@ -28,6 +32,15 @@ ORCHESTRATOR = importlib.import_module(
     f"{group_plugin_api._LOCAL_PACKAGE}.group_orchestrator"
 )
 GATEWAY = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_gateway")
+NODE_WORKER = importlib.import_module(
+    f"{group_plugin_api._LOCAL_PACKAGE}.group_node_worker"
+)
+NODE_REGISTRY = importlib.import_module(
+    f"{group_plugin_api._LOCAL_PACKAGE}.group_node_registry"
+)
+REMOTE_GATEWAY = importlib.import_module(
+    f"{group_plugin_api._LOCAL_PACKAGE}.group_remote_gateway"
+)
 GroupStore = STORE_MODULE.GroupStore
 
 
@@ -36,6 +49,239 @@ def request_id() -> str:
 
 
 class GroupSettingsContractTests(unittest.TestCase):
+    def test_room_accepts_same_profile_from_local_and_paired_node(self) -> None:
+        node_id = request_id()
+        request = PROTOCOL.CreateRoomRequest.model_validate({
+            "requestId": request_id(),
+            "name": "跨节点评审",
+            "agents": [
+                {"profile": "default", "displayName": "本机 Agent"},
+                {
+                    "profile": "default",
+                    "nodeId": node_id,
+                    "nodeLabel": "MacBook Pro",
+                    "displayName": "远端 Agent",
+                },
+            ],
+        })
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                group_plugin_api._create_room_command(request)
+            )
+            agents_by_node = {item["nodeId"]: item for item in room["agents"]}
+            self.assertEqual(set(agents_by_node), {"local", node_id})
+            self.assertEqual(agents_by_node["local"]["profile"], "default")
+            self.assertEqual(agents_by_node[node_id]["profile"], "default")
+            self.assertEqual(agents_by_node[node_id]["nodeLabel"], "MacBook Pro")
+            with store.connection() as connection:
+                stored = connection.execute(
+                    "SELECT profile, execution_profile FROM group_agents "
+                    "ORDER BY created_at, id"
+                ).fetchall()
+            self.assertNotEqual(stored[0]["profile"], stored[1]["profile"])
+            self.assertIn("default", {row["execution_profile"] for row in stored})
+
+    def test_paired_node_registry_encrypts_token_and_router_scopes_runtime(self) -> None:
+        node_id = request_id()
+        with tempfile.TemporaryDirectory() as directory:
+            registry = NODE_REGISTRY.PairedNodeRegistry(Path(directory))
+            registered = registry.register({
+                "nodeId": node_id,
+                "name": "远端 Mac",
+                "serverUrl": "https://remote.example/node/11111111-1111-4111-8111-111111111111",
+                "fingerprint": "fingerprint",
+                "accessToken": "top-secret-token",
+            })
+            self.assertNotIn("accessToken", registered)
+            persisted = (Path(directory) / "paired-group-nodes.json").read_text()
+            self.assertNotIn("top-secret-token", persisted)
+            self.assertEqual(registry.get(node_id)["accessToken"], "top-secret-token")
+
+            events: list[tuple[str, str, dict[str, object]]] = []
+            remote = _FakeRemoteGateway()
+            router = REMOTE_GATEWAY.GroupGatewayRouter(
+                lambda *event: events.append(event),
+                registry=registry,
+                local=_FakeNodeGateway(),
+                remote_factory=lambda _node, callback: remote.bind(callback),
+            )
+            identity = router.create_session_for_node(
+                node_id, "default", "群聊", "", [], None
+            )
+            self.assertTrue(identity.runtime_id.startswith("remote:"))
+            router.submit_prompt(identity.runtime_id, "执行")
+            self.assertEqual(remote.submissions, [("remote-runtime", "执行")])
+            remote.emit("remote-runtime", "message.delta", {"text": "完成"})
+            self.assertEqual(events, [(identity.runtime_id, "message.delta", {"text": "完成"})])
+            router.shutdown()
+
+    def test_remote_gateway_adapter_calls_paired_worker_and_replays_events(self) -> None:
+        _WorkerHTTPHandler.prompts = []
+        _WorkerHTTPHandler.attachments = []
+        _WorkerHTTPHandler.emitted = False
+        server = ThreadingHTTPServer(("127.0.0.1", 0), _WorkerHTTPHandler)
+        thread = threading.Thread(target=server.serve_forever, daemon=True)
+        thread.start()
+        events: list[tuple[str, str, dict[str, object]]] = []
+        attachments = tempfile.TemporaryDirectory()
+        attachment_root = Path(attachments.name)
+        room_root = attachment_root / "room"
+        room_root.mkdir()
+        attachment_path = room_root / "资料.txt"
+        attachment_path.write_text("跨节点附件", encoding="utf-8")
+        adapter = REMOTE_GATEWAY.RemoteNodeGatewayAdapter(
+            {
+                "serverUrl": (
+                    f"http://127.0.0.1:{server.server_port}"
+                    "/node/11111111-1111-4111-8111-111111111111"
+                ),
+                "accessToken": "worker-token",
+            },
+            lambda *event: events.append(event),
+            poll_interval=0.01,
+            attachment_root=attachment_root,
+        )
+        try:
+            identity = adapter.create_session(
+                "default", "跨节点", "", [], None
+            )
+            self.assertEqual(identity.runtime_id, "runtime-http")
+            adapter.submit_prompt(
+                identity.runtime_id,
+                f"开始\n\n[资料](<{attachment_path}>)",
+            )
+            deadline = time.time() + 2
+            while not events and time.time() < deadline:
+                time.sleep(0.01)
+            self.assertEqual(
+                events,
+                [("runtime-http", "message.complete", {"text": "远端完成"})],
+            )
+            self.assertIn("开始", _WorkerHTTPHandler.prompts[0])
+            self.assertIn("attachment://", _WorkerHTTPHandler.prompts[0])
+            self.assertEqual(
+                _WorkerHTTPHandler.attachments,
+                [("资料.txt", "跨节点附件".encode("utf-8"))],
+            )
+        finally:
+            adapter.shutdown()
+            server.shutdown()
+            server.server_close()
+            thread.join(timeout=2)
+            attachments.cleanup()
+
+    def test_orchestrator_dispatches_remote_agent_through_node_router(self) -> None:
+        node_id = request_id()
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": request_id(),
+                "name": "远程执行",
+                "cwd": directory,
+                "agents": [{
+                    "profile": "default",
+                    "nodeId": node_id,
+                    "nodeLabel": "Remote Mac",
+                    "displayName": "远端 Agent",
+                }],
+            })
+            [agent] = room["agents"]
+            created = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="执行远端任务",
+                mention_agent_ids=[agent["id"]],
+            )
+            run_id = created["runs"][0]["id"]
+            gateway_holder: list[_CrossNodeOrchestratorGateway] = []
+
+            async def exercise() -> None:
+                def factory(callback):
+                    gateway = _CrossNodeOrchestratorGateway(callback)
+                    gateway_holder.append(gateway)
+                    return gateway
+
+                orchestrator = ORCHESTRATOR.GroupOrchestrator(
+                    store,
+                    gateway_factory=factory,
+                    completion_timeout=2,
+                )
+                await orchestrator.start()
+                orchestrator.wake()
+                deadline = asyncio.get_running_loop().time() + 3
+                while store.get_run(run_id)["status"] not in {
+                    "completed", "failed", "interrupted"
+                }:
+                    if asyncio.get_running_loop().time() >= deadline:
+                        self.fail("Remote Agent run did not settle")
+                    await asyncio.sleep(0.01)
+                await orchestrator.shutdown()
+
+            asyncio.run(exercise())
+            [gateway] = gateway_holder
+            self.assertEqual(gateway.remote_opens[0][:2], (node_id, "default"))
+            self.assertEqual(gateway.remote_opens[0][2], "")
+            self.assertEqual(store.get_run(run_id)["status"], "completed")
+            messages = store.list_messages(room["id"])
+            self.assertEqual(messages[-1]["content"], "远端完成")
+
+    def test_node_worker_routes_and_bounded_event_replay(self) -> None:
+        gateway = _FakeNodeGateway()
+        worker = NODE_WORKER.NodeGatewayWorker(gateway=gateway)
+        opened = worker.open_session(
+            NODE_WORKER.NodeWorkerOpenRequest.model_validate({
+                "profile": "remote-agent",
+                "title": "跨节点群聊",
+                "cwd": "",
+            })
+        )
+        self.assertEqual(opened["storedSessionId"], "stored-1")
+        self.assertEqual(opened["runtimeSessionId"], "runtime-1")
+
+        worker.submit_prompt(
+            "runtime-1",
+            NODE_WORKER.NodeWorkerPromptRequest(text="请分析"),
+        )
+        self.assertEqual(gateway.submissions, [("runtime-1", "请分析")])
+        worker.attach_file(
+            "runtime-1",
+            name="资料.txt",
+            mime_type="text/plain",
+            content="附件内容".encode("utf-8"),
+        )
+        self.assertEqual(gateway.requests[0][0], "file.attach")
+        self.assertTrue(
+            gateway.requests[0][1]["data_url"].startswith(
+                "data:text/plain;base64,"
+            )
+        )
+        worker._on_event("runtime-1", "message.delta", {"text": "结果"})
+        page = worker.events(after=0, runtime_id="runtime-1", limit=100)
+        self.assertEqual(page["latestCursor"], 1)
+        self.assertEqual(page["items"][0]["payload"], {"text": "结果"})
+        self.assertFalse(page["reset"])
+        self.assertEqual(
+            worker.events(
+                after=0,
+                runtime_id=None,
+                limit=100,
+                client_id="another-client",
+            )["items"],
+            [],
+        )
+        with self.assertRaises(NODE_WORKER.NodeWorkerError):
+            worker.interrupt("runtime-1", client_id="another-client")
+
+        paths = {getattr(route, "path", "") for route in group_plugin_api.router.routes}
+        self.assertIn("/v1/node-worker/sessions", paths)
+        self.assertIn("/v1/node-worker/events", paths)
+        worker.shutdown()
+        self.assertTrue(gateway.shutdown_called)
+
     def test_room_instructions_round_trip_into_agent_prompt(self) -> None:
         create = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": request_id(),
@@ -107,7 +353,7 @@ class GroupSettingsContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
 
     def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
@@ -412,7 +658,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 )
 
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
@@ -786,7 +1032,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 12)
+            self.assertEqual(store.schema_version(), 13)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
@@ -1799,5 +2045,207 @@ class GroupSettingsContractTests(unittest.TestCase):
         connection.close()
 
 
+class _WorkerHTTPHandler(BaseHTTPRequestHandler):
+    prompts: list[str] = []
+    attachments: list[tuple[str, bytes]] = []
+    emitted = False
+
+    def do_POST(self) -> None:  # noqa: N802 - stdlib callback spelling
+        if self.headers.get("Authorization") != "Bearer worker-token":
+            self._json(401, {"error": "unauthorized"})
+            return
+        length = int(self.headers.get("Content-Length", "0"))
+        raw_body = self.rfile.read(length)
+        if self.path.endswith("/sessions/runtime-http/attachments"):
+            encoded_name = self.headers["X-File-Name-B64"]
+            name = base64.urlsafe_b64decode(
+                encoded_name + "=" * (-len(encoded_name) % 4)
+            ).decode()
+            self.attachments.append((name, raw_body))
+            self._json(200, {"attached": True})
+            return
+        body = json.loads(raw_body or b"{}")
+        if self.path.endswith("/node-worker/sessions"):
+            self._json(200, {
+                "storedSessionId": "stored-http",
+                "runtimeSessionId": "runtime-http",
+                "running": False,
+            })
+            return
+        if self.path.endswith("/sessions/runtime-http/prompt"):
+            self.prompts.append(body["text"])
+            self._json(200, {"status": "streaming"})
+            return
+        self._json(404, {"error": "not found"})
+
+    def do_GET(self) -> None:  # noqa: N802 - stdlib callback spelling
+        if self.headers.get("Authorization") != "Bearer worker-token":
+            self._json(401, {"error": "unauthorized"})
+            return
+        if "/node-worker/events?" not in self.path:
+            self._json(404, {"error": "not found"})
+            return
+        items = []
+        if not self.emitted:
+            type(self).emitted = True
+            items = [{
+                "cursor": 1,
+                "runtimeSessionId": "runtime-http",
+                "type": "message.complete",
+                "payload": {"text": "远端完成"},
+            }]
+        self._json(200, {
+            "items": items,
+            "latestCursor": 1 if self.emitted else 0,
+            "oldestCursor": 1,
+            "reset": False,
+        })
+
+    def log_message(self, format: str, *args: object) -> None:
+        _ = (format, args)
+
+    def _json(self, status: int, value: object) -> None:
+        encoded = json.dumps(value).encode()
+        self.send_response(status)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(encoded)))
+        self.end_headers()
+        self.wfile.write(encoded)
+
+
+class _FakeNodeGateway:
+    def __init__(self) -> None:
+        self.submissions: list[tuple[str, str]] = []
+        self.requests: list[tuple[str, dict[str, object]]] = []
+        self.shutdown_called = False
+
+    def create_session(
+        self,
+        profile: str,
+        title: str,
+        cwd: str,
+        messages: list[dict[str, object]],
+        configuration: dict[str, object],
+    ) -> object:
+        _ = (profile, title, cwd, messages, configuration)
+        return GATEWAY.SessionIdentity("stored-1", "runtime-1", False)
+
+    def resume_session(self, profile: str, stored_id: str) -> object:
+        _ = profile
+        return GATEWAY.SessionIdentity(stored_id, "runtime-1", False)
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None:
+        self.submissions.append((runtime_id, text))
+
+    def request(
+        self, method: str, params: dict[str, object]
+    ) -> dict[str, object]:
+        self.requests.append((method, params))
+        return {"attached": True}
+
+    def interrupt(self, runtime_id: str) -> None:
+        _ = runtime_id
+
+    def close(self, runtime_id: str) -> bool:
+        _ = runtime_id
+        return True
+
+    def respond_approval(self, runtime_id: str, choice: str) -> int:
+        _ = (runtime_id, choice)
+        return 1
+
+    def respond_clarification(self, request_id: str, answer: str) -> str:
+        _ = (request_id, answer)
+        return "ok"
+
+    def shutdown(self) -> None:
+        self.shutdown_called = True
+
+
+class _FakeRemoteGateway(_FakeNodeGateway):
+    def __init__(self) -> None:
+        super().__init__()
+        self.callback = None
+
+    def bind(self, callback):
+        self.callback = callback
+        return self
+
+    def create_session(
+        self,
+        profile: str,
+        title: str,
+        cwd: str,
+        messages: list[dict[str, object]],
+        configuration: dict[str, object] | None,
+    ) -> object:
+        _ = (profile, title, cwd, messages, configuration)
+        return GATEWAY.SessionIdentity(
+            "remote-stored", "remote-runtime", False
+        )
+
+    def emit(
+        self, runtime_id: str, event_type: str, payload: dict[str, object]
+    ) -> None:
+        assert self.callback is not None
+        self.callback(runtime_id, event_type, payload)
+
+    def owns_clarification(self, request_id: str) -> bool:
+        _ = request_id
+        return False
+
+
+class _CrossNodeOrchestratorGateway(_FakeNodeGateway):
+    transport = type(
+        "DrainTransport",
+        (),
+        {"drain": lambda self, timeout=None: True},
+    )()
+
+    def __init__(self, callback) -> None:
+        super().__init__()
+        self.callback = callback
+        self.remote_opens: list[tuple[str, str, str]] = []
+
+    def create_session_for_node(
+        self,
+        node_id: str,
+        profile: str,
+        title: str,
+        cwd: str,
+        messages: list[dict[str, object]],
+        configuration: dict[str, object] | None,
+    ) -> object:
+        _ = (title, messages, configuration)
+        self.remote_opens.append((node_id, profile, cwd))
+        return GATEWAY.SessionIdentity(
+            "remote-stored", "remote:runtime", False
+        )
+
+    def resume_session_for_node(
+        self, node_id: str, profile: str, stored_id: str
+    ) -> object:
+        self.remote_opens.append((node_id, profile, ""))
+        return GATEWAY.SessionIdentity(stored_id, "remote:runtime", False)
+
+    def submit_prompt(self, runtime_id: str, text: str) -> None:
+        self.submissions.append((runtime_id, text))
+        self.callback(runtime_id, "message.delta", {"text": "远端完成"})
+        self.callback(
+            runtime_id,
+            "message.complete",
+            {"status": "complete", "text": "远端完成"},
+        )
+        self.callback(
+            runtime_id,
+            "session.info",
+            {
+                "running": False,
+                "stored_session_id": "remote-stored",
+                "profile_name": "default",
+            },
+        )
+
+
 if __name__ == "__main__":
     unittest.main()

+ 13 - 4
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -37,7 +37,7 @@ def new_id() -> str:
 
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated.initialize()
 
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             with migrated.connection() as connection:
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 12)
+            self.assertEqual(store.schema_version(), 13)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1536,7 +1536,16 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "instructions": "",
                 }
             )
-            self.assertEqual(dict(agent), {**expected_agent, "is_host": 1})
+            self.assertEqual(
+                dict(agent),
+                {
+                    **expected_agent,
+                    "is_host": 1,
+                    "node_id": "local",
+                    "node_label": "",
+                    "execution_profile": None,
+                },
+            )
             self.assertEqual(
                 (room["name"], room["max_reply_rounds"], room["archived"]),
                 ("保留群", 7, 1),

+ 331 - 3
package-lock.json

@@ -27,6 +27,7 @@
         "naive-ui": "^2.44.1",
         "pdfjs-dist": "^5.7.284",
         "pinia": "^3.0.4",
+        "qrcode": "^1.5.4",
         "read-excel-file": "^7.0.3",
         "vue": "^3.5.32",
         "vue-router": "^4.6.4",
@@ -46,6 +47,7 @@
         "@types/markdown-it": "^14.1.2",
         "@types/mime-types": "^2.1.4",
         "@types/node": "^24.10.0",
+        "@types/qrcode": "^1.5.6",
         "@types/supertest": "^6.0.3",
         "@types/ws": "^8.18.1",
         "@vitejs/plugin-vue": "^6.0.5",
@@ -2500,6 +2502,16 @@
         "undici-types": "~7.18.0"
       }
     },
+    "node_modules/@types/qrcode": {
+      "version": "1.5.6",
+      "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+      "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/qs": {
       "version": "6.15.1",
       "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
@@ -3239,6 +3251,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/chai": {
       "version": "5.3.3",
       "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
@@ -3282,6 +3303,87 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/cliui/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/cliui/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/cliui/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cliui/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/co": {
       "version": "4.6.0",
       "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz",
@@ -3312,7 +3414,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "color-name": "~1.1.4"
@@ -3325,7 +3426,6 @@
       "version": "1.1.4",
       "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/combined-stream": {
@@ -3602,6 +3702,15 @@
         }
       }
     },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/decimal.js": {
       "version": "10.6.0",
       "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -3681,6 +3790,12 @@
         "wrappy": "1"
       }
     },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
     "node_modules/docx-preview": {
       "version": "0.3.7",
       "resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
@@ -3936,6 +4051,19 @@
       "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
       "license": "MIT"
     },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/foreground-child": {
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -4067,6 +4195,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "license": "ISC",
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
     "node_modules/get-intrinsic": {
       "version": "1.3.0",
       "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4390,7 +4527,6 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -5145,6 +5281,18 @@
         "uc.micro": "^2.0.0"
       }
     },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.18.1",
       "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -5508,6 +5656,42 @@
       "resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
       "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
     },
+    "node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/package-json-from-dist": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5563,6 +5747,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -5723,6 +5916,15 @@
         "node": ">=20"
       }
     },
+    "node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.26",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
@@ -5783,6 +5985,23 @@
         "node": ">=6"
       }
     },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/qs": {
       "version": "6.15.3",
       "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -5863,6 +6082,15 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/require-from-string": {
       "version": "2.0.2",
       "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5873,6 +6101,12 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
     "node_modules/resolve-path": {
       "version": "1.4.0",
       "resolved": "https://registry.npmmirror.com/resolve-path/-/resolve-path-1.4.0.tgz",
@@ -6112,6 +6346,12 @@
         "node": ">=10"
       }
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
     "node_modules/setimmediate": {
       "version": "1.0.5",
       "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -7405,6 +7645,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
     "node_modules/why-is-node-running": {
       "version": "2.3.0",
       "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -7565,6 +7811,88 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/yargs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "license": "MIT"
+    },
+    "node_modules/yargs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yargs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/ylru": {
       "version": "1.4.0",
       "resolved": "https://registry.npmmirror.com/ylru/-/ylru-1.4.0.tgz",

+ 2 - 0
package.json

@@ -42,6 +42,7 @@
     "naive-ui": "^2.44.1",
     "pdfjs-dist": "^5.7.284",
     "pinia": "^3.0.4",
+    "qrcode": "^1.5.4",
     "read-excel-file": "^7.0.3",
     "vue": "^3.5.32",
     "vue-router": "^4.6.4",
@@ -58,6 +59,7 @@
     "@types/markdown-it": "^14.1.2",
     "@types/mime-types": "^2.1.4",
     "@types/node": "^24.10.0",
+    "@types/qrcode": "^1.5.6",
     "@types/supertest": "^6.0.3",
     "@types/ws": "^8.18.1",
     "@vitejs/plugin-vue": "^6.0.5",

+ 29 - 1
src/client/api/groups.ts

@@ -1,5 +1,5 @@
 import type {
-  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupRoomDetail, GroupRoomPage, GroupRun, GroupTopicPage, GroupTopicSummary, JsonValue, UploadReference,
+  GroupAgent, GroupCapabilities, GroupInteraction, GroupMessagePage, GroupNode, GroupRoomDetail, GroupRoomPage, GroupRun, GroupTopicPage, GroupTopicSummary, JsonValue, UploadReference,
 } from '@shared/types'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import {
@@ -16,6 +16,32 @@ export async function getGroupCapabilities(): Promise<GroupCapabilities> {
   return normalizeGroupCapabilities(unwrapData(await apiRequest<unknown>(`${BASE}/capabilities`)))
 }
 
+export async function getGroupNodes(): Promise<GroupNode[]> {
+  const payload = record(unwrapData(await apiRequest<unknown>(`${BASE}/nodes`)))
+  return values(payload.items ?? payload.nodes).flatMap(value => {
+    const node = record(value)
+    const nodeId = String(node.nodeId ?? node.node_id ?? '')
+    if (!nodeId) return []
+    return [{
+      nodeId,
+      name: String(node.name ?? nodeId),
+      serverUrl: String(node.serverUrl ?? node.server_url ?? ''),
+      fingerprint: String(node.fingerprint ?? ''),
+      createdAt: Number(node.createdAt ?? node.created_at ?? 0),
+      updatedAt: Number(node.updatedAt ?? node.updated_at ?? 0),
+      profiles: values(node.profiles).flatMap(profileValue => {
+        const profile = record(profileValue)
+        const name = String(profile.name ?? '')
+        return name ? [{
+          name,
+          displayName: String(profile.displayName ?? profile.display_name ?? name),
+          model: String(profile.model ?? ''),
+        }] : []
+      }),
+    }]
+  })
+}
+
 export async function getGroupRooms(cursor?: string, limit = 50, archived = false): Promise<GroupRoomPage> {
   const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)), archived })))
   const source = record(payload)
@@ -72,6 +98,8 @@ export async function markGroupTopicRead(roomId: string, topicId: string, throug
 
 export interface AgentSeed {
   profile: string
+  nodeId?: string
+  nodeLabel?: string
   displayName: string
   description?: string
   replyWithoutMention?: boolean

+ 60 - 0
src/client/api/pairing.ts

@@ -0,0 +1,60 @@
+import type { JsonValue } from '@shared/types'
+import { apiRequest } from './client'
+
+export type NodeScope =
+  | 'agents.read'
+  | 'history.read'
+  | 'sessions.execute'
+  | 'groups.read'
+  | 'groups.execute'
+
+export interface PairingSession {
+  protocolVersion: number
+  pairingId: string
+  nodeId: string
+  fingerprint: string
+  scopes: NodeScope[]
+  expiresAt: number
+  qrPayload: string
+}
+
+export interface PairedDevice {
+  id: string
+  name: string
+  scopes: NodeScope[]
+  createdAt: number
+  lastUsedAt: number
+}
+
+export interface PairedDevicesResponse {
+  nodeId: string
+  fingerprint: string
+  devices: PairedDevice[]
+}
+
+export async function createPairing(
+  scopes: NodeScope[],
+  username: string,
+  password: string,
+): Promise<PairingSession> {
+  return apiRequest<PairingSession>('/api/app/pairings', {
+    method: 'POST',
+    body: { scopes, username, password } as unknown as JsonValue,
+  })
+}
+
+export async function pairingStatus(
+  pairingId: string,
+): Promise<{ state: 'pending' | 'claimed' | 'expired'; deviceID?: string }> {
+  return apiRequest(`/api/app/pairings/${encodeURIComponent(pairingId)}`)
+}
+
+export async function pairedDevices(): Promise<PairedDevicesResponse> {
+  return apiRequest('/api/app/paired-devices')
+}
+
+export async function revokePairedDevice(deviceId: string): Promise<void> {
+  await apiRequest(`/api/app/paired-devices/${encodeURIComponent(deviceId)}`, {
+    method: 'DELETE', body: {} as JsonValue,
+  })
+}

+ 189 - 0
src/client/components/app/NodePairingDialog.vue

@@ -0,0 +1,189 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import QRCode from 'qrcode'
+import AppIcon from '@/components/common/AppIcon.vue'
+import {
+  createPairing,
+  pairedDevices,
+  pairingStatus,
+  revokePairedDevice,
+  type NodeScope,
+  type PairedDevice,
+  type PairingSession,
+} from '@/api/pairing'
+
+const props = withDefaults(defineProps<{ open: boolean; insecureTransport?: boolean; userName?: string }>(), {
+  insecureTransport: false,
+  userName: '',
+})
+const emit = defineEmits<{ close: [] }>()
+
+const ALL_SCOPES: NodeScope[] = [
+  'agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute',
+]
+const pairing = ref<PairingSession>()
+const qrImage = ref('')
+const devices = ref<PairedDevice[]>([])
+const nodeID = ref('')
+const busy = ref(false)
+const error = ref('')
+const username = ref('')
+const password = ref('')
+const now = ref(Date.now())
+let timer: number | undefined
+
+const secondsRemaining = computed(() => pairing.value
+  ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
+  : 0)
+
+function stopPolling() {
+  if (timer !== undefined) window.clearInterval(timer)
+  timer = undefined
+}
+
+async function refreshDevices() {
+  const response = await pairedDevices()
+  devices.value = response.devices
+  nodeID.value = response.nodeId
+}
+
+async function poll() {
+  now.value = Date.now()
+  const current = pairing.value
+  if (!current || secondsRemaining.value <= 0) {
+    stopPolling()
+    return
+  }
+  try {
+    const status = await pairingStatus(current.pairingId)
+    if (status.state === 'claimed') {
+      pairing.value = undefined
+      qrImage.value = ''
+      stopPolling()
+      await refreshDevices()
+    } else if (status.state === 'expired') {
+      stopPolling()
+    }
+  } catch {
+    // A transient poll failure must not discard a still-valid QR code.
+  }
+}
+
+async function beginPairing() {
+  busy.value = true
+  error.value = ''
+  stopPolling()
+  try {
+    const created = await createPairing(
+      ALL_SCOPES,
+      username.value.trim(),
+      password.value,
+    )
+    password.value = ''
+    pairing.value = created
+    now.value = Date.now()
+    qrImage.value = await QRCode.toDataURL(created.qrPayload, {
+      width: 296,
+      margin: 2,
+      errorCorrectionLevel: 'M',
+      color: { dark: '#191918', light: '#ffffff' },
+    })
+    timer = window.setInterval(() => { void poll() }, 1_000)
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '无法创建配对二维码'
+  } finally {
+    busy.value = false
+  }
+}
+
+async function revoke(device: PairedDevice) {
+  if (!window.confirm(`撤销“${device.name}”的 Hermes 访问权限?`)) return
+  busy.value = true
+  error.value = ''
+  try {
+    await revokePairedDevice(device.id)
+    await refreshDevices()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '撤销设备失败'
+  } finally {
+    busy.value = false
+  }
+}
+
+watch(() => props.open, open => {
+  stopPolling()
+  pairing.value = undefined
+  qrImage.value = ''
+  error.value = ''
+  password.value = ''
+  if (open) {
+    username.value = props.userName
+    busy.value = true
+    void refreshDevices()
+      .catch(cause => { error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
+      .finally(() => { busy.value = false })
+  }
+})
+
+onBeforeUnmount(stopPolling)
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="pairing-fade">
+      <div v-if="open" class="pairing-layer" role="presentation" @mousedown.self="emit('close')">
+        <section class="pairing-dialog" role="dialog" aria-modal="true" aria-labelledby="pairing-title">
+          <header>
+            <div><small>HERMES NODE</small><h2 id="pairing-title">手机与节点</h2></div>
+            <button class="icon-button" type="button" aria-label="关闭手机与节点" @click="emit('close')"><AppIcon name="close" /></button>
+          </header>
+
+          <p class="pairing-intro">扫描一次即可在 iOS 中使用这台 Hermes 的 Bots、聊天历史和原生群聊 Agent。</p>
+          <p v-if="insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="15" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
+          <p v-if="error" class="pairing-error">{{ error }}</p>
+
+          <div v-if="!pairing" class="pairing-credentials">
+            <label><span>Hermes 用户名</span><input v-model="username" autocomplete="username" /></label>
+            <label><span>密码</span><input v-model="password" type="password" autocomplete="current-password" @keydown.enter="beginPairing" /></label>
+            <small>密码只用于向 Hermes 创建一份独立的设备会话,不写入二维码,也不会由夭夭 Web 保存。</small>
+          </div>
+
+          <section v-if="pairing" class="qr-card">
+            <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
+            <strong>用夭夭 iOS 扫描</strong>
+            <span v-if="secondsRemaining">二维码将在 {{ secondsRemaining }} 秒后失效</span>
+            <span v-else>二维码已失效</span>
+            <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
+          </section>
+          <button v-else class="solid-button create-pairing" type="button" :disabled="busy || !username.trim() || !password" @click="beginPairing">
+            <AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成手机配对二维码' }}
+          </button>
+
+          <section class="device-section">
+            <div class="device-heading"><strong>已授权设备</strong><small v-if="nodeID">节点 {{ nodeID.slice(0, 8) }}</small></div>
+            <p v-if="!busy && !devices.length" class="device-empty">还没有已授权设备。</p>
+            <div v-for="device in devices" :key="device.id" class="device-row">
+              <span><b>{{ device.name }}</b><small>{{ device.scopes.includes('history.read') ? 'Bots、历史与群聊' : '受限访问' }}</small></span>
+              <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
+            </div>
+          </section>
+        </section>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.pairing-layer { position: fixed; z-index: 260; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(6px); }
+.pairing-dialog { width: min(470px, 100%); max-height: min(760px, calc(100vh - 36px)); overflow-y: auto; padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
+header { display: flex; align-items: flex-start; justify-content: space-between; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .08em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
+.pairing-intro { margin: 17px 0 14px; color: var(--text-secondary); font-size: 11px; line-height: 1.65; }
+.pairing-warning, .pairing-error { margin: 0 0 13px; padding: 10px; border-radius: 10px; font-size: 10px; line-height: 1.55; }.pairing-warning { display: flex; gap: 7px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }.pairing-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
+.create-pairing { width: 100%; min-height: 43px; gap: 8px; }
+.pairing-credentials { display: grid; gap: 10px; margin-bottom: 13px; }.pairing-credentials label { display: grid; gap: 5px; color: var(--text-secondary); font-size: 10px; }.pairing-credentials input { min-height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.pairing-credentials input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.pairing-credentials small { color: var(--text-muted); font-size: 9px; line-height: 1.5; }
+.qr-card { display: grid; justify-items: center; gap: 7px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }.qr-card img { width: min(296px, 100%); aspect-ratio: 1; }.qr-card strong { font-size: 12px; }.qr-card span { color: #686865; font-size: 10px; }.qr-card .quiet-button { color: #191918; }
+.device-section { margin-top: 19px; padding-top: 15px; border-top: 1px solid var(--line); }.device-heading { display: flex; justify-content: space-between; margin-bottom: 8px; }.device-heading strong { font-size: 11px; }.device-heading small, .device-empty { color: var(--text-muted); font-size: 9px; }.device-empty { margin: 12px 1px; }
+.device-row { display: flex; align-items: center; gap: 12px; min-height: 47px; padding: 6px 2px; border-bottom: 1px solid var(--line); }.device-row span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.device-row b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.device-row small { color: var(--text-muted); font-size: 9px; }.device-row button { border: 0; background: transparent; color: var(--danger); font-size: 10px; cursor: pointer; }
+.pairing-fade-enter-active, .pairing-fade-leave-active { transition: opacity 140ms ease; }.pairing-fade-enter-active .pairing-dialog, .pairing-fade-leave-active .pairing-dialog { transition: transform 170ms var(--ease-out); }.pairing-fade-enter-from, .pairing-fade-leave-to { opacity: 0; }.pairing-fade-enter-from .pairing-dialog, .pairing-fade-leave-to .pairing-dialog { transform: translateY(8px) scale(.985); }
+@media (max-width: 540px) { .pairing-layer { place-items: end center; padding: 0; }.pairing-dialog { width: 100%; max-height: calc(100vh - 12px); border-radius: 19px 19px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); } }
+</style>

+ 12 - 0
src/client/components/app/WorkspaceShell.vue

@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
+import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 type NavItem = {
@@ -18,6 +19,7 @@ const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
 
 const props = withDefaults(defineProps<{
   userName?: string
+  pairingUserName?: string
   profileName?: string
   profiles?: string[]
   theme?: 'light' | 'dark'
@@ -29,6 +31,7 @@ const props = withDefaults(defineProps<{
   inspectorCloseLabel?: string
 }>(), {
   userName: '',
+  pairingUserName: '',
   profileName: '',
   profiles: () => [],
   theme: 'light',
@@ -53,6 +56,7 @@ const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
+const nodePairingOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -257,6 +261,9 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
+          <button class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true">
+            <AppIcon name="users" :size="17" />
+          </button>
           <Transition name="menu-fade">
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
@@ -363,6 +370,9 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
+          <button class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
+            <AppIcon name="users" :size="17" />
+          </button>
           <Transition name="menu-fade">
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
@@ -399,6 +409,8 @@ onBeforeUnmount(() => {
         <slot name="inspector" />
       </aside>
     </Transition>
+
+    <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" @close="nodePairingOpen = false" />
   </div>
 </template>
 

+ 12 - 11
src/client/components/groups/CreateGroupDialog.vue

@@ -1,10 +1,11 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import type { GroupProfileOption } from './types'
 
 interface CreateGroupPayload {
   name: string
-  profiles: string[]
+  members: GroupProfileOption[]
   autoReply: boolean
   replyRounds: number
   instructions?: string
@@ -12,7 +13,7 @@ interface CreateGroupPayload {
   orchestrationMode?: 'free' | 'host'
 }
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
+const props = withDefaults(defineProps<{ open: boolean; profiles: GroupProfileOption[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; roomInstructionsEnabled?: boolean; busy?: boolean }>(), {
   hostEnabled: false,
   hostFlowEnabled: false,
   roomInstructionsEnabled: false,
@@ -36,16 +37,16 @@ watch(() => props.open, open => {
   if (!open) return
   name.value = ''
   instructions.value = ''
-  selected.value = props.profiles.slice(0, 1)
+  selected.value = props.profiles.slice(0, 1).map(profile => profile.id)
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
   replyRounds.value = 3
   orchestrationMode.value = 'free'
 }, { immediate: true })
 
-function toggle(profile: string) {
-  if (selected.value.includes(profile)) selected.value = selected.value.filter(item => item !== profile)
-  else if (selected.value.length < 8) selected.value.push(profile)
+function toggle(profile: GroupProfileOption) {
+  if (selected.value.includes(profile.id)) selected.value = selected.value.filter(item => item !== profile.id)
+  else if (selected.value.length < 8) selected.value.push(profile.id)
   if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
 }
 
@@ -53,7 +54,7 @@ function create() {
   if (!valid.value) return
   const payload: CreateGroupPayload = {
     name: name.value.trim(),
-    profiles: selected.value,
+    members: selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []),
     autoReply: autoReply.value,
     replyRounds: replyRounds.value,
   }
@@ -74,15 +75,15 @@ function create() {
           <label v-if="roomInstructionsEnabled" class="field room-instructions"><span>说明 <small>供所有 Agent 查阅的规则和形式准则</small></span><textarea v-model="instructions" maxlength="4000" rows="4" placeholder="例如:先确认事实;结论使用中文;涉及发布必须等待确认。" /></label>
           <div class="agent-picker">
             <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
-            <button v-for="profile in profiles" :key="profile" type="button" :class="{ selected: selected.includes(profile) }" :disabled="!selected.includes(profile) && selected.length >= 8" @click="toggle(profile)">
-              <span>{{ profile.slice(0, 1).toUpperCase() }}</span><strong>{{ profile }}</strong><AppIcon v-if="selected.includes(profile)" name="check" :size="15" />
+            <button v-for="profile in profiles" :key="profile.id" type="button" :class="{ selected: selected.includes(profile.id) }" :disabled="!selected.includes(profile.id) && selected.length >= 8" @click="toggle(profile)">
+              <span>{{ profile.displayName.slice(0, 1).toUpperCase() }}</span><strong>{{ profile.displayName }}<small>{{ profile.profile }} · {{ profile.nodeLabel }}</small></strong><AppIcon v-if="selected.includes(profile.id)" name="check" :size="15" />
             </button>
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
           </div>
           <label v-if="hostEnabled" class="host-picker">
             <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
             <select v-model="hostProfile" aria-label="主持人">
-              <option v-for="profile in selected" :key="profile" :value="profile">{{ profile }}</option>
+              <option v-for="profileID in selected" :key="profileID" :value="profileID">{{ profiles.find(profile => profile.id === profileID)?.displayName || profileID }}</option>
             </select>
           </label>
           <label v-if="hostFlowEnabled" class="host-picker">
@@ -108,7 +109,7 @@ function create() {
 .create-dialog { width: min(470px, 100%); max-height: min(680px, calc(100vh - 36px)); padding: 18px; overflow-y: auto; border: 1px solid var(--line); border-radius: 17px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
 header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18px; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .06em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
 .field { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: 10px; }.field span { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }.field small { color: var(--text-muted); font-size: 9px; font-weight: 400; }.field input, .field textarea { padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: none; resize: vertical; background: var(--surface-soft); color: var(--text-primary); }.field input { height: 38px; }.field textarea { min-height: 86px; padding-block: 9px; line-height: 1.5; }.field input:focus, .field textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.room-instructions { margin-top: 13px; }
-.agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button > span { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; }.agent-picker > button strong { flex: 1; font-size: 11px; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
+.agent-picker { margin-top: 17px; }.agent-picker__heading { display: flex; justify-content: space-between; margin-bottom: 7px; color: var(--text-secondary); font-size: 10px; }.agent-picker__heading small { color: var(--text-muted); }.agent-picker > button { display: flex; width: 100%; min-height: 42px; align-items: center; gap: 9px; padding: 5px 8px; border: 0; border-radius: 9px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }.agent-picker > button:hover, .agent-picker > button.selected { background: var(--surface-soft); }.agent-picker > button:disabled { opacity: .35; }.agent-picker > button > span { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; }.agent-picker > button strong { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; font-size: 11px; }.agent-picker > button strong small { overflow: hidden; color: var(--text-muted); font-size: 9px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }.agent-picker p { color: var(--text-muted); font-size: 10px; }
 .host-picker { display: grid; grid-template-columns: minmax(0, 1fr) 148px; align-items: center; gap: 12px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-secondary); font-size: 10px; }.host-picker > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; font-weight: 650; }.host-picker small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.45; }.host-picker select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font-size: 10px; }.host-picker select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .dialog-settings { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 15px; padding: 11px; border-radius: 10px; background: var(--surface-soft); }.dialog-settings label { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 10px; }.dialog-settings input[type="checkbox"] { accent-color: var(--accent); }.dialog-settings input[type="number"] { width: 51px; padding: 4px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); color: var(--text-primary); text-align: center; }
 footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }

+ 4 - 3
src/client/components/groups/GroupManager.vue

@@ -3,6 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
 import type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import type { UiRoom } from './types'
+import type { GroupProfileOption } from './types'
 
 type AgentSettingsPatch = Partial<Pick<GroupAgent,
   'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
@@ -37,7 +38,7 @@ const props = withDefaults(defineProps<{
   hostEnabled?: boolean
   hostFlowEnabled?: boolean
   roomInstructionsEnabled?: boolean
-  availableProfiles?: string[]
+  availableProfiles?: GroupProfileOption[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsError?: Record<string, string>
@@ -241,7 +242,7 @@ function statusLabel(status: GroupAgent['status']): string {
       <div class="agent-list">
         <article v-for="agent in agents" :key="agent.id">
           <span class="agent-avatar">{{ agent.displayName.slice(0, 1).toUpperCase() }}<i :class="`status-${agent.status}`" /></span>
-          <span class="agent-copy"><span class="agent-name-line"><strong>{{ agent.displayName }}</strong><em v-if="hostEnabled && agent.isHost" class="host-badge">主持人</em></span><small>{{ agent.profile }} · {{ statusLabel(agent.status) }}</small></span>
+          <span class="agent-copy"><span class="agent-name-line"><strong>{{ agent.displayName }}</strong><em v-if="hostEnabled && agent.isHost" class="host-badge">主持人</em></span><small>{{ agent.profile }}<template v-if="agent.nodeId !== 'local'"> · {{ agent.nodeLabel || agent.nodeId.slice(0, 8) }}</template> · {{ statusLabel(agent.status) }}</small></span>
           <button class="agent-action" type="button" :title="`设置 ${agent.displayName}`" :aria-label="`设置${agent.displayName}`" @click="openAgentSettings(agent)"><AppIcon name="settings" :size="14" /></button>
           <button v-if="agent.status === 'running' || agent.status === 'queued'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
           <button class="agent-action danger" type="button" :title="removeAgentTitle(agent)" :aria-label="`移除${agent.displayName}`" :disabled="!canRemoveAgent(agent) || busy" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
@@ -251,7 +252,7 @@ function statusLabel(status: GroupAgent['status']): string {
       <div v-if="agents.length < 8 && availableProfiles?.length" class="add-agent">
         <select aria-label="选择要添加的 Agent" @change="($event.target as HTMLSelectElement).value && emit('addAgent', ($event.target as HTMLSelectElement).value); ($event.target as HTMLSelectElement).value = ''">
           <option value="">添加 Agent…</option>
-          <option v-for="profile in availableProfiles" :key="profile" :value="profile">{{ profile }}</option>
+          <option v-for="profile in availableProfiles" :key="profile.id" :value="profile.id">{{ profile.displayName }} · {{ profile.nodeLabel }}</option>
         </select>
       </div>
     </section>

+ 8 - 0
src/client/components/groups/types.ts

@@ -9,6 +9,14 @@ export type UiAgent = {
   replyRounds?: number
 }
 
+export type GroupProfileOption = {
+  id: string
+  profile: string
+  displayName: string
+  nodeId: string
+  nodeLabel: string
+}
+
 export type UiRoom = {
   id: string
   name: string

+ 1 - 0
src/client/components/workspace/WorkspaceView.vue

@@ -28,6 +28,7 @@ function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.
 <template>
   <WorkspaceShell
     :user-name="userName"
+    :pairing-user-name="auth.user?.username || ''"
     :profile-name="auth.activeProfile?.agentName || auth.activeProfile?.displayName || profileName"
     :profiles="profileNames"
     :theme="theme.resolvedTheme"

+ 7 - 3
src/client/stores/groups.ts

@@ -2,12 +2,12 @@ import { computed, ref, shallowRef, watch } from 'vue'
 import { defineStore } from 'pinia'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type {
-  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
+  GroupAgent, GroupCapabilities, GroupMessage, GroupMessagePage, GroupNode, GroupRoomDetail, GroupRoomSummary, GroupRun, GroupSocketEnvelope, GroupTopicSummary, RealtimeConnectionState,
 } from '@shared/types'
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
-  getGroupMessages, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
+  getGroupMessages, getGroupNodes, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
   removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
@@ -43,6 +43,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const selectedRoomId = ref<string>()
   const selectedTopicId = ref<string>()
   const pinnedTopics = ref<GroupTopicSummary[]>([])
+  const nodes = ref<GroupNode[]>([])
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = ref(false)
   const isSending = ref(false)
@@ -214,6 +215,9 @@ export const useGroupsStore = defineStore('groups', () => {
           throw new Error(`群聊协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
         }
         const loadedRooms = await loadRooms()
+        nodes.value = anchor.features?.includes('nodeRegistry')
+          ? await getGroupNodes()
+          : []
         if (expectedGeneration !== generation) return
         await loadPinnedTopics()
         if (expectedGeneration !== generation) return
@@ -745,7 +749,7 @@ export const useGroupsStore = defineStore('groups', () => {
     availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
-    pinnedTopics, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
+    pinnedTopics, nodes, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
     removeAgent, sendMessage, loadOlder, interruptAgent, approveInteraction, clarifyInteraction,
   }
 })

+ 4 - 1
src/client/utils/normalize.ts

@@ -350,7 +350,10 @@ export function normalizeGroupAgent(value: unknown): GroupAgent {
   const rawFastMode = pick(source, 'fastMode', 'fast_mode')
   return {
     id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
-    profile: string(source.profile), displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
+    profile: string(source.profile),
+    nodeId: string(pick(source, 'nodeId', 'node_id'), 'local'),
+    nodeLabel: string(pick(source, 'nodeLabel', 'node_label')),
+    displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
     description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
     lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
     enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),

+ 34 - 11
src/client/views/GroupsView.vue

@@ -10,6 +10,7 @@ import ComposerShell from '@/components/composer/ComposerShell.vue'
 import type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
+import type { GroupProfileOption } from '@/components/groups/types'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue'
@@ -140,8 +141,24 @@ const hostAgent = computed(() => groups.hostProtocol ? groups.agents.find(agent
 const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
 const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
-const profiles = computed(() => auth.profiles.map(profile => profile.name))
-const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
+const profiles = computed<GroupProfileOption[]>(() => [
+  ...auth.profiles.map(profile => ({
+    id: `local|${profile.name}`,
+    profile: profile.name,
+    displayName: profile.agentName || profile.displayName || profile.name,
+    nodeId: 'local',
+    nodeLabel: '当前 Hermes',
+  })),
+  ...groups.nodes.flatMap(node => node.profiles.map(profile => ({
+    id: `${node.nodeId}|${profile.name}`,
+    profile: profile.name,
+    displayName: profile.displayName || profile.name,
+    nodeId: node.nodeId,
+    nodeLabel: node.name,
+  }))),
+])
+const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent =>
+  agent.profile === profile.profile && agent.nodeId === profile.nodeId)))
 const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
 const managerBusy = computed(() => groups.isLoading || Object.values(agentUpdateBusy.value).some(Boolean))
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
@@ -328,16 +345,18 @@ async function startTopic(roomId: string) {
   } catch { /* store publishes the error */ }
 }
 
-async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
-  const hostProfile = payload.hostProfile || payload.profiles[0]
+async function createRoom(payload: { name: string; members: GroupProfileOption[]; autoReply: boolean; replyRounds: number; instructions?: string; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
+  const hostProfile = payload.hostProfile || payload.members[0]?.id
   const detail = await groups.createRoom({
     name: payload.name,
     ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
-    agents: payload.profiles.map(profile => ({
-      profile,
-      displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
+    agents: payload.members.map(member => ({
+      profile: member.profile,
+      nodeId: member.nodeId,
+      nodeLabel: member.nodeLabel,
+      displayName: member.displayName,
       replyWithoutMention: payload.autoReply,
-      ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
+      ...(groups.hostProtocol ? { isHost: member.id === hostProfile } : {}),
     })),
     maxReplyRounds: payload.replyRounds,
     ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
@@ -365,11 +384,15 @@ async function updateRoom(patch: { name?: string; instructions?: string; replyRo
   })
 }
 
-async function addAgent(profile: string) {
+async function addAgent(profileID: string) {
   if (!groups.selectedRoom) return
+  const member = profiles.value.find(profile => profile.id === profileID)
+  if (!member) return
   await groups.addAgent(groups.selectedRoom.id, {
-    profile,
-    displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile,
+    profile: member.profile,
+    nodeId: member.nodeId,
+    nodeLabel: member.nodeLabel,
+    displayName: member.displayName,
     replyWithoutMention: true,
     ...(groups.hostProtocol ? { isHost: false } : {}),
   })

+ 12 - 2
src/server/app.ts

@@ -9,6 +9,7 @@ import type { ServerConfig } from './config.js'
 import { loadServerConfig } from './config.js'
 import { errorMessage, HttpError } from './errors.js'
 import { RealtimeLeaseStore } from './leases.js'
+import { NodePairingStore } from './pairing.js'
 import { createApiRouter } from './routes.js'
 import {
   applySecurityHeaders,
@@ -25,6 +26,7 @@ export interface ApplicationOptions {
   fetchImpl?: typeof fetch
   csrfSecret?: Buffer
   leases?: RealtimeLeaseStore
+  pairings?: NodePairingStore
   uploads?: UploadStore
 }
 
@@ -33,6 +35,7 @@ export interface ApplicationRuntime {
   config: ServerConfig
   csrf: CsrfProtection
   leases: RealtimeLeaseStore
+  pairings: NodePairingStore
   upstream: UpstreamClient
   uploads: UploadStore
   close(): void
@@ -71,6 +74,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   app.proxy = false
   const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
   const leases = options.leases ?? new RealtimeLeaseStore()
+  const pairings = options.pairings ?? new NodePairingStore(config.home)
   const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   const uploads = options.uploads ?? new UploadStore(config.home)
   try {
@@ -134,7 +138,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     },
   }))
 
-  const router = createApiRouter({ config, csrf, upstream, leases, uploads })
+  const router = createApiRouter({ config, csrf, upstream, leases, pairings, uploads })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(async (ctx, next) => {
@@ -152,6 +156,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     config,
     csrf,
     leases,
+    pairings,
     upstream,
     uploads,
     close: () => uploads.close(),
@@ -171,7 +176,12 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
-  const removeWebSockets = installWebSocketRelay(server, config, runtime.leases)
+  const removeWebSockets = installWebSocketRelay(
+    server,
+    config,
+    runtime.leases,
+    runtime.pairings,
+  )
   return {
     server,
     close: async () => {

+ 409 - 0
src/server/pairing.ts

@@ -0,0 +1,409 @@
+import {
+  createCipheriv,
+  createDecipheriv,
+  createHash,
+  randomBytes,
+  randomUUID,
+  timingSafeEqual,
+} from 'node:crypto'
+import {
+  mkdirSync,
+  readFileSync,
+  renameSync,
+  writeFileSync,
+} from 'node:fs'
+import { join } from 'node:path'
+import { HttpError } from './errors.js'
+
+const PAIRING_TTL_MS = 2 * 60 * 1_000
+const MAX_PENDING_PAIRINGS = 32
+const MAX_PAIRED_DEVICES = 64
+const TOKEN_BYTES = 32
+
+export const NODE_PAIRING_PROTOCOL_VERSION = 1
+export const DEFAULT_NODE_SCOPES = [
+  'agents.read',
+  'history.read',
+  'sessions.execute',
+  'groups.read',
+  'groups.execute',
+] as const
+
+export type NodeScope = typeof DEFAULT_NODE_SCOPES[number]
+
+interface PersistedDevice {
+  id: string
+  name: string
+  tokenHash: string
+  encryptedCookie: string
+  scopes: NodeScope[]
+  createdAt: number
+  lastUsedAt: number
+}
+
+interface PersistedPairingState {
+  version: 1
+  nodeID: string
+  devices: PersistedDevice[]
+}
+
+interface PendingPairing {
+  id: string
+  secretHash: Buffer
+  cookieHeader: string
+  scopes: NodeScope[]
+  createdAt: number
+  expiresAt: number
+  claimedDeviceID?: string
+}
+
+export interface PairingSession {
+  id: string
+  secret: string
+  nodeID: string
+  fingerprint: string
+  scopes: NodeScope[]
+  createdAt: number
+  expiresAt: number
+}
+
+export interface PairedDeviceSummary {
+  id: string
+  name: string
+  scopes: NodeScope[]
+  createdAt: number
+  lastUsedAt: number
+}
+
+export interface PairingClaim {
+  pairingID: string
+  secret: string
+  deviceName: string
+}
+
+export interface PairingClaimResult {
+  device: PairedDeviceSummary
+  token: string
+  nodeID: string
+  fingerprint: string
+  scopes: NodeScope[]
+}
+
+function hashSecret(value: string): Buffer {
+  return createHash('sha256').update(value, 'utf8').digest()
+}
+
+function canonicalDeviceName(value: string): string {
+  const normalized = value.trim().replace(/\s+/g, ' ')
+  if (!normalized || normalized.length > 100 || /[\u0000-\u001f\u007f]/.test(normalized)) {
+    throw new HttpError(400, 'Device name is invalid', 'invalid_device_name')
+  }
+  return normalized
+}
+
+function canonicalPairingID(value: string): string {
+  const normalized = value.trim().toLowerCase()
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
+    throw new HttpError(400, 'Pairing ID is invalid', 'invalid_pairing')
+  }
+  return normalized
+}
+
+function canonicalDeviceID(value: string): string {
+  const normalized = value.trim().toLowerCase()
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
+    throw new HttpError(400, 'Device ID is invalid', 'invalid_device')
+  }
+  return normalized
+}
+
+function parseScopes(value: readonly string[] | undefined): NodeScope[] {
+  const requested = value ?? [...DEFAULT_NODE_SCOPES]
+  const allowed = new Set<string>(DEFAULT_NODE_SCOPES)
+  const result: NodeScope[] = []
+  for (const scope of requested) {
+    if (!allowed.has(scope)) {
+      throw new HttpError(400, `Unsupported node scope: ${scope}`, 'invalid_scope')
+    }
+    if (!result.includes(scope as NodeScope)) result.push(scope as NodeScope)
+  }
+  if (result.length === 0) throw new HttpError(400, 'At least one node scope is required', 'invalid_scope')
+  return result
+}
+
+function persistentSecret(home: string): Buffer {
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const path = join(home, 'node-pairing-secret.bin')
+  try {
+    const existing = readFileSync(path)
+    if (existing.byteLength !== 32) throw new Error('Node pairing secret has an invalid length')
+    return existing
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+  }
+  const generated = randomBytes(32)
+  try {
+    writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
+    return generated
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
+    throw error
+  }
+}
+
+export class NodePairingStore {
+  readonly #home: string
+  readonly #secret: Buffer
+  readonly #statePath: string
+  readonly #clock: () => number
+  readonly #pending = new Map<string, PendingPairing>()
+  #state: PersistedPairingState
+
+  constructor(home: string, clock: () => number = Date.now) {
+    this.#home = home
+    this.#secret = persistentSecret(home)
+    this.#statePath = join(home, 'paired-devices.json')
+    this.#clock = clock
+    this.#state = this.#load()
+  }
+
+  get nodeID(): string { return this.#state.nodeID }
+
+  get fingerprint(): string {
+    return createHash('sha256')
+      .update(this.#state.nodeID, 'utf8')
+      .update(this.#secret)
+      .digest('base64url')
+  }
+
+  create(cookieHeader: string | undefined, requestedScopes?: readonly string[]): PairingSession {
+    const cookies = cookieHeader?.trim() ?? ''
+    if (!cookies) {
+      throw new HttpError(401, 'An authenticated Hermes session is required', 'authentication_required')
+    }
+    this.#prunePending()
+    while (this.#pending.size >= MAX_PENDING_PAIRINGS) {
+      const oldest = this.#pending.keys().next().value as string | undefined
+      if (!oldest) break
+      this.#pending.delete(oldest)
+    }
+    const id = randomUUID().toLowerCase()
+    const secret = randomBytes(TOKEN_BYTES).toString('base64url')
+    const now = this.#clock()
+    const pending: PendingPairing = {
+      id,
+      secretHash: hashSecret(`${id}:${secret}`),
+      cookieHeader: cookies,
+      scopes: parseScopes(requestedScopes),
+      createdAt: now,
+      expiresAt: now + PAIRING_TTL_MS,
+    }
+    this.#pending.set(id, pending)
+    return {
+      id,
+      secret,
+      nodeID: this.nodeID,
+      fingerprint: this.fingerprint,
+      scopes: [...pending.scopes],
+      createdAt: now,
+      expiresAt: pending.expiresAt,
+    }
+  }
+
+  status(pairingID: string): { state: 'pending' | 'claimed' | 'expired'; deviceID?: string } {
+    const id = canonicalPairingID(pairingID)
+    const pending = this.#pending.get(id)
+    if (!pending || pending.expiresAt <= this.#clock()) {
+      this.#pending.delete(id)
+      return { state: 'expired' }
+    }
+    return pending.claimedDeviceID
+      ? { state: 'claimed', deviceID: pending.claimedDeviceID }
+      : { state: 'pending' }
+  }
+
+  claim(input: PairingClaim): PairingClaimResult {
+    this.#prunePending()
+    const id = canonicalPairingID(input.pairingID)
+    const pending = this.#pending.get(id)
+    if (!pending || pending.expiresAt <= this.#clock()) {
+      this.#pending.delete(id)
+      throw new HttpError(410, 'Pairing code expired', 'pairing_expired')
+    }
+    if (pending.claimedDeviceID) {
+      throw new HttpError(409, 'Pairing code was already used', 'pairing_already_used')
+    }
+    const supplied = hashSecret(`${id}:${input.secret}`)
+    if (supplied.byteLength !== pending.secretHash.byteLength
+      || !timingSafeEqual(supplied, pending.secretHash)) {
+      throw new HttpError(401, 'Pairing secret is invalid', 'invalid_pairing_secret')
+    }
+    if (this.#state.devices.length >= MAX_PAIRED_DEVICES) {
+      throw new HttpError(409, 'Paired device limit reached', 'paired_device_limit')
+    }
+    const now = this.#clock()
+    const token = randomBytes(TOKEN_BYTES).toString('base64url')
+    const device: PersistedDevice = {
+      id: randomUUID().toLowerCase(),
+      name: canonicalDeviceName(input.deviceName),
+      tokenHash: hashSecret(token).toString('base64url'),
+      encryptedCookie: this.#encrypt(pending.cookieHeader),
+      scopes: [...pending.scopes],
+      createdAt: now,
+      lastUsedAt: now,
+    }
+    this.#state.devices.push(device)
+    pending.claimedDeviceID = device.id
+    pending.cookieHeader = ''
+    this.#save()
+    return {
+      device: this.#summary(device),
+      token,
+      nodeID: this.nodeID,
+      fingerprint: this.fingerprint,
+      scopes: [...device.scopes],
+    }
+  }
+
+  list(): PairedDeviceSummary[] {
+    return this.#state.devices
+      .map((device) => this.#summary(device))
+      .sort((left, right) => right.lastUsedAt - left.lastUsedAt)
+  }
+
+  hasDevice(deviceID: string): boolean {
+    try {
+      const id = canonicalDeviceID(deviceID)
+      return this.#state.devices.some((device) => device.id === id)
+    } catch {
+      return false
+    }
+  }
+
+  delegatedCookies(deviceID: string): string {
+    const id = canonicalDeviceID(deviceID)
+    const device = this.#state.devices.find((candidate) => candidate.id === id)
+    if (!device) throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
+    return this.#decrypt(device.encryptedCookie)
+  }
+
+  revoke(deviceID: string): boolean {
+    const id = canonicalDeviceID(deviceID)
+    const before = this.#state.devices.length
+    this.#state.devices = this.#state.devices.filter((device) => device.id !== id)
+    if (this.#state.devices.length === before) return false
+    this.#save()
+    return true
+  }
+
+  authorize(deviceID: string, token: string, requiredScope?: NodeScope): string {
+    const id = canonicalDeviceID(deviceID)
+    const normalizedToken = token.trim()
+    if (!normalizedToken || normalizedToken.length > 512) {
+      throw new HttpError(401, 'Paired device token is missing', 'paired_device_unauthorized')
+    }
+    const device = this.#state.devices.find((candidate) => candidate.id === id)
+    if (!device) throw new HttpError(401, 'Paired device is not recognized', 'paired_device_unauthorized')
+    const expected = Buffer.from(device.tokenHash, 'base64url')
+    const supplied = hashSecret(normalizedToken)
+    if (expected.byteLength !== supplied.byteLength || !timingSafeEqual(expected, supplied)) {
+      throw new HttpError(401, 'Paired device token is invalid', 'paired_device_unauthorized')
+    }
+    if (requiredScope && !device.scopes.includes(requiredScope)) {
+      throw new HttpError(403, `Paired device lacks ${requiredScope}`, 'paired_device_forbidden')
+    }
+    device.lastUsedAt = this.#clock()
+    this.#save()
+    return this.#decrypt(device.encryptedCookie)
+  }
+
+  updateCookies(deviceID: string, cookieHeader: string | undefined): void {
+    const id = canonicalDeviceID(deviceID)
+    const device = this.#state.devices.find((candidate) => candidate.id === id)
+    if (!device || !cookieHeader?.trim()) return
+    device.encryptedCookie = this.#encrypt(cookieHeader.trim())
+    device.lastUsedAt = this.#clock()
+    this.#save()
+  }
+
+  #summary(device: PersistedDevice): PairedDeviceSummary {
+    return {
+      id: device.id,
+      name: device.name,
+      scopes: [...device.scopes],
+      createdAt: device.createdAt,
+      lastUsedAt: device.lastUsedAt,
+    }
+  }
+
+  #prunePending(): void {
+    const now = this.#clock()
+    for (const [id, pending] of this.#pending) {
+      if (pending.expiresAt <= now) this.#pending.delete(id)
+    }
+  }
+
+  #encrypt(value: string): string {
+    const iv = randomBytes(12)
+    const cipher = createCipheriv('aes-256-gcm', this.#secret, iv)
+    const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
+    return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url')
+  }
+
+  #decrypt(value: string): string {
+    try {
+      const encoded = Buffer.from(value, 'base64url')
+      if (encoded.byteLength < 29) throw new Error('encrypted cookie is truncated')
+      const iv = encoded.subarray(0, 12)
+      const tag = encoded.subarray(12, 28)
+      const ciphertext = encoded.subarray(28)
+      const decipher = createDecipheriv('aes-256-gcm', this.#secret, iv)
+      decipher.setAuthTag(tag)
+      return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
+    } catch {
+      throw new HttpError(401, 'Paired device credentials are unavailable', 'paired_device_credentials_invalid')
+    }
+  }
+
+  #load(): PersistedPairingState {
+    try {
+      const parsed = JSON.parse(readFileSync(this.#statePath, 'utf8')) as Partial<PersistedPairingState>
+      if (parsed.version !== 1
+        || typeof parsed.nodeID !== 'string'
+        || !Array.isArray(parsed.devices)) {
+        throw new Error('paired device state is invalid')
+      }
+      return {
+        version: 1,
+        nodeID: canonicalDeviceID(parsed.nodeID),
+        devices: parsed.devices as PersistedDevice[],
+      }
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      const state: PersistedPairingState = {
+        version: 1,
+        nodeID: randomUUID().toLowerCase(),
+        devices: [],
+      }
+      this.#state = state
+      this.#save()
+      return state
+    }
+  }
+
+  #save(): void {
+    mkdirSync(this.#home, { recursive: true, mode: 0o700 })
+    const temporary = `${this.#statePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
+    writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, {
+      mode: 0o600,
+      flag: 'wx',
+    })
+    renameSync(temporary, this.#statePath)
+  }
+}
+
+export function bearerToken(value: string | undefined): string {
+  const match = value?.match(/^Bearer ([A-Za-z0-9_-]{20,512})$/)
+  if (!match) throw new HttpError(401, 'Paired device bearer token is required', 'paired_device_unauthorized')
+  return match[1]!
+}

+ 319 - 0
src/server/routes.ts

@@ -9,6 +9,13 @@ import type { ServerConfig } from './config.js'
 import { isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import {
+  bearerToken,
+  DEFAULT_NODE_SCOPES,
+  NODE_PAIRING_PROTOCOL_VERSION,
+  type NodeScope,
+  NodePairingStore,
+} from './pairing.js'
 import { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
 import {
   applyUpstreamCookies,
@@ -27,6 +34,7 @@ export interface RouteDependencies {
   csrf: CsrfProtection
   upstream: UpstreamClient
   leases: RealtimeLeaseStore
+  pairings: NodePairingStore
   uploads: UploadStore
 }
 
@@ -38,6 +46,28 @@ function body(ctx: Koa.Context): JsonObject {
   return value as JsonObject
 }
 
+function optionalBody(ctx: Koa.Context): JsonObject | undefined {
+  const value = (ctx.request as Koa.Request & { body?: unknown }).body
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? value as JsonObject
+    : undefined
+}
+
+async function boundedRawBody(ctx: Koa.Context, maximum: number): Promise<Buffer> {
+  const chunks: Buffer[] = []
+  let size = 0
+  for await (const raw of ctx.req) {
+    const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array)
+    size += chunk.byteLength
+    if (size > maximum) {
+      throw new HttpError(413, 'Paired node attachment is too large', 'node_attachment_too_large')
+    }
+    chunks.push(chunk)
+  }
+  if (size === 0) throw new HttpError(400, 'Paired node attachment is empty', 'invalid_node_attachment')
+  return Buffer.concat(chunks, size)
+}
+
 function parseJson(response: UpstreamResponse): JsonObject {
   try {
     const value = JSON.parse(response.body.toString('utf8')) as unknown
@@ -142,6 +172,53 @@ function json(ctx: Koa.Context, status: number, value: unknown): void {
   ctx.body = value
 }
 
+function requestOrigin(ctx: Koa.Context, config: ServerConfig): string {
+  const secure = ctx.secure || Boolean(config.tlsCert)
+  return `${secure ? 'https' : 'http'}://${ctx.host}`
+}
+
+function pairingScopes(value: unknown): string[] | undefined {
+  if (value === undefined) return undefined
+  if (!Array.isArray(value) || !value.every((scope) => typeof scope === 'string')) {
+    throw new HttpError(400, 'scopes must be a string array', 'invalid_scope')
+  }
+  return value
+}
+
+function pairedProxyScope(path: string, method: string): NodeScope {
+  if (path === '/profiles' || path.startsWith('/profiles/')) {
+    return path.includes('/sessions') ? 'history.read' : 'agents.read'
+  }
+  if (path === '/sessions' || path.startsWith('/sessions/')) {
+    return ['GET', 'HEAD'].includes(method) ? 'history.read' : 'sessions.execute'
+  }
+  if (path.startsWith('/plugins/yaoyao/v1/')) {
+    return ['GET', 'HEAD'].includes(method) ? 'groups.read' : 'groups.execute'
+  }
+  return 'sessions.execute'
+}
+
+function pairedProxyPath(rawPath: string): string {
+  const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
+  if (path.includes('\\') || path.includes('\u0000') || path.length > 2_048) {
+    throw new HttpError(400, 'Paired node path is invalid', 'invalid_node_path')
+  }
+  const allowed = path === '/status'
+    || path === '/auth/me'
+    || path === '/auth/ws-ticket'
+    || path === '/profiles'
+    || path.startsWith('/profiles/')
+    || path === '/sessions'
+    || path.startsWith('/sessions/')
+    || path.startsWith('/model/')
+    || path.startsWith('/models/')
+    || path.startsWith('/files/')
+    || path.startsWith('/attachments/')
+    || path.startsWith('/plugins/yaoyao/')
+  if (!allowed) throw new HttpError(404, 'Paired node route is not available', 'node_route_not_found')
+  return `/api${path}`
+}
+
 async function proxy(
   ctx: Koa.Context,
   upstream: UpstreamClient,
@@ -283,6 +360,58 @@ async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: Coo
   await bootstrap(ctx, dependencies, jar, true)
 }
 
+async function independentPairingCookies(
+  ctx: Koa.Context,
+  dependencies: RouteDependencies,
+  authenticatedJar: CookieJar,
+  request: JsonObject | undefined,
+): Promise<string> {
+  const status = requireSuccess(
+    await dependencies.upstream.request('/api/status', authenticatedJar),
+  )
+  if (!authRequired(status)) {
+    const cookies = authenticatedJar.header
+    if (!cookies) throw new HttpError(401, 'Hermes session is unavailable', 'authentication_required')
+    return cookies
+  }
+  const username = typeof request?.username === 'string' ? request.username.trim() : ''
+  const password = typeof request?.password === 'string' ? request.password : ''
+  if (!username || !password || username.length > 320 || password.length > 4_096) {
+    throw new HttpError(
+      400,
+      'Hermes username and password are required to create an independent paired-device session',
+      'pairing_credentials_required',
+    )
+  }
+  const jar = new CookieJar('')
+  const providersBody = requireSuccess(
+    await dependencies.upstream.request('/api/auth/providers', jar),
+  )
+  const providers = Array.isArray(providersBody.providers) ? providersBody.providers : []
+  const provider = providers.find((entry) => Boolean(
+    entry && typeof entry === 'object' && !Array.isArray(entry)
+      && (entry as JsonObject).supports_password === true
+      && String((entry as JsonObject).name).toLowerCase() === 'basic',
+  )) as JsonObject | undefined
+  if (!provider || typeof provider.name !== 'string') {
+    throw new HttpError(
+      403,
+      'Hermes does not offer independent password sessions for QR pairing',
+      'pairing_login_unavailable',
+    )
+  }
+  const response = await dependencies.upstream.request('/auth/password-login', jar, {
+    method: 'POST',
+    body: { provider: provider.name, username, password, next: '' },
+    clientAddress: ctx.req.socket.remoteAddress,
+  })
+  const result = requireSuccess(response)
+  if (result.ok !== true) throw new HttpError(401, 'Hermes rejected the pairing login', 'pairing_login_failed')
+  await requireGatewayAuthentication(ctx, dependencies, jar)
+  if (!jar.header) throw new HttpError(502, 'Hermes did not issue paired-device cookies', 'pairing_session_missing')
+  return jar.header
+}
+
 async function requireGatewayAuthentication(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
@@ -456,6 +585,179 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { ok: true })
   })
 
+  router.post('/api/app/pairings', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      const request = optionalBody(ctx)
+      const cookieHeader = await independentPairingCookies(
+        ctx,
+        dependencies,
+        jar,
+        request,
+      )
+      const pairing = dependencies.pairings.create(
+        cookieHeader,
+        pairingScopes(request?.scopes),
+      )
+      const origin = requestOrigin(ctx, dependencies.config)
+      const deepLink = new URL('yaoyao://pair')
+      deepLink.searchParams.set('v', String(NODE_PAIRING_PROTOCOL_VERSION))
+      deepLink.searchParams.set('url', origin)
+      deepLink.searchParams.set('node', pairing.nodeID)
+      deepLink.searchParams.set('id', pairing.id)
+      deepLink.searchParams.set('secret', pairing.secret)
+      deepLink.searchParams.set('fingerprint', pairing.fingerprint)
+      json(ctx, 201, {
+        protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+        pairingId: pairing.id,
+        nodeId: pairing.nodeID,
+        fingerprint: pairing.fingerprint,
+        scopes: pairing.scopes,
+        expiresAt: pairing.expiresAt,
+        qrPayload: deepLink.toString(),
+      })
+    })
+  })
+
+  router.get('/api/app/pairings/:pairingID', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      json(ctx, 200, dependencies.pairings.status(ctx.params.pairingID))
+    })
+  })
+
+  router.get('/api/app/paired-devices', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      json(ctx, 200, {
+        nodeId: dependencies.pairings.nodeID,
+        fingerprint: dependencies.pairings.fingerprint,
+        devices: dependencies.pairings.list(),
+      })
+    })
+  })
+
+  router.delete('/api/app/paired-devices/:deviceID', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      try {
+        const delegated = new CookieJar(
+          dependencies.pairings.delegatedCookies(ctx.params.deviceID),
+        )
+        await dependencies.upstream.request('/auth/logout', delegated, {
+          method: 'POST', clientAddress: ctx.req.socket.remoteAddress,
+        })
+      } catch {
+        // Revocation of the local delegation remains authoritative even when
+        // Hermes is temporarily unreachable; its cookie is never returned.
+      }
+      if (!dependencies.pairings.revoke(ctx.params.deviceID)) {
+        throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
+      }
+      json(ctx, 200, { ok: true })
+    })
+  })
+
+  router.post('/api/pair/v1/claim', (ctx) => {
+    const request = body(ctx)
+    const pairingID = typeof request.pairingId === 'string' ? request.pairingId : ''
+    const secret = typeof request.secret === 'string' ? request.secret : ''
+    const deviceName = typeof request.deviceName === 'string' ? request.deviceName : ''
+    const claimed = dependencies.pairings.claim({ pairingID, secret, deviceName })
+    const origin = requestOrigin(ctx, dependencies.config)
+    json(ctx, 201, {
+      protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+      nodeId: claimed.nodeID,
+      fingerprint: claimed.fingerprint,
+      deviceId: claimed.device.id,
+      deviceName: claimed.device.name,
+      token: claimed.token,
+      scopes: claimed.scopes,
+      serverUrl: `${origin}/node/${claimed.device.id}`,
+    })
+  })
+
+  router.get('/api/pair/v1/capabilities', (ctx) => {
+    json(ctx, 200, {
+      protocolVersion: NODE_PAIRING_PROTOCOL_VERSION,
+      nodeId: dependencies.pairings.nodeID,
+      fingerprint: dependencies.pairings.fingerprint,
+      scopes: [...DEFAULT_NODE_SCOPES],
+      features: ['bots', 'bot-group', 'native-group-worker', 'history'],
+    })
+  })
+
+  router.delete('/api/pair/v1/devices/:deviceID', async (ctx) => {
+    const token = bearerToken(ctx.get('authorization'))
+    const cookies = dependencies.pairings.authorize(ctx.params.deviceID, token)
+    try {
+      await dependencies.upstream.request('/auth/logout', new CookieJar(cookies), {
+        method: 'POST', clientAddress: ctx.req.socket.remoteAddress,
+      })
+    } catch {
+      // The paired bearer is revoked locally even if upstream logout fails.
+    }
+    if (!dependencies.pairings.revoke(ctx.params.deviceID)) {
+      throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
+    }
+    json(ctx, 200, { ok: true })
+  })
+
+  router.all('/node/:deviceID/api/*nodePath', async (ctx) => {
+    const rawPath = Array.isArray(ctx.params.nodePath)
+      ? ctx.params.nodePath.join('/')
+      : ctx.params.nodePath
+    const path = pairedProxyPath(rawPath)
+    const scope = pairedProxyScope(path.slice('/api'.length), ctx.method)
+    const token = bearerToken(ctx.get('authorization'))
+    const cookieHeader = dependencies.pairings.authorize(
+      ctx.params.deviceID,
+      token,
+      scope,
+    )
+    if (ctx.querystring.length > 8_192) {
+      throw new HttpError(414, 'Paired node query is too long', 'node_query_too_long')
+    }
+    const jar = new CookieJar(cookieHeader)
+    const isWorkerAttachment = ctx.method === 'POST'
+      && /\/plugins\/yaoyao\/v1\/node-worker\/sessions\/[^/]+\/attachments$/.test(path)
+      && ctx.get('content-type').split(';', 1)[0]?.trim().toLowerCase() === 'application/octet-stream'
+    let rawBody: BodyInit | undefined
+    let requestHeaders: Record<string, string> = {
+      'x-yaoyao-node-client': ctx.params.deviceID,
+    }
+    if (isWorkerAttachment) {
+      const encodedName = ctx.get('x-file-name-b64')
+      const mimeType = ctx.get('x-mime-type') || 'application/octet-stream'
+      if (!/^[A-Za-z0-9_-]{2,512}$/.test(encodedName)
+        || !/^[\x20-\x7e]{1,200}$/.test(mimeType)) {
+        throw new HttpError(400, 'Paired node attachment headers are invalid', 'invalid_node_attachment')
+      }
+      const buffer = await boundedRawBody(ctx, 25 * 1_024 * 1_024)
+      rawBody = buffer.buffer.slice(
+        buffer.byteOffset,
+        buffer.byteOffset + buffer.byteLength,
+      ) as ArrayBuffer
+      requestHeaders = {
+        ...requestHeaders,
+        'content-type': 'application/octet-stream',
+        'x-file-name-b64': encodedName,
+        'x-mime-type': mimeType,
+      }
+    }
+    const response = await dependencies.upstream.request(path, jar, {
+      method: ctx.method,
+      search: new URLSearchParams(ctx.querystring),
+      body: ['GET', 'HEAD'].includes(ctx.method) || isWorkerAttachment
+        ? undefined : optionalBody(ctx),
+      rawBody,
+      headers: requestHeaders,
+      clientAddress: ctx.req.socket.remoteAddress,
+    })
+    dependencies.pairings.updateCookies(ctx.params.deviceID, jar.header)
+    sendUpstreamResponse(ctx, response, jar)
+  })
+
   // Historical messages can contain Markdown links such as
   // /Users/<owner>/Agents/<path>. Map only the configured media root, never
   // an arbitrary local filesystem path.
@@ -630,6 +932,23 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.get('/api/app/groups/capabilities', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/capabilities')
   })
+  router.get('/api/app/groups/nodes', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes')
+  })
+  router.post('/api/app/groups/nodes', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes', {
+      method: 'POST', requestBody: body(ctx),
+    })
+  })
+  router.delete('/api/app/groups/nodes/:nodeID', async (ctx) => {
+    const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
+    await proxy(
+      ctx,
+      dependencies.upstream,
+      `/api/plugins/yaoyao/v1/nodes/${nodeID}`,
+      { method: 'DELETE', requestBody: body(ctx) },
+    )
+  })
   router.get('/api/app/groups/rooms', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
       search: searchFrom(ctx, ['limit', 'cursor', 'archived']),

+ 208 - 3
src/server/websocket.ts

@@ -4,6 +4,7 @@ import WebSocket, { WebSocketServer, type RawData } from 'ws'
 import type { ServerConfig } from './config.js'
 import { HttpError } from './errors.js'
 import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import type { NodePairingStore } from './pairing.js'
 import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
 
 const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
@@ -16,6 +17,9 @@ const CHAT_METHODS = new Set([
   'file.attach',
   'image.attach_bytes',
   'pdf.attach',
+  'profiles.configure',
+  'profiles.get_asset',
+  'profiles.list',
   'prompt.submit',
   'session.branch',
   'session.close',
@@ -137,7 +141,55 @@ function normalizedSessionOpenParams(
   }
 }
 
-function checkedChatFrame(data: RawData, isBinary: boolean): string {
+function normalizedPairedSessionOpenParams(
+  method: 'session.create' | 'session.resume',
+  params: Record<string, unknown>,
+): Record<string, unknown> {
+  const profile = safeString(params.profile, 'profile', true)!
+  const source = safeString(params.source, 'source') ?? 'mobile'
+  const columns = typeof params.cols === 'number' && Number.isInteger(params.cols)
+    ? Math.min(500, Math.max(20, params.cols))
+    : 80
+  if (method === 'session.resume') {
+    return {
+      session_id: safeString(params.session_id, 'session_id', true)!,
+      profile,
+      source,
+      close_on_disconnect: false,
+      omit_messages: params.omit_messages === true,
+      cols: columns,
+    }
+  }
+  const title = safeString(params.title, 'title')
+  const cwd = typeof params.cwd === 'string' && params.cwd.length <= 4_096 ? params.cwd : ''
+  const reasoning = safeString(params.reasoning_effort, 'reasoning effort')
+  const allowedReasoning = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
+  if (reasoning && !allowedReasoning.has(reasoning)) throw new HttpError(400, 'Reasoning effort is invalid')
+  const model = safeString(params.model, 'model')
+  const provider = safeString(params.provider, 'provider')
+  if ((model === undefined) !== (provider === undefined)) {
+    throw new HttpError(400, 'Model and provider must be set together')
+  }
+  const messages = params.messages
+  if (messages !== undefined && (!Array.isArray(messages) || messages.length > 256)) {
+    throw new HttpError(400, 'Seed messages are invalid')
+  }
+  return {
+    profile,
+    source,
+    close_on_disconnect: false,
+    cols: columns,
+    cwd,
+    hidden: params.hidden === true,
+    ...(title ? { title } : {}),
+    ...(reasoning ? { reasoning_effort: reasoning } : {}),
+    ...(model && provider ? { model, provider } : {}),
+    ...(typeof params.fast === 'boolean' ? { fast: params.fast } : {}),
+    ...(messages !== undefined ? { messages } : {}),
+  }
+}
+
+function checkedChatFrame(data: RawData, isBinary: boolean, paired = false): string {
   if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
   const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
   if (bytes.byteLength > CHAT_MAX_PAYLOAD) throw new HttpError(413, 'Chat frame is too large')
@@ -165,7 +217,43 @@ function checkedChatFrame(data: RawData, isBinary: boolean): string {
   const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
   safeString(params.profile, 'profile', profileRequired)
   if (request.method === 'session.create' || request.method === 'session.resume') {
-    params = normalizedSessionOpenParams(request.method, params)
+    params = paired
+      ? normalizedPairedSessionOpenParams(request.method, params)
+      : normalizedSessionOpenParams(request.method, params)
+  }
+  if (request.method === 'profiles.list') {
+    const preferred = params.preferred_session_ids
+    if (preferred !== undefined && (!preferred || typeof preferred !== 'object' || Array.isArray(preferred))) {
+      throw new HttpError(400, 'preferred_session_ids must be an object')
+    }
+    params = {
+      ...(typeof params.include_sessions === 'boolean' ? { include_sessions: params.include_sessions } : {}),
+      ...(preferred !== undefined ? { preferred_session_ids: preferred } : {}),
+    }
+  }
+  if (request.method === 'profiles.get_asset') {
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      asset: safeString(params.asset, 'profile asset', true)!,
+    }
+  }
+  if (request.method === 'profiles.configure') {
+    if (!params.ui_meta || typeof params.ui_meta !== 'object' || Array.isArray(params.ui_meta)) {
+      throw new HttpError(400, 'ui_meta must be an object')
+    }
+    if (params.ui_meta_expected_revisions !== undefined
+      && (!params.ui_meta_expected_revisions
+        || typeof params.ui_meta_expected_revisions !== 'object'
+        || Array.isArray(params.ui_meta_expected_revisions))) {
+      throw new HttpError(400, 'ui_meta_expected_revisions must be an object')
+    }
+    params = {
+      name: safeString(params.name, 'profile name', true)!,
+      ui_meta: params.ui_meta,
+      ...(params.ui_meta_expected_revisions !== undefined
+        ? { ui_meta_expected_revisions: params.ui_meta_expected_revisions }
+        : {}),
+    }
   }
   if (request.method === 'config.set') params = normalizedConfigParams(params)
   if (request.method === 'prompt.submit' || request.method === 'session.steer') {
@@ -215,6 +303,28 @@ function upstreamWebSocketURL(config: ServerConfig, channel: RealtimeChannel, le
   return url
 }
 
+function pairedUpstreamWebSocketURL(
+  config: ServerConfig,
+  channel: RealtimeChannel,
+  ticket: string,
+  epoch?: string,
+  cursor?: number,
+): URL {
+  const url = new URL(config.upstream)
+  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+  const prefix = config.upstream.pathname === '/' ? '' : config.upstream.pathname.replace(/\/$/, '')
+  url.pathname = channel === 'chat'
+    ? `${prefix}/api/ws`
+    : `${prefix}/api/plugins/yaoyao/v1/events`
+  url.search = ''
+  url.searchParams.set('ticket', ticket)
+  if (channel === 'groups') {
+    url.searchParams.set('epoch', epoch!)
+    url.searchParams.set('cursor', String(cursor ?? 0))
+  }
+  return url
+}
+
 function closePair(client: WebSocket, upstream: WebSocket, code = 1000, reason = ''): void {
   const safeReason = safeCloseReason(reason)
   const safeCode = safeCloseCode(code)
@@ -298,6 +408,7 @@ function relaySocket(
   channel: RealtimeChannel,
   onFinished: () => void,
   groupAnchor?: { epoch: string; cursor: number },
+  paired = false,
 ): void {
   const groupValidator = groupAnchor
     ? new GroupFrameValidator(groupAnchor.epoch, groupAnchor.cursor)
@@ -332,7 +443,7 @@ function relaySocket(
       if (channel === 'groups') throw new HttpError(403, 'Group event stream is read-only')
       if (upstream.readyState !== WebSocket.OPEN) throw new HttpError(409, 'Hermes socket is not ready')
       if (upstream.bufferedAmount > MAX_BUFFERED_BYTES) throw new HttpError(429, 'Hermes socket is congested')
-      upstream.send(checkedChatFrame(data, isBinary))
+      upstream.send(checkedChatFrame(data, isBinary, paired))
     } catch (error) {
       const reason = error instanceof Error ? error.message : 'Invalid frame'
       closePair(client, upstream, 1008, reason)
@@ -382,6 +493,7 @@ export function installWebSocketRelay(
   server: HttpServer,
   config: ServerConfig,
   leases: RealtimeLeaseStore,
+  pairings: NodePairingStore,
 ): () => void {
   const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
   const active = new Map<string, number>()
@@ -393,6 +505,99 @@ export function installWebSocketRelay(
       rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
       return
     }
+    const pairedMatch = url.pathname.match(
+      /^\/node\/([0-9a-f-]{36})\/api\/(ws|plugins\/yaoyao\/v1\/events)$/,
+    )
+    if (pairedMatch) {
+      const deviceID = pairedMatch[1]!
+      const channel: RealtimeChannel = pairedMatch[2] === 'ws' ? 'chat' : 'groups'
+      const host = request.headers.host
+      const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
+      const secure = Boolean(config.tlsCert)
+      if (!isAllowedHostHeader(host, config)
+        || (origin !== undefined && !isExactOrigin(origin, host, secure, config.allowedHosts))) {
+        rejectUpgrade(socket, 403, 'Host or Origin rejected')
+        return
+      }
+      if (!pairings.hasDevice(deviceID)) {
+        rejectUpgrade(socket, 401, 'Paired device was revoked')
+        return
+      }
+      const allowedQuery = channel === 'groups'
+        ? new Set(['ticket', 'epoch', 'cursor'])
+        : new Set(['ticket'])
+      if ([...url.searchParams.keys()].some((name) => !allowedQuery.has(name))) {
+        rejectUpgrade(socket, 400, 'Unexpected WebSocket query parameter')
+        return
+      }
+      const ticket = url.searchParams.get('ticket')
+      if (!ticket || ticket.length > 4_096) {
+        rejectUpgrade(socket, 401, 'Missing paired node ticket')
+        return
+      }
+      const epoch = channel === 'groups' ? url.searchParams.get('epoch') : undefined
+      const cursorValue = channel === 'groups' ? Number(url.searchParams.get('cursor') ?? '0') : 0
+      if (channel === 'groups' && (!epoch
+        || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(epoch)
+        || !Number.isSafeInteger(cursorValue) || cursorValue < 0)) {
+        rejectUpgrade(socket, 400, 'Invalid paired group cursor')
+        return
+      }
+      const connectionKey = `paired:${deviceID}:${channel}`
+      const maximum = channel === 'chat' ? 4 : 2
+      if ((active.get(connectionKey) ?? 0) >= maximum) {
+        rejectUpgrade(socket, 429, 'Too many realtime connections')
+        return
+      }
+      active.set(connectionKey, (active.get(connectionKey) ?? 0) + 1)
+      socketServer.handleUpgrade(request, socket, head, (client) => {
+        const upstream = new WebSocket(
+          pairedUpstreamWebSocketURL(
+            config,
+            channel,
+            ticket,
+            epoch ?? undefined,
+            cursorValue,
+          ),
+          {
+            headers: { Origin: config.upstream.origin },
+            maxPayload: channel === 'chat' ? CHAT_MAX_PAYLOAD : GROUP_MAX_PAYLOAD,
+            handshakeTimeout: 15_000,
+          },
+        )
+        let released = false
+        const release = () => {
+          if (released) return
+          released = true
+          const count = (active.get(connectionKey) ?? 1) - 1
+          if (count <= 0) active.delete(connectionKey)
+          else active.set(connectionKey, count)
+        }
+        upstream.once('open', () => relaySocket(
+          client,
+          upstream,
+          channel,
+          release,
+          channel === 'groups' ? { epoch: epoch!, cursor: cursorValue } : undefined,
+          true,
+        ))
+        upstream.once('unexpected-response', () => {
+          if (client.readyState === WebSocket.OPEN) client.close(1011, 'Hermes rejected WebSocket')
+          release()
+        })
+        upstream.once('error', () => {
+          if (upstream.readyState !== WebSocket.OPEN && client.readyState === WebSocket.OPEN) {
+            client.close(1011, 'Unable to connect to Hermes')
+            release()
+          }
+        })
+        client.once('close', () => {
+          if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
+          release()
+        })
+      })
+      return
+    }
     if (!url.pathname.startsWith('/ws/')) {
       if (config.production) rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
       return

+ 19 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
-export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 10
+export const MAX_SUPPORTED_GROUP_PROTOCOL_VERSION = 11
 export const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -266,6 +266,8 @@ export interface GroupAgent {
   id: string
   roomId: string
   profile: string
+  nodeId: string
+  nodeLabel: string
   displayName: string
   description: string
   storedSessionId?: string | null
@@ -282,6 +284,22 @@ export interface GroupAgent {
   status: GroupAgentStatus
 }
 
+export interface GroupNodeProfile {
+  name: string
+  displayName: string
+  model: string
+}
+
+export interface GroupNode {
+  nodeId: string
+  name: string
+  serverUrl: string
+  fingerprint: string
+  createdAt: number
+  updatedAt: number
+  profiles: GroupNodeProfile[]
+}
+
 export interface GroupMessage {
   seq: number
   id: string

+ 36 - 8
tests/client/groupHost.test.ts

@@ -5,9 +5,13 @@ import type { GroupAgent } from '@shared/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 
+const profiles = (...names: string[]) => names.map(name => ({
+  id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
+}))
+
 function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
   return {
-    id, roomId: 'room-1', profile, displayName, description: `${displayName} 的职责`, storedSessionId: null,
+    id, roomId: 'room-1', profile, nodeId: 'local', nodeLabel: '', displayName, description: `${displayName} 的职责`, storedSessionId: null,
     lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     createdAt: 1, updatedAt: 1, status: 'idle',
@@ -15,10 +19,34 @@ function agent(id: string, profile: string, displayName: string, isHost: boolean
 }
 
 describe('group host controls', () => {
+  it('keeps same-name Profiles distinct across paired Hermes nodes', async () => {
+    const local = profiles('default')[0]!
+    const remote = {
+      id: '11111111-1111-4111-8111-111111111111|default',
+      profile: 'default',
+      displayName: '远端夭夭',
+      nodeId: '11111111-1111-4111-8111-111111111111',
+      nodeLabel: 'MacBook Pro',
+    }
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: { open: true, profiles: [local, remote], hostEnabled: true },
+      global: { stubs: { teleport: true } },
+    })
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('跨节点群')
+    await wrapper.findAll('.agent-picker > button')[1]!.trigger('click')
+    await wrapper.get('.solid-button').trigger('click')
+    expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
+      members: [local, remote],
+      hostProfile: local.id,
+    })
+    wrapper.unmount()
+  })
+
   it('keeps one independent host selection when creating a v5 room', async () => {
     const wrapper = mount(CreateGroupDialog, {
       attachTo: document.body,
-      props: { open: true, profiles: ['yaoyao', 'yaoer'], hostEnabled: true },
+      props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true },
       global: { stubs: { teleport: true } },
     })
     await nextTick()
@@ -26,16 +54,16 @@ describe('group host controls', () => {
     const profileButtons = wrapper.findAll('.agent-picker > button')
     await profileButtons[1]!.trigger('click')
     const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
-    await host.setValue('yaoer')
+    await host.setValue('local|yaoer')
     await profileButtons[1]!.trigger('click')
-    expect(host.element.value).toBe('yaoyao')
+    expect(host.element.value).toBe('local|yaoyao')
     await profileButtons[1]!.trigger('click')
-    await host.setValue('yaoer')
+    await host.setValue('local|yaoer')
     expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
     await wrapper.get('.solid-button').trigger('click')
 
     expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
-      name: '主持人验收', profiles: ['yaoyao', 'yaoer'], hostProfile: 'yaoer', autoReply: true, replyRounds: 3,
+      name: '主持人验收', members: profiles('yaoyao', 'yaoer'), hostProfile: 'local|yaoer', autoReply: true, replyRounds: 3,
     })
     wrapper.unmount()
   })
@@ -43,7 +71,7 @@ describe('group host controls', () => {
   it('keeps the legacy v4 create UI and payload free of host fields', async () => {
     const wrapper = mount(CreateGroupDialog, {
       attachTo: document.body,
-      props: { open: true, profiles: ['yaoyao'], hostEnabled: false },
+      props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false },
       global: { stubs: { teleport: true } },
     })
     await nextTick()
@@ -62,7 +90,7 @@ describe('group host controls', () => {
       attachTo: document.body,
       props: {
         open: true,
-        profiles: ['yaoyao', 'yaoer'],
+        profiles: profiles('yaoyao', 'yaoer'),
         hostEnabled: true,
         hostFlowEnabled: true,
         roomInstructionsEnabled: true,

+ 4 - 3
tests/client/groupProtocol.test.ts

@@ -33,7 +33,7 @@ function initial(): GroupProtocolState {
 
 function groupAgent(id: string, displayName: string, isHost: boolean): GroupAgent {
   return {
-    id, roomId: 'room-1', profile: displayName, displayName, description: '', storedSessionId: null,
+    id, roomId: 'room-1', profile: displayName, nodeId: 'local', nodeLabel: '', displayName, description: '', storedSessionId: null,
     lastContextMessageSeq: 0, enabled: true, replyWithoutMention: false, isHost,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     createdAt: 1, updatedAt: 1, status: 'idle',
@@ -167,7 +167,8 @@ describe('group protocol continuity', () => {
     expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(10)).toBe(true)
-    expect(isSupportedGroupProtocolVersion(11)).toBe(false)
-    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v10')
+    expect(isSupportedGroupProtocolVersion(11)).toBe(true)
+    expect(isSupportedGroupProtocolVersion(12)).toBe(false)
+    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v11')
   })
 })

+ 230 - 0
tests/server/pairing.test.ts

@@ -0,0 +1,230 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
+import type { ServerConfig } from '../../src/server/config.js'
+import { NodePairingStore } from '../../src/server/pairing.js'
+
+const homes: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+function temporaryHome(): string {
+  const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-pairing-'))
+  homes.push(home)
+  return home
+}
+
+function config(home: string): ServerConfig {
+  return {
+    host: '127.0.0.1',
+    port: 8800,
+    upstream: new URL('http://127.0.0.1:9119'),
+    allowedHosts: new Set(),
+    home,
+    mediaRoot: join(home, 'media'),
+    attachmentsRoot: join(home, 'attachments'),
+    imagesRoot: join(home, 'images'),
+    mediaOwner: 'tester',
+    allowInsecureLan: false,
+    insecureLan: false,
+    production: false,
+  }
+}
+
+function cookies(response: request.Response): string {
+  const values = response.headers['set-cookie'] as unknown as string[] | undefined
+  return (values ?? []).map((value) => value.split(';', 1)[0]).join('; ')
+}
+
+describe('Hermes node pairing', () => {
+  it('stores only encrypted delegated cookies and rejects replayed pairing secrets', () => {
+    const home = temporaryHome()
+    let now = 1_000
+    const store = new NodePairingStore(home, () => now)
+    const pairing = store.create('hermes_session_rt=refresh-secret', ['agents.read', 'history.read'])
+    const claimed = store.claim({
+      pairingID: pairing.id,
+      secret: pairing.secret,
+      deviceName: '测试 iPhone',
+    })
+
+    expect(store.authorize(claimed.device.id, claimed.token, 'history.read'))
+      .toBe('hermes_session_rt=refresh-secret')
+    expect(() => store.claim({
+      pairingID: pairing.id,
+      secret: pairing.secret,
+      deviceName: '重放设备',
+    })).toThrowError(/already used/i)
+    expect(() => store.authorize(claimed.device.id, claimed.token, 'groups.execute'))
+      .toThrowError(/lacks groups.execute/i)
+
+    const persisted = readFileSync(join(home, 'paired-devices.json'), 'utf8')
+    expect(persisted).not.toContain('refresh-secret')
+    expect(persisted).not.toContain(claimed.token)
+
+    now += 1
+    const restored = new NodePairingStore(home, () => now)
+    expect(restored.nodeID).toBe(store.nodeID)
+    expect(restored.authorize(claimed.device.id, claimed.token, 'agents.read'))
+      .toBe('hermes_session_rt=refresh-secret')
+  })
+
+  it('expires unclaimed QR secrets after two minutes', () => {
+    const home = temporaryHome()
+    let now = 10_000
+    const store = new NodePairingStore(home, () => now)
+    const pairing = store.create('hermes_session_at=access')
+    now = pairing.expiresAt + 1
+    expect(store.status(pairing.id)).toEqual({ state: 'expired' })
+    expect(() => store.claim({
+      pairingID: pairing.id,
+      secret: pairing.secret,
+      deviceName: '迟到的设备',
+    })).toThrowError(/expired/i)
+  })
+
+  it('claims from the QR and exposes a revocable Hermes-compatible node proxy', async () => {
+    const home = temporaryHome()
+    const upstreamRequests: Array<{
+      path: string
+      cookie: string
+      authorization: string
+      contentType: string
+      nodeClient: string
+      body?: Buffer
+    }> = []
+    const fetchImpl: typeof fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      const headers = new Headers(init?.headers)
+      const recordedBody = init?.body === undefined
+        ? undefined
+        : Buffer.from(await new Response(init.body).arrayBuffer())
+      upstreamRequests.push({
+        path: url.pathname,
+        cookie: headers.get('cookie') ?? '',
+        authorization: headers.get('authorization') ?? '',
+        contentType: headers.get('content-type') ?? '',
+        nodeClient: headers.get('x-yaoyao-node-client') ?? '',
+        body: recordedBody,
+      })
+      const responseHeaders = new Headers({ 'content-type': 'application/json' })
+      if (url.pathname === '/api/status') {
+        responseHeaders.append('set-cookie', 'hermes_session_at=paired-access; Path=/; HttpOnly')
+        return new Response(JSON.stringify({ auth_required: true }), { headers: responseHeaders })
+      }
+      if (url.pathname === '/api/auth/me') {
+        return new Response(JSON.stringify({ user_id: 'owner', display_name: 'Owner' }), { headers: responseHeaders })
+      }
+      if (url.pathname === '/api/profiles') {
+        return new Response(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }), { headers: responseHeaders })
+      }
+      if (url.pathname === '/api/plugins/yaoyao/profiles') {
+        return new Response(JSON.stringify({ profiles: [{ name: 'default', agentName: '夭夭' }] }), { headers: responseHeaders })
+      }
+      if (url.pathname === '/api/auth/providers') {
+        return new Response(JSON.stringify({
+          providers: [{ name: 'basic', supports_password: true }],
+        }), { headers: responseHeaders })
+      }
+      if (url.pathname === '/auth/password-login') {
+        responseHeaders.append('set-cookie', 'hermes_session_rt=independent-refresh; Path=/; HttpOnly')
+        return new Response(JSON.stringify({ ok: true }), { headers: responseHeaders })
+      }
+      return new Response(JSON.stringify({ ok: true }), { headers: responseHeaders })
+    }) as typeof fetch
+    const runtime = createApplication({ config: config(home), fetchImpl })
+    runtimes.push(runtime)
+
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    const cookie = cookies(bootstrap)
+    await request(runtime.app.callback())
+      .post('/api/app/pairings')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookie)
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ scopes: ['agents.read'] })
+      .expect(400)
+    const pairing = await request(runtime.app.callback())
+      .post('/api/app/pairings')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookie)
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({
+        scopes: ['agents.read', 'history.read', 'sessions.execute', 'groups.execute'],
+        username: 'paired-user',
+        password: 'paired-password',
+      })
+      .expect(201)
+
+    const deepLink = new URL(pairing.body.qrPayload)
+    expect(deepLink.protocol).toBe('yaoyao:')
+    expect(deepLink.searchParams.get('secret')).toBeTruthy()
+    const claim = await request(runtime.app.callback())
+      .post('/api/pair/v1/claim')
+      .set('Host', '127.0.0.1:8800')
+      .send({
+        pairingId: pairing.body.pairingId,
+        secret: deepLink.searchParams.get('secret'),
+        deviceName: 'YaoYao iPhone',
+      })
+      .expect(201)
+    expect(claim.body.serverUrl).toBe(`http://127.0.0.1:8800/node/${claim.body.deviceId}`)
+    expect(readFileSync(join(home, 'paired-devices.json'), 'utf8'))
+      .not.toContain('paired-password')
+
+    const profiles = await request(runtime.app.callback())
+      .get(`/node/${claim.body.deviceId}/api/profiles`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Authorization', `Bearer ${claim.body.token}`)
+      .expect(200)
+    expect(profiles.body.profiles[0].name).toBe('default')
+    expect(upstreamRequests.at(-1)).toMatchObject({
+      path: '/api/profiles',
+      authorization: '',
+    })
+    expect(upstreamRequests.at(-1)?.cookie).toContain('hermes_session_at=paired-access')
+    expect(upstreamRequests.at(-1)?.cookie).toContain('hermes_session_rt=independent-refresh')
+
+    const attachment = Buffer.from('remote attachment')
+    await request(runtime.app.callback())
+      .post(`/node/${claim.body.deviceId}/api/plugins/yaoyao/v1/node-worker/sessions/runtime-1/attachments`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Authorization', `Bearer ${claim.body.token}`)
+      .set('Content-Type', 'application/octet-stream')
+      .set('X-File-Name-B64', Buffer.from('note.txt').toString('base64url'))
+      .set('X-Mime-Type', 'text/plain')
+      .send(attachment)
+      .expect(200)
+    expect(upstreamRequests.at(-1)).toMatchObject({
+      path: '/api/plugins/yaoyao/v1/node-worker/sessions/runtime-1/attachments',
+      contentType: 'application/octet-stream',
+      nodeClient: claim.body.deviceId,
+      body: attachment,
+    })
+
+    await request(runtime.app.callback())
+      .delete(`/api/app/paired-devices/${claim.body.deviceId}`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookie)
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .expect(200)
+    await request(runtime.app.callback())
+      .get(`/node/${claim.body.deviceId}/api/profiles`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Authorization', `Bearer ${claim.body.token}`)
+      .expect(401)
+  })
+})

+ 163 - 0
tests/server/websocket.test.ts

@@ -57,6 +57,169 @@ function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
 }
 
 describe('WebSocket relay', () => {
+  it('lets a QR-paired iOS node use Hermes-compatible REST and WebSocket paths', async () => {
+    let pairedCookie = ''
+    const pairedFrames: Array<Record<string, unknown>> = []
+    const upstream = createServer((request, response) => {
+      response.setHeader('Content-Type', 'application/json')
+      if (request.url === '/api/status') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.setHeader('Set-Cookie', 'hermes_session_at=paired-user; Path=/; HttpOnly')
+        response.end(JSON.stringify({ auth_required: true }))
+      } else if (request.url === '/api/auth/me') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ user_id: 'paired-user', display_name: 'Paired User' }))
+      } else if (request.url === '/api/profiles') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
+      } else if (request.url === '/api/plugins/yaoyao/profiles') {
+        response.end(JSON.stringify({ profiles: [{ name: 'default', agentName: '夭夭' }] }))
+      } else if (request.url === '/api/auth/providers') {
+        response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
+      } else if (request.url === '/auth/password-login' && request.method === 'POST') {
+        response.setHeader('Set-Cookie', 'hermes_session_rt=paired-refresh; Path=/; HttpOnly')
+        response.end(JSON.stringify({ ok: true }))
+      } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
+        pairedCookie = request.headers.cookie ?? pairedCookie
+        response.end(JSON.stringify({ ticket: 'paired-upstream-ticket' }))
+      } else {
+        response.statusCode = 404
+        response.end(JSON.stringify({ error: 'not found' }))
+      }
+    })
+    const upstreamSockets = new WebSocketServer({ noServer: true })
+    upstream.on('upgrade', (request, socket, head) => {
+      const url = new URL(request.url ?? '/', 'http://127.0.0.1')
+      if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'paired-upstream-ticket') {
+        socket.destroy()
+        return
+      }
+      upstreamSockets.handleUpgrade(request, socket, head, client => {
+        client.send(JSON.stringify({ method: 'gateway.ready', params: { paired: true } }))
+        client.on('message', data => {
+          const frame = JSON.parse(data.toString()) as Record<string, unknown>
+          pairedFrames.push(frame)
+          client.send(JSON.stringify({ id: frame.id, result: {} }))
+        })
+      })
+    })
+    const upstreamPort = await listen(upstream)
+    closers.push(async () => {
+      for (const client of upstreamSockets.clients) client.terminate()
+      upstreamSockets.close()
+      await closeServer(upstream)
+    })
+
+    const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-paired-ws-'))
+    homes.push(home)
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800,
+      upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
+      allowedHosts: new Set(), home,
+      mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'tester',
+      allowInsecureLan: false, insecureLan: false, production: false,
+    }
+    const application = createApplication({ config })
+    const node = createNodeServer(application)
+    await listen(node.server)
+    closers.push(closeRuntime(node))
+    const port = (node.server.address() as AddressInfo).port
+    config.port = port
+    const origin = `http://127.0.0.1:${port}`
+
+    const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
+    const bootstrapBody = await bootstrap.json() as { csrfToken: string }
+    const cookie = cookies(bootstrap)
+    const pairingResponse = await fetch(`${origin}/api/app/pairings`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json', Cookie: cookie, Origin: origin,
+        'X-CSRF-Token': bootstrapBody.csrfToken,
+      },
+      body: JSON.stringify({
+        username: 'paired-user', password: 'paired-password',
+      }),
+    })
+    expect(pairingResponse.status).toBe(201)
+    const pairing = await pairingResponse.json() as { pairingId: string; qrPayload: string }
+    const qr = new URL(pairing.qrPayload)
+    const claimResponse = await fetch(`${origin}/api/pair/v1/claim`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        pairingId: pairing.pairingId,
+        secret: qr.searchParams.get('secret'),
+        deviceName: 'Test iPhone',
+      }),
+    })
+    expect(claimResponse.status).toBe(201)
+    const claim = await claimResponse.json() as { serverUrl: string; token: string; deviceId: string }
+    const authorization = `Bearer ${claim.token}`
+
+    const status = await fetch(`${claim.serverUrl}/api/status`, {
+      headers: { Authorization: authorization },
+    })
+    expect(status.status).toBe(200)
+    expect((await status.json() as { auth_required: boolean }).auth_required).toBe(true)
+    const ticketResponse = await fetch(`${claim.serverUrl}/api/auth/ws-ticket`, {
+      method: 'POST', headers: { Authorization: authorization },
+    })
+    expect(ticketResponse.status).toBe(200)
+    const ticket = (await ticketResponse.json() as { ticket: string }).ticket
+    expect(pairedCookie).toContain('hermes_session_at=paired-user')
+
+    const socket = await wsOpen(
+      `${claim.serverUrl.replace(/^http/, 'ws')}/api/ws?ticket=${encodeURIComponent(ticket)}`,
+      origin,
+      '',
+    )
+    expect(await wsMessage(socket)).toMatchObject({
+      method: 'gateway.ready', params: { paired: true },
+    })
+    socket.send(JSON.stringify({
+      id: 'profiles', method: 'profiles.list',
+      params: { include_sessions: true },
+    }))
+    await wsMessage(socket)
+    socket.send(JSON.stringify({
+      id: 'profile-meta', method: 'profiles.configure',
+      params: {
+        name: 'default',
+        ui_meta: { 'hermes-bots': { chat: 'stored-session' } },
+        ui_meta_expected_revisions: { 'hermes-bots': 2 },
+      },
+    }))
+    await wsMessage(socket)
+    socket.send(JSON.stringify({
+      id: 'hidden-bot', method: 'session.create',
+      params: {
+        profile: 'default', title: 'Bot Chat', source: 'ios_bot_group',
+        hidden: true, close_on_disconnect: false,
+      },
+    }))
+    await wsMessage(socket)
+    expect(pairedFrames[0]).toEqual({
+      id: 'profiles', method: 'profiles.list', params: { include_sessions: true },
+    })
+    expect(pairedFrames[1]).toMatchObject({
+      id: 'profile-meta', method: 'profiles.configure',
+      params: { name: 'default' },
+    })
+    expect(pairedFrames[2]).toMatchObject({
+      id: 'hidden-bot', method: 'session.create',
+      params: { profile: 'default', source: 'ios_bot_group', hidden: true },
+    })
+    socket.close()
+
+    const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
+      method: 'DELETE', headers: { Authorization: authorization },
+    })
+    expect(revoke.status).toBe(200)
+    expect(await fetch(`${claim.serverUrl}/api/status`, {
+      headers: { Authorization: authorization },
+    })).toHaveProperty('status', 401)
+  })
+
   it('keeps the upstream ticket server-side and filters chat RPC methods', async () => {
     const received: Array<Record<string, unknown>> = []
     const upstream = createServer((request, response) => {