Bläddra i källkod

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

samien 2 månader sedan
förälder
incheckning
3538f16992
35 ändrade filer med 4123 tillägg och 88 borttagningar
  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 Dashboard 插件,位于
 [`hermes-plugins/yaoyao/dashboard`](hermes-plugins/yaoyao/dashboard)。Web 工作台和该插件是两个独立部署单元:前者运行在 `8800`,后者由已运行的 Hermes Dashboard 在 `9119` 加载。
 [`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 重载与验证步骤。
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
 
@@ -85,6 +85,23 @@ cp .env.example .env
 npm run dev
 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 分别控制
 ## 网络暴露:9119 与 8800 分别控制
 
 
 默认情况下,两个端口都**不开放局域网**:Hermes Dashboard `9119` 和夭夭 Web `8800` 都只监听 `127.0.0.1`。通过本项目安装的受管服务开启局域网时,两个端口会一起开放。
 默认情况下,两个端口都**不开放局域网**: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` |
 | Git 发布标签 | `v0.1.0` | `git describe --tags --exact-match HEAD` |
 | 夭夭 Web | `0.1.0` | `package.json` 的 `version` |
 | 夭夭 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 "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
 test "$(node -p \"require('./package.json').version\")" = "0.1.0"
 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 负责修改代码,应另行创建工作分支,不要在此发布检出上修改。
 这会进入 detached HEAD 状态,属于部署发布版本的预期行为。若 Agent 负责修改代码,应另行创建工作分支,不要在此发布检出上修改。
@@ -116,7 +116,7 @@ test -f "$PLUGIN_DIR/manifest.json"
 test -f "$PLUGIN_DIR/plugin_api.py"
 test -f "$PLUGIN_DIR/plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/dist/index.js"
 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` 的同步命令。
 `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
 import uuid
 from typing import Any, Awaitable, Callable, Mapping, Protocol
 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
 from .group_protocol import MAX_MESSAGE_BYTES, MAX_TOOL_STATE_BYTES
 
 
 
 
@@ -515,7 +516,7 @@ class GroupOrchestrator:
         self._gateway_factory: GatewayFactory = (
         self._gateway_factory: GatewayFactory = (
             gateway_factory
             gateway_factory
             if gateway_factory is not None
             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):
         if work_enabled is not None and not callable(work_enabled):
             raise ValueError("work_enabled must be callable")
             raise ValueError("work_enabled must be callable")
@@ -2224,6 +2225,9 @@ class GroupOrchestrator:
             await self._fail_claim_without_projection(run_id)
             await self._fail_claim_without_projection(run_id)
             return
             return
         profile = _required_string(agent.get("profile"), "projection.agent.profile")
         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")
         reply_mode = _required_string(run.get("replyMode"), "projection.run.replyMode")
         if reply_mode not in {"mentioned", "automatic"}:
         if reply_mode not in {"mentioned", "automatic"}:
             raise GroupOrchestratorError("projection.run.replyMode is invalid")
             raise GroupOrchestratorError("projection.run.replyMode is invalid")
@@ -2261,18 +2265,51 @@ class GroupOrchestrator:
         gateway = self._required_gateway()
         gateway = self._required_gateway()
         try:
         try:
             if expected_stored_id is None:
             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:
             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
         except Exception:  # noqa: BLE001 - never create a fallback session
             logger.error("YaoYao group session create/resume failed")
             logger.error("YaoYao group session create/resume failed")
             await self._settle_unbound_failed(
             await self._settle_unbound_failed(

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

@@ -3,6 +3,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import base64
 import hashlib
 import hashlib
 import importlib
 import importlib
 import inspect
 import inspect
@@ -101,6 +102,12 @@ _package = _ensure_local_package()
 _protocol = importlib.import_module(f"{_LOCAL_PACKAGE}.group_protocol")
 _protocol = importlib.import_module(f"{_LOCAL_PACKAGE}.group_protocol")
 _store_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_store")
 _store_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_store")
 _stream_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_stream")
 _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
 PROTOCOL_VERSION = _protocol.PROTOCOL_VERSION
@@ -119,6 +126,12 @@ UpdateAgentRequest = _protocol.UpdateAgentRequest
 UpdateRoomRequest = _protocol.UpdateRoomRequest
 UpdateRoomRequest = _protocol.UpdateRoomRequest
 UpdateTopicRequest = _protocol.UpdateTopicRequest
 UpdateTopicRequest = _protocol.UpdateTopicRequest
 MarkTopicReadRequest = _protocol.MarkTopicReadRequest
 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
 GroupStore = _store_module.GroupStore
 GroupStoreError = _store_module.GroupStoreError
 GroupStoreError = _store_module.GroupStoreError
@@ -894,6 +907,16 @@ with _bootstrap_lock:
         _package._yaoyao_group_runtime_override = False
         _package._yaoyao_group_runtime_override = False
         _package._yaoyao_group_orchestrator_factory = _default_orchestrator_factory
         _package._yaoyao_group_orchestrator_factory = _default_orchestrator_factory
         _package._yaoyao_group_runtime_manager = _RuntimeManager()
         _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:
 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
     _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:
 def set_runtime_facade(runtime: object | None) -> None:
     """Bind the narrow Task-8 runtime surface, or restore the safe default."""
     """Bind the narrow Task-8 runtime surface, or restore the safe default."""
     _package._yaoyao_group_runtime_override = runtime is not None
     _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)
                 item.pop("replyWithoutMention", None)
             if isinstance(item, dict) and "is_host" not in seed.model_fields_set:
             if isinstance(item, dict) and "is_host" not in seed.model_fields_set:
                 item.pop("isHost", None)
                 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):
             if isinstance(item, dict):
                 for field, alias in (
                 for field, alias in (
                     ("model", "model"),
                     ("model", "model"),
@@ -1025,6 +1103,10 @@ def _add_agent_command(request: AddAgentRequest) -> dict[str, object]:
         command.pop("replyWithoutMention", None)
         command.pop("replyWithoutMention", None)
     if "is_host" not in request.model_fields_set:
     if "is_host" not in request.model_fields_set:
         command.pop("isHost", None)
         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 (
     for field, alias in (
         ("model", "model"),
         ("model", "model"),
         ("provider", "provider"),
         ("provider", "provider"),
@@ -1052,7 +1134,9 @@ async def capabilities() -> dict[str, object]:
         "latestCursor": latest_cursor,
         "latestCursor": latest_cursor,
         "limits": limits_payload(),
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
         "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:
 async def stream_events(websocket: WebSocket) -> None:
     """Delegate the read-only event channel to the authenticated streamer."""
     """Delegate the read-only event channel to the authenticated streamer."""
     allowed, close_code = _stream_module.websocket_upgrade_allowed(websocket)
     allowed, close_code = _stream_module.websocket_upgrade_allowed(websocket)
@@ -1359,6 +1582,47 @@ def _build_router() -> APIRouter:
         respond_clarification,
         respond_clarification,
         methods=["POST"],
         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)
     built.add_api_websocket_route("/events", stream_events)
     return built
     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_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024
@@ -97,6 +97,8 @@ class GroupModel(BaseModel):
 
 
 class AgentSeed(GroupModel):
 class AgentSeed(GroupModel):
     profile: str = Field(min_length=1, max_length=100)
     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(
     display_name: str | None = Field(
         default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
         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:
     def reject_reserved_display_name(cls, value: str | None) -> str | None:
         return validate_optional_display_name(value)
         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):
 class CreateRoomRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     request_id: UUID = Field(alias="requestId")
@@ -237,6 +249,8 @@ class MarkTopicReadRequest(GroupModel):
 class AddAgentRequest(GroupModel):
 class AddAgentRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     request_id: UUID = Field(alias="requestId")
     profile: str = Field(min_length=1, max_length=100)
     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(
     display_name: str | None = Field(
         default=None, alias="displayName", max_length=MAX_AGENT_DISPLAY_NAME_LENGTH
         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:
     def reject_reserved_display_name(cls, value: str | None) -> str | None:
         return validate_optional_display_name(value)
         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):
 class UpdateAgentRequest(GroupModel):
     request_id: UUID = Field(alias="requestId")
     request_id: UUID = Field(alias="requestId")
@@ -346,6 +370,19 @@ def normalize_optional_agent_configuration(value: str | None) -> str | None:
     return normalized
     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:
 def validate_reasoning_override(value: str | None) -> str | None:
     if value is not None and value not in REASONING_EFFORTS:
     if value is not None and value not in REASONING_EFFORTS:
         raise ValueError("reasoningEffort is invalid")
         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)
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
 MIN_RETAINED_EVENTS = 100_000
@@ -137,6 +137,9 @@ _SCHEMA_STATEMENTS = (
         id TEXT PRIMARY KEY,
         id TEXT PRIMARY KEY,
         room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
         room_id TEXT NOT NULL REFERENCES group_rooms(id) ON DELETE CASCADE,
         profile TEXT NOT NULL,
         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 TEXT NOT NULL,
         display_name_key TEXT NOT NULL,
         display_name_key TEXT NOT NULL,
         description TEXT NOT NULL DEFAULT '',
         description TEXT NOT NULL DEFAULT '',
@@ -499,6 +502,9 @@ class GroupStore:
             raw_version = "11"
             raw_version = "11"
         if raw_version == "11":
         if raw_version == "11":
             self._migrate_v11_to_v12(connection)
             self._migrate_v11_to_v12(connection)
+            raw_version = "12"
+        if raw_version == "12":
+            self._migrate_v12_to_v13(connection)
         self._validated_schema_version(
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "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'"
             "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
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "instructions", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
             "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_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_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_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"},
             "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_rooms", "instructions"): ("TEXT", 1, "''"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("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_messages", "visible"): ("INTEGER", 1, "1"),
             ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
             ("group_agent_runs", "reply_mode"): ("TEXT", 1, "'mentioned'"),
             ("group_agent_runs", "required_reply"): ("INTEGER", 1, "0"),
             ("group_agent_runs", "required_reply"): ("INTEGER", 1, "0"),
@@ -1316,6 +1348,15 @@ class GroupStore:
             """SELECT 1 FROM group_agents
             """SELECT 1 FROM group_agents
                 WHERE typeof(is_host) != 'integer' OR is_host NOT IN (0, 1)
                 WHERE typeof(is_host) != 'integer' OR is_host NOT IN (0, 1)
                 LIMIT 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
             """SELECT 1 FROM group_rooms AS room
                 LEFT JOIN group_agents AS agent ON agent.room_id = room.id
                 LEFT JOIN group_agents AS agent ON agent.room_id = room.id
                 GROUP BY room.id
                 GROUP BY room.id
@@ -6687,11 +6728,21 @@ class GroupStore:
             command,
             command,
             {
             {
                 "profile", "displayName", "description", "replyWithoutMention",
                 "profile", "displayName", "description", "replyWithoutMention",
-                "isHost",
+                "isHost", "nodeId", "nodeLabel",
                 "model", "provider", "reasoningEffort", "fastMode",
                 "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")
         raw_name = command.get("displayName")
         explicit_name = (
         explicit_name = (
             raw_name.strip()
             raw_name.strip()
@@ -6699,7 +6750,13 @@ class GroupStore:
             else ""
             else ""
         )
         )
         display_name, display_name_key = normalize_display_name(
         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)
         reply_without_mention = command.get("replyWithoutMention", False)
         if not isinstance(reply_without_mention, bool):
         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")
             raise ValueError("model and provider must both be set or both be null")
         return {
         return {
             "profile": profile,
             "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": display_name,
             "display_name_key": display_name_key,
             "display_name_key": display_name_key,
             "description": self._description(command.get("description", "")),
             "description": self._description(command.get("description", "")),
@@ -6767,14 +6829,18 @@ class GroupStore:
         agent_id = str(uuid.uuid4())
         agent_id = str(uuid.uuid4())
         connection.execute(
         connection.execute(
             """INSERT INTO group_agents
             """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,
              reply_without_mention, is_host, model_override, provider_override,
              reasoning_effort_override, fast_mode_override, created_at, updated_at)
              reasoning_effort_override, fast_mode_override, created_at, updated_at)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
             (
             (
                 agent_id,
                 agent_id,
                 room_id,
                 room_id,
                 agent["profile"],
                 agent["profile"],
+                agent["node_id"],
+                agent["node_label"],
+                agent["execution_profile"],
                 agent["display_name"],
                 agent["display_name"],
                 agent["display_name_key"],
                 agent["display_name_key"],
                 agent["description"],
                 agent["description"],
@@ -6881,7 +6947,9 @@ class GroupStore:
         return {
         return {
             "id": row["id"],
             "id": row["id"],
             "roomId": row["room_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"],
             "displayName": row["display_name"],
             "description": row["description"],
             "description": row["description"],
             "storedSessionId": row["stored_session_id"],
             "storedSessionId": row["stored_session_id"],
@@ -7063,6 +7131,21 @@ class GroupStore:
             raise ValueError("profile must contain 1 to 100 characters")
             raise ValueError("profile must contain 1 to 100 characters")
         return profile
         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
     @staticmethod
     def _description(value: object) -> str:
     def _description(value: object) -> str:
         if not isinstance(value, str):
         if not isinstance(value, str):

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

@@ -3,7 +3,7 @@
   "label": "夭夭",
   "label": "夭夭",
   "description": "文件归档、Agent 名称、语音设置与多设备群聊",
   "description": "文件归档、Agent 名称、语音设置与多设备群聊",
   "icon": "FolderOpen",
   "icon": "FolderOpen",
-  "version": "1.6.1",
+  "version": "1.7.0",
   "tab": { "path": "/yaoyao-files", "position": "after:files" },
   "tab": { "path": "/yaoyao-files", "position": "after:files" },
   "entry": "dist/index.js",
   "entry": "dist/index.js",
   "css": "dist/style.v1.4.0.css",
   "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):
 class GroupHostContractTests(unittest.TestCase):
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
     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({
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": new_id(),
             "requestId": new_id(),
             "name": "兼容群",
             "name": "兼容群",
@@ -496,7 +496,7 @@ class GroupHostContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertTrue(
             self.assertTrue(
                 all(
                 all(
                     room["orchestrationMode"] == "free"
                     room["orchestrationMode"] == "free"
@@ -881,7 +881,7 @@ class GroupHostContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertEqual(
             self.assertEqual(
                 migrated.get_room(room["id"])["orchestrationMode"], "free"
                 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 importlib
 import asyncio
 import asyncio
+import base64
 import hashlib
 import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 import json
 import json
 import os
 import os
 from pathlib import Path
 from pathlib import Path
@@ -11,6 +13,8 @@ import sys
 import subprocess
 import subprocess
 import tempfile
 import tempfile
 import textwrap
 import textwrap
+import threading
+import time
 import unittest
 import unittest
 import uuid
 import uuid
 
 
@@ -28,6 +32,15 @@ ORCHESTRATOR = importlib.import_module(
     f"{group_plugin_api._LOCAL_PACKAGE}.group_orchestrator"
     f"{group_plugin_api._LOCAL_PACKAGE}.group_orchestrator"
 )
 )
 GATEWAY = importlib.import_module(f"{group_plugin_api._LOCAL_PACKAGE}.group_gateway")
 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
 GroupStore = STORE_MODULE.GroupStore
 
 
 
 
@@ -36,6 +49,239 @@ def request_id() -> str:
 
 
 
 
 class GroupSettingsContractTests(unittest.TestCase):
 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:
     def test_room_instructions_round_trip_into_agent_prompt(self) -> None:
         create = PROTOCOL.CreateRoomRequest.model_validate({
         create = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": request_id(),
             "requestId": request_id(),
@@ -107,7 +353,7 @@ class GroupSettingsContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
             self.assertEqual(migrated.get_room(room["id"])["instructions"], "")
 
 
     def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
     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:
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 11)
         self.assertEqual(
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
         )
@@ -786,7 +1032,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             self._create_v1_database(path)
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 12)
+            self.assertEqual(store.schema_version(), 13)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
@@ -1799,5 +2045,207 @@ class GroupSettingsContractTests(unittest.TestCase):
         connection.close()
         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__":
 if __name__ == "__main__":
     unittest.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):
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
     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("topic.updated", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         self.assertIn("room.activity", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
         request = PROTOCOL.SendMessageRequest.model_validate({
@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
 
 
-            self.assertEqual(migrated.schema_version(), 12)
+            self.assertEqual(migrated.schema_version(), 13)
             with migrated.connection() as connection:
             with migrated.connection() as connection:
                 topic = connection.execute(
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
 
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 12)
+            self.assertEqual(store.schema_version(), 13)
             with store.connection() as migrated:
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1536,7 +1536,16 @@ class GroupTopicsContractTests(unittest.TestCase):
                     "instructions": "",
                     "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(
             self.assertEqual(
                 (room["name"], room["max_reply_rounds"], room["archived"]),
                 (room["name"], room["max_reply_rounds"], room["archived"]),
                 ("保留群", 7, 1),
                 ("保留群", 7, 1),

+ 331 - 3
package-lock.json

@@ -27,6 +27,7 @@
         "naive-ui": "^2.44.1",
         "naive-ui": "^2.44.1",
         "pdfjs-dist": "^5.7.284",
         "pdfjs-dist": "^5.7.284",
         "pinia": "^3.0.4",
         "pinia": "^3.0.4",
+        "qrcode": "^1.5.4",
         "read-excel-file": "^7.0.3",
         "read-excel-file": "^7.0.3",
         "vue": "^3.5.32",
         "vue": "^3.5.32",
         "vue-router": "^4.6.4",
         "vue-router": "^4.6.4",
@@ -46,6 +47,7 @@
         "@types/markdown-it": "^14.1.2",
         "@types/markdown-it": "^14.1.2",
         "@types/mime-types": "^2.1.4",
         "@types/mime-types": "^2.1.4",
         "@types/node": "^24.10.0",
         "@types/node": "^24.10.0",
+        "@types/qrcode": "^1.5.6",
         "@types/supertest": "^6.0.3",
         "@types/supertest": "^6.0.3",
         "@types/ws": "^8.18.1",
         "@types/ws": "^8.18.1",
         "@vitejs/plugin-vue": "^6.0.5",
         "@vitejs/plugin-vue": "^6.0.5",
@@ -2500,6 +2502,16 @@
         "undici-types": "~7.18.0"
         "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": {
     "node_modules/@types/qs": {
       "version": "6.15.1",
       "version": "6.15.1",
       "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
       "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
@@ -3239,6 +3251,15 @@
         "url": "https://github.com/sponsors/ljharb"
         "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": {
     "node_modules/chai": {
       "version": "5.3.3",
       "version": "5.3.3",
       "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
       "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
@@ -3282,6 +3303,87 @@
         "url": "https://paulmillr.com/funding/"
         "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": {
     "node_modules/co": {
       "version": "4.6.0",
       "version": "4.6.0",
       "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz",
       "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz",
@@ -3312,7 +3414,6 @@
       "version": "2.0.1",
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "color-name": "~1.1.4"
         "color-name": "~1.1.4"
@@ -3325,7 +3426,6 @@
       "version": "1.1.4",
       "version": "1.1.4",
       "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
       "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/combined-stream": {
     "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": {
     "node_modules/decimal.js": {
       "version": "10.6.0",
       "version": "10.6.0",
       "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
       "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -3681,6 +3790,12 @@
         "wrappy": "1"
         "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": {
     "node_modules/docx-preview": {
       "version": "0.3.7",
       "version": "0.3.7",
       "resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
       "resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
@@ -3936,6 +4051,19 @@
       "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
       "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
       "license": "MIT"
       "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": {
     "node_modules/foreground-child": {
       "version": "3.3.1",
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
       "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -4067,6 +4195,15 @@
         "node": ">= 0.4"
         "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": {
     "node_modules/get-intrinsic": {
       "version": "1.3.0",
       "version": "1.3.0",
       "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
       "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4390,7 +4527,6 @@
       "version": "3.0.0",
       "version": "3.0.0",
       "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
       "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-      "dev": true,
       "license": "MIT",
       "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
@@ -5145,6 +5281,18 @@
         "uc.micro": "^2.0.0"
         "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": {
     "node_modules/lodash": {
       "version": "4.18.1",
       "version": "4.18.1",
       "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
       "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",
       "resolved": "https://registry.npmmirror.com/only/-/only-0.0.2.tgz",
       "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ=="
       "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": {
     "node_modules/package-json-from-dist": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
       "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5563,6 +5747,15 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "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": {
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
       "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -5723,6 +5916,15 @@
         "node": ">=20"
         "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": {
     "node_modules/postcss": {
       "version": "8.5.26",
       "version": "8.5.26",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
@@ -5783,6 +5985,23 @@
         "node": ">=6"
         "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": {
     "node_modules/qs": {
       "version": "6.15.3",
       "version": "6.15.3",
       "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
       "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -5863,6 +6082,15 @@
         "url": "https://paulmillr.com/funding/"
         "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": {
     "node_modules/require-from-string": {
       "version": "2.0.2",
       "version": "2.0.2",
       "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
       "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -5873,6 +6101,12 @@
         "node": ">=0.10.0"
         "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": {
     "node_modules/resolve-path": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmmirror.com/resolve-path/-/resolve-path-1.4.0.tgz",
       "resolved": "https://registry.npmmirror.com/resolve-path/-/resolve-path-1.4.0.tgz",
@@ -6112,6 +6346,12 @@
         "node": ">=10"
         "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": {
     "node_modules/setimmediate": {
       "version": "1.0.5",
       "version": "1.0.5",
       "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
       "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -7405,6 +7645,12 @@
         "node": ">= 8"
         "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": {
     "node_modules/why-is-node-running": {
       "version": "2.3.0",
       "version": "2.3.0",
       "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
       "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -7565,6 +7811,88 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "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": {
     "node_modules/ylru": {
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmmirror.com/ylru/-/ylru-1.4.0.tgz",
       "resolved": "https://registry.npmmirror.com/ylru/-/ylru-1.4.0.tgz",

+ 2 - 0
package.json

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

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

@@ -1,5 +1,5 @@
 import type {
 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'
 } from '@shared/types'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import { apiRequest, apiUrl, unwrapData } from './client'
 import {
 import {
@@ -16,6 +16,32 @@ export async function getGroupCapabilities(): Promise<GroupCapabilities> {
   return normalizeGroupCapabilities(unwrapData(await apiRequest<unknown>(`${BASE}/capabilities`)))
   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> {
 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 payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)), archived })))
   const source = record(payload)
   const source = record(payload)
@@ -72,6 +98,8 @@ export async function markGroupTopicRead(roomId: string, topicId: string, throug
 
 
 export interface AgentSeed {
 export interface AgentSeed {
   profile: string
   profile: string
+  nodeId?: string
+  nodeLabel?: string
   displayName: string
   displayName: string
   description?: string
   description?: string
   replyWithoutMention?: boolean
   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 { useRoute, useRouter } from 'vue-router'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
 import BrandMark from '@/components/common/BrandMark.vue'
+import NodePairingDialog from '@/components/app/NodePairingDialog.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 
 
 type NavItem = {
 type NavItem = {
@@ -18,6 +19,7 @@ const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
 
 
 const props = withDefaults(defineProps<{
 const props = withDefaults(defineProps<{
   userName?: string
   userName?: string
+  pairingUserName?: string
   profileName?: string
   profileName?: string
   profiles?: string[]
   profiles?: string[]
   theme?: 'light' | 'dark'
   theme?: 'light' | 'dark'
@@ -29,6 +31,7 @@ const props = withDefaults(defineProps<{
   inspectorCloseLabel?: string
   inspectorCloseLabel?: string
 }>(), {
 }>(), {
   userName: '',
   userName: '',
+  pairingUserName: '',
   profileName: '',
   profileName: '',
   profiles: () => [],
   profiles: () => [],
   theme: 'light',
   theme: 'light',
@@ -53,6 +56,7 @@ const mobileDrawerOpen = ref(false)
 const profileMenuOpen = ref(false)
 const profileMenuOpen = ref(false)
 const sidebarCollapsed = ref(false)
 const sidebarCollapsed = ref(false)
 const sidebarSearchOpen = ref(false)
 const sidebarSearchOpen = ref(false)
+const nodePairingOpen = ref(false)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = 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')">
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
           </button>
+          <button class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true">
+            <AppIcon name="users" :size="17" />
+          </button>
           <Transition name="menu-fade">
           <Transition name="menu-fade">
             <div v-if="profileMenuOpen" class="profile-menu">
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
               <button
@@ -363,6 +370,9 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
           </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">
           <Transition name="menu-fade">
             <div v-if="profileMenuOpen" class="profile-menu">
             <div v-if="profileMenuOpen" class="profile-menu">
               <button
               <button
@@ -399,6 +409,8 @@ onBeforeUnmount(() => {
         <slot name="inspector" />
         <slot name="inspector" />
       </aside>
       </aside>
     </Transition>
     </Transition>
+
+    <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" @close="nodePairingOpen = false" />
   </div>
   </div>
 </template>
 </template>
 
 

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

@@ -1,10 +1,11 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 import { computed, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import type { GroupProfileOption } from './types'
 
 
 interface CreateGroupPayload {
 interface CreateGroupPayload {
   name: string
   name: string
-  profiles: string[]
+  members: GroupProfileOption[]
   autoReply: boolean
   autoReply: boolean
   replyRounds: number
   replyRounds: number
   instructions?: string
   instructions?: string
@@ -12,7 +13,7 @@ interface CreateGroupPayload {
   orchestrationMode?: 'free' | 'host'
   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,
   hostEnabled: false,
   hostFlowEnabled: false,
   hostFlowEnabled: false,
   roomInstructionsEnabled: false,
   roomInstructionsEnabled: false,
@@ -36,16 +37,16 @@ watch(() => props.open, open => {
   if (!open) return
   if (!open) return
   name.value = ''
   name.value = ''
   instructions.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] ?? ''
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
   autoReply.value = true
   replyRounds.value = 3
   replyRounds.value = 3
   orchestrationMode.value = 'free'
   orchestrationMode.value = 'free'
 }, { immediate: true })
 }, { 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] ?? ''
   if (!selected.value.includes(hostProfile.value)) hostProfile.value = selected.value[0] ?? ''
 }
 }
 
 
@@ -53,7 +54,7 @@ function create() {
   if (!valid.value) return
   if (!valid.value) return
   const payload: CreateGroupPayload = {
   const payload: CreateGroupPayload = {
     name: name.value.trim(),
     name: name.value.trim(),
-    profiles: selected.value,
+    members: selected.value.flatMap(id => props.profiles.find(profile => profile.id === id) ?? []),
     autoReply: autoReply.value,
     autoReply: autoReply.value,
     replyRounds: replyRounds.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>
           <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">
             <div class="agent-picker__heading"><span>选择 Agent</span><small>{{ selected.length }}/8</small></div>
             <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>
             </button>
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
             <p v-if="!profiles.length">当前没有可用 Agent,请先在 Hermes 配置 Profile。</p>
           </div>
           </div>
           <label v-if="hostEnabled" class="host-picker">
           <label v-if="hostEnabled" class="host-picker">
             <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
             <span>主持人<small>用户没有明确 @ 时,主持人始终负责回应。</small></span>
             <select v-model="hostProfile" aria-label="主持人">
             <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>
             </select>
           </label>
           </label>
           <label v-if="hostFlowEnabled" class="host-picker">
           <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); }
 .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; }
 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; }
 .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); }
 .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; }
 .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; }
 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 type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import type { UiRoom } from './types'
 import type { UiRoom } from './types'
+import type { GroupProfileOption } from './types'
 
 
 type AgentSettingsPatch = Partial<Pick<GroupAgent,
 type AgentSettingsPatch = Partial<Pick<GroupAgent,
   'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
   'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'isHost' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
@@ -37,7 +38,7 @@ const props = withDefaults(defineProps<{
   hostEnabled?: boolean
   hostEnabled?: boolean
   hostFlowEnabled?: boolean
   hostFlowEnabled?: boolean
   roomInstructionsEnabled?: boolean
   roomInstructionsEnabled?: boolean
-  availableProfiles?: string[]
+  availableProfiles?: GroupProfileOption[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsError?: Record<string, string>
   modelOptionsError?: Record<string, string>
@@ -241,7 +242,7 @@ function statusLabel(status: GroupAgent['status']): string {
       <div class="agent-list">
       <div class="agent-list">
         <article v-for="agent in agents" :key="agent.id">
         <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-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 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 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>
           <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">
       <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 = ''">
         <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 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>
         </select>
       </div>
       </div>
     </section>
     </section>

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

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

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

@@ -28,6 +28,7 @@ function selectProfile(label: string) { auth.selectProfile(profileNameMap.value.
 <template>
 <template>
   <WorkspaceShell
   <WorkspaceShell
     :user-name="userName"
     :user-name="userName"
+    :pairing-user-name="auth.user?.username || ''"
     :profile-name="auth.activeProfile?.agentName || auth.activeProfile?.displayName || profileName"
     :profile-name="auth.activeProfile?.agentName || auth.activeProfile?.displayName || profileName"
     :profiles="profileNames"
     :profiles="profileNames"
     :theme="theme.resolvedTheme"
     :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 { defineStore } from 'pinia'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import { isSupportedGroupProtocolVersion, SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
 import type {
 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'
 } from '@shared/types'
 import {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
   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,
   removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 } from '@/api/groups'
@@ -43,6 +43,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const selectedRoomId = ref<string>()
   const selectedRoomId = ref<string>()
   const selectedTopicId = ref<string>()
   const selectedTopicId = ref<string>()
   const pinnedTopics = ref<GroupTopicSummary[]>([])
   const pinnedTopics = ref<GroupTopicSummary[]>([])
+  const nodes = ref<GroupNode[]>([])
   const connectionState = ref<RealtimeConnectionState>('idle')
   const connectionState = ref<RealtimeConnectionState>('idle')
   const isLoading = ref(false)
   const isLoading = ref(false)
   const isSending = 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'}`)
           throw new Error(`群聊协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
         }
         }
         const loadedRooms = await loadRooms()
         const loadedRooms = await loadRooms()
+        nodes.value = anchor.features?.includes('nodeRegistry')
+          ? await getGroupNodes()
+          : []
         if (expectedGeneration !== generation) return
         if (expectedGeneration !== generation) return
         await loadPinnedTopics()
         await loadPinnedTopics()
         if (expectedGeneration !== generation) return
         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,
     availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, roomInstructionsProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     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,
     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')
   const rawFastMode = pick(source, 'fastMode', 'fast_mode')
   return {
   return {
     id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
     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,
     description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
     lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
     lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
     enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
     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 type { ComposerOption, ComposerReference, ComposerSubmit } from '@/components/composer/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
+import type { GroupProfileOption } from '@/components/groups/types'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
 import type { PreviewMedia } 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 connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
 const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom) : null)
 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 uploadsEnabled = computed(() => auth.groupUploadsEnabled)
 const managerBusy = computed(() => groups.isLoading || Object.values(agentUpdateBusy.value).some(Boolean))
 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)
 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 */ }
   } 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({
   const detail = await groups.createRoom({
     name: payload.name,
     name: payload.name,
     ...(groups.roomInstructionsProtocol ? { instructions: payload.instructions ?? '' } : {}),
     ...(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,
       replyWithoutMention: payload.autoReply,
-      ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
+      ...(groups.hostProtocol ? { isHost: member.id === hostProfile } : {}),
     })),
     })),
     maxReplyRounds: payload.replyRounds,
     maxReplyRounds: payload.replyRounds,
     ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
     ...(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
   if (!groups.selectedRoom) return
+  const member = profiles.value.find(profile => profile.id === profileID)
+  if (!member) return
   await groups.addAgent(groups.selectedRoom.id, {
   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,
     replyWithoutMention: true,
     ...(groups.hostProtocol ? { isHost: false } : {}),
     ...(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 { loadServerConfig } from './config.js'
 import { errorMessage, HttpError } from './errors.js'
 import { errorMessage, HttpError } from './errors.js'
 import { RealtimeLeaseStore } from './leases.js'
 import { RealtimeLeaseStore } from './leases.js'
+import { NodePairingStore } from './pairing.js'
 import { createApiRouter } from './routes.js'
 import { createApiRouter } from './routes.js'
 import {
 import {
   applySecurityHeaders,
   applySecurityHeaders,
@@ -25,6 +26,7 @@ export interface ApplicationOptions {
   fetchImpl?: typeof fetch
   fetchImpl?: typeof fetch
   csrfSecret?: Buffer
   csrfSecret?: Buffer
   leases?: RealtimeLeaseStore
   leases?: RealtimeLeaseStore
+  pairings?: NodePairingStore
   uploads?: UploadStore
   uploads?: UploadStore
 }
 }
 
 
@@ -33,6 +35,7 @@ export interface ApplicationRuntime {
   config: ServerConfig
   config: ServerConfig
   csrf: CsrfProtection
   csrf: CsrfProtection
   leases: RealtimeLeaseStore
   leases: RealtimeLeaseStore
+  pairings: NodePairingStore
   upstream: UpstreamClient
   upstream: UpstreamClient
   uploads: UploadStore
   uploads: UploadStore
   close(): void
   close(): void
@@ -71,6 +74,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   app.proxy = false
   app.proxy = false
   const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
   const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
   const leases = options.leases ?? new RealtimeLeaseStore()
   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 upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   const uploads = options.uploads ?? new UploadStore(config.home)
   const uploads = options.uploads ?? new UploadStore(config.home)
   try {
   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.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(router.allowedMethods({ throw: false }))
   app.use(async (ctx, next) => {
   app.use(async (ctx, next) => {
@@ -152,6 +156,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     config,
     config,
     csrf,
     csrf,
     leases,
     leases,
+    pairings,
     upstream,
     upstream,
     uploads,
     uploads,
     close: () => uploads.close(),
     close: () => uploads.close(),
@@ -171,7 +176,12 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
     : createHttpServer(runtime.app.callback())
-  const removeWebSockets = installWebSocketRelay(server, config, runtime.leases)
+  const removeWebSockets = installWebSocketRelay(
+    server,
+    config,
+    runtime.leases,
+    runtime.pairings,
+  )
   return {
   return {
     server,
     server,
     close: async () => {
     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 { isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.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 { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
 import {
 import {
   applyUpstreamCookies,
   applyUpstreamCookies,
@@ -27,6 +34,7 @@ export interface RouteDependencies {
   csrf: CsrfProtection
   csrf: CsrfProtection
   upstream: UpstreamClient
   upstream: UpstreamClient
   leases: RealtimeLeaseStore
   leases: RealtimeLeaseStore
+  pairings: NodePairingStore
   uploads: UploadStore
   uploads: UploadStore
 }
 }
 
 
@@ -38,6 +46,28 @@ function body(ctx: Koa.Context): JsonObject {
   return value as 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 {
 function parseJson(response: UpstreamResponse): JsonObject {
   try {
   try {
     const value = JSON.parse(response.body.toString('utf8')) as unknown
     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
   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(
 async function proxy(
   ctx: Koa.Context,
   ctx: Koa.Context,
   upstream: UpstreamClient,
   upstream: UpstreamClient,
@@ -283,6 +360,58 @@ async function login(ctx: Koa.Context, dependencies: RouteDependencies, jar: Coo
   await bootstrap(ctx, dependencies, jar, true)
   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(
 async function requireGatewayAuthentication(
   ctx: Koa.Context,
   ctx: Koa.Context,
   dependencies: RouteDependencies,
   dependencies: RouteDependencies,
@@ -456,6 +585,179 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { ok: true })
     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
   // Historical messages can contain Markdown links such as
   // /Users/<owner>/Agents/<path>. Map only the configured media root, never
   // /Users/<owner>/Agents/<path>. Map only the configured media root, never
   // an arbitrary local filesystem path.
   // an arbitrary local filesystem path.
@@ -630,6 +932,23 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   router.get('/api/app/groups/capabilities', async (ctx) => {
   router.get('/api/app/groups/capabilities', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/capabilities')
     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) => {
   router.get('/api/app/groups/rooms', async (ctx) => {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
     await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/rooms', {
       search: searchFrom(ctx, ['limit', 'cursor', 'archived']),
       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 type { ServerConfig } from './config.js'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
 import type { RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import type { NodePairingStore } from './pairing.js'
 import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
 import { isAllowedHostHeader, isExactOrigin, requestAccountKey } from './security.js'
 
 
 const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
 const CHAT_MAX_PAYLOAD = 36 * 1_024 * 1_024
@@ -16,6 +17,9 @@ const CHAT_METHODS = new Set([
   'file.attach',
   'file.attach',
   'image.attach_bytes',
   'image.attach_bytes',
   'pdf.attach',
   'pdf.attach',
+  'profiles.configure',
+  'profiles.get_asset',
+  'profiles.list',
   'prompt.submit',
   'prompt.submit',
   'session.branch',
   'session.branch',
   'session.close',
   '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')
   if (isBinary) throw new HttpError(400, 'Binary chat frames are not accepted')
   const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer)
   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')
   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'
   const profileRequired = request.method === 'session.create' || request.method === 'session.resume'
   safeString(params.profile, 'profile', profileRequired)
   safeString(params.profile, 'profile', profileRequired)
   if (request.method === 'session.create' || request.method === 'session.resume') {
   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 === 'config.set') params = normalizedConfigParams(params)
   if (request.method === 'prompt.submit' || request.method === 'session.steer') {
   if (request.method === 'prompt.submit' || request.method === 'session.steer') {
@@ -215,6 +303,28 @@ function upstreamWebSocketURL(config: ServerConfig, channel: RealtimeChannel, le
   return url
   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 {
 function closePair(client: WebSocket, upstream: WebSocket, code = 1000, reason = ''): void {
   const safeReason = safeCloseReason(reason)
   const safeReason = safeCloseReason(reason)
   const safeCode = safeCloseCode(code)
   const safeCode = safeCloseCode(code)
@@ -298,6 +408,7 @@ function relaySocket(
   channel: RealtimeChannel,
   channel: RealtimeChannel,
   onFinished: () => void,
   onFinished: () => void,
   groupAnchor?: { epoch: string; cursor: number },
   groupAnchor?: { epoch: string; cursor: number },
+  paired = false,
 ): void {
 ): void {
   const groupValidator = groupAnchor
   const groupValidator = groupAnchor
     ? new GroupFrameValidator(groupAnchor.epoch, groupAnchor.cursor)
     ? 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 (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.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')
       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) {
     } catch (error) {
       const reason = error instanceof Error ? error.message : 'Invalid frame'
       const reason = error instanceof Error ? error.message : 'Invalid frame'
       closePair(client, upstream, 1008, reason)
       closePair(client, upstream, 1008, reason)
@@ -382,6 +493,7 @@ export function installWebSocketRelay(
   server: HttpServer,
   server: HttpServer,
   config: ServerConfig,
   config: ServerConfig,
   leases: RealtimeLeaseStore,
   leases: RealtimeLeaseStore,
+  pairings: NodePairingStore,
 ): () => void {
 ): () => void {
   const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
   const socketServer = new WebSocketServer({ noServer: true, maxPayload: CHAT_MAX_PAYLOAD })
   const active = new Map<string, number>()
   const active = new Map<string, number>()
@@ -393,6 +505,99 @@ export function installWebSocketRelay(
       rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
       rejectUpgrade(socket, 400, 'Invalid WebSocket URL')
       return
       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 (!url.pathname.startsWith('/ws/')) {
       if (config.production) rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
       if (config.production) rejectUpgrade(socket, 404, 'Unknown WebSocket endpoint')
       return
       return

+ 19 - 1
src/shared/types.ts

@@ -241,7 +241,7 @@ export interface GroupLimits {
 }
 }
 
 
 export const MIN_SUPPORTED_GROUP_PROTOCOL_VERSION = 2
 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 const SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL = `v${MIN_SUPPORTED_GROUP_PROTOCOL_VERSION}–v${MAX_SUPPORTED_GROUP_PROTOCOL_VERSION}`
 
 
 export function isSupportedGroupProtocolVersion(value: number): boolean {
 export function isSupportedGroupProtocolVersion(value: number): boolean {
@@ -266,6 +266,8 @@ export interface GroupAgent {
   id: string
   id: string
   roomId: string
   roomId: string
   profile: string
   profile: string
+  nodeId: string
+  nodeLabel: string
   displayName: string
   displayName: string
   description: string
   description: string
   storedSessionId?: string | null
   storedSessionId?: string | null
@@ -282,6 +284,22 @@ export interface GroupAgent {
   status: GroupAgentStatus
   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 {
 export interface GroupMessage {
   seq: number
   seq: number
   id: string
   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 CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.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 {
 function agent(id: string, profile: string, displayName: string, isHost: boolean, replyWithoutMention: boolean): GroupAgent {
   return {
   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,
     lastContextMessageSeq: 0, enabled: true, replyWithoutMention, isHost,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     createdAt: 1, updatedAt: 1, status: 'idle',
     createdAt: 1, updatedAt: 1, status: 'idle',
@@ -15,10 +19,34 @@ function agent(id: string, profile: string, displayName: string, isHost: boolean
 }
 }
 
 
 describe('group host controls', () => {
 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 () => {
   it('keeps one independent host selection when creating a v5 room', async () => {
     const wrapper = mount(CreateGroupDialog, {
     const wrapper = mount(CreateGroupDialog, {
       attachTo: document.body,
       attachTo: document.body,
-      props: { open: true, profiles: ['yaoyao', 'yaoer'], hostEnabled: true },
+      props: { open: true, profiles: profiles('yaoyao', 'yaoer'), hostEnabled: true },
       global: { stubs: { teleport: true } },
       global: { stubs: { teleport: true } },
     })
     })
     await nextTick()
     await nextTick()
@@ -26,16 +54,16 @@ describe('group host controls', () => {
     const profileButtons = wrapper.findAll('.agent-picker > button')
     const profileButtons = wrapper.findAll('.agent-picker > button')
     await profileButtons[1]!.trigger('click')
     await profileButtons[1]!.trigger('click')
     const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
     const host = wrapper.get<HTMLSelectElement>('select[aria-label="主持人"]')
-    await host.setValue('yaoer')
+    await host.setValue('local|yaoer')
     await profileButtons[1]!.trigger('click')
     await profileButtons[1]!.trigger('click')
-    expect(host.element.value).toBe('yaoyao')
+    expect(host.element.value).toBe('local|yaoyao')
     await profileButtons[1]!.trigger('click')
     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)
     expect(wrapper.get<HTMLInputElement>('input[aria-label="所有成员无需 @ 也回复"]').element.checked).toBe(true)
     await wrapper.get('.solid-button').trigger('click')
     await wrapper.get('.solid-button').trigger('click')
 
 
     expect(wrapper.emitted('create')?.[0]?.[0]).toEqual({
     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()
     wrapper.unmount()
   })
   })
@@ -43,7 +71,7 @@ describe('group host controls', () => {
   it('keeps the legacy v4 create UI and payload free of host fields', async () => {
   it('keeps the legacy v4 create UI and payload free of host fields', async () => {
     const wrapper = mount(CreateGroupDialog, {
     const wrapper = mount(CreateGroupDialog, {
       attachTo: document.body,
       attachTo: document.body,
-      props: { open: true, profiles: ['yaoyao'], hostEnabled: false },
+      props: { open: true, profiles: profiles('yaoyao'), hostEnabled: false },
       global: { stubs: { teleport: true } },
       global: { stubs: { teleport: true } },
     })
     })
     await nextTick()
     await nextTick()
@@ -62,7 +90,7 @@ describe('group host controls', () => {
       attachTo: document.body,
       attachTo: document.body,
       props: {
       props: {
         open: true,
         open: true,
-        profiles: ['yaoyao', 'yaoer'],
+        profiles: profiles('yaoyao', 'yaoer'),
         hostEnabled: true,
         hostEnabled: true,
         hostFlowEnabled: true,
         hostFlowEnabled: true,
         roomInstructionsEnabled: 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 {
 function groupAgent(id: string, displayName: string, isHost: boolean): GroupAgent {
   return {
   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,
     lastContextMessageSeq: 0, enabled: true, replyWithoutMention: false, isHost,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     model: null, provider: null, reasoningEffort: null, fastMode: null,
     createdAt: 1, updatedAt: 1, status: 'idle',
     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(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(10)).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', () => {
 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 () => {
   it('keeps the upstream ticket server-side and filters chat RPC methods', async () => {
     const received: Array<Record<string, unknown>> = []
     const received: Array<Record<string, unknown>> = []
     const upstream = createServer((request, response) => {
     const upstream = createServer((request, response) => {