diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c6dcc8b..ed8d409a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@
### Added
+- 手机壳与 phone-link.v1:手机打开产品为原生壳(组织/指令/桌面/设置);一键连接电脑发指令;组织跟随电脑当前工作区;设置可断开配对。托管入口见 deploy/ 与 start.sh。
+
- #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。
- #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。
diff --git a/apps/desktop/src/phone-link-host.cjs b/apps/desktop/src/phone-link-host.cjs
new file mode 100644
index 00000000..3f7b65b8
--- /dev/null
+++ b/apps/desktop/src/phone-link-host.cjs
@@ -0,0 +1,249 @@
+const { randomUUID } = require("node:crypto");
+const path = require("node:path");
+const { pathToFileURL } = require("node:url");
+
+const MAX_SUMMARY = 2000;
+
+function loadWebSocket() {
+ if (typeof WebSocket === "function") return WebSocket;
+ const wsPath = path.join(__dirname, "..", "..", "..", "deploy", "node_modules", "ws");
+ return require(wsPath).WebSocket;
+}
+
+function bind(socket, event, handler) {
+ const wrapped = (...args) => {
+ if (event === "message") {
+ const raw = args[0] && args[0].data !== undefined ? args[0].data : args[0];
+ handler({ data: raw });
+ return;
+ }
+ handler(args[0]);
+ };
+ if (typeof socket.addEventListener === "function") socket.addEventListener(event, wrapped);
+ else socket.on(event, wrapped);
+}
+
+function truncate(text) {
+ if (typeof text !== "string" || text.length === 0) return "";
+ return text.length <= MAX_SUMMARY ? text : `${text.slice(0, MAX_SUMMARY - 1)}…`;
+}
+
+function summarizeTurn(record) {
+ if (!record || typeof record !== "object") return "已完成";
+ if (typeof record.output === "string") return truncate(record.output);
+ if (record.output && typeof record.output === "object") {
+ for (const key of ["text", "answer", "message", "content"]) {
+ if (typeof record.output[key] === "string") return truncate(record.output[key]);
+ }
+ }
+ const deltas = Array.isArray(record.events)
+ ? record.events
+ .filter((event) => event && event.type === "model.delta")
+ .map((event) => event.text || event.delta || "")
+ .join("")
+ : "";
+ if (deltas) return truncate(deltas);
+ if (record.error && typeof record.error.message === "string") return truncate(record.error.message);
+ return "已完成";
+}
+
+function firstReadyEngine(health) {
+ const hosts = health && typeof health === "object" ? health.hosts : null;
+ if (!hosts || typeof hosts !== "object") return null;
+ const preferred = ["qoder", "claude-local", "claude-code", "codex-local", "codex", "workbuddy"];
+ for (const id of preferred) {
+ if (hosts[id] && hosts[id].ready === true) return id;
+ }
+ for (const [id, host] of Object.entries(hosts)) {
+ if (host && host.ready === true) return id;
+ }
+ return null;
+}
+
+function firstPositionId(tree, preferred) {
+ if (typeof preferred === "string" && preferred.trim()) return preferred.trim();
+ const walk = (nodes) => {
+ if (!Array.isArray(nodes)) return null;
+ for (const node of nodes) {
+ if (node && typeof node.id === "string") return node.id;
+ const nested = walk(node && node.children);
+ if (nested) return nested;
+ }
+ return null;
+ };
+ return walk(tree && tree.tree);
+}
+
+async function defaultLoadSnapshot(dir) {
+ const url = pathToFileURL(path.join(__dirname, "..", "..", "..", "deploy", "mobile-surface.mjs")).href;
+ const mod = await import(url);
+ return mod.liveOrgSnapshot(mod.loadWorkspaceSnapshot(dir), dir);
+}
+
+async function collectLiveOrgSnapshot(apiRequest, loadSnapshot = defaultLoadSnapshot) {
+ const workspace = await apiRequest("/workspace");
+ if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) {
+ return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
+ }
+ const dir = workspace.body.path;
+ if (typeof dir !== "string" || dir.length === 0) {
+ return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
+ }
+ try {
+ const snapshot = await loadSnapshot(dir);
+ const live = snapshot && snapshot.source === "live"
+ ? snapshot
+ : { ...snapshot, open: true, source: "live" };
+ if (JSON.stringify(live).includes(dir)) {
+ return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
+ }
+ return live;
+ } catch {
+ return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
+ }
+}
+
+async function dispatchPhoneCommand(apiRequest, text, preferredPositionId) {
+ const workspace = await apiRequest("/workspace");
+ if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) {
+ return { state: "failed", summary: "电脑上还没有打开工作区" };
+ }
+ const org = await apiRequest("/org/tree");
+ const positionId = firstPositionId(org.body, preferredPositionId);
+ if (!positionId) return { state: "failed", summary: "组织里没有可派发的员工" };
+ const health = await apiRequest("/health", { withAuth: false });
+ const engine = firstReadyEngine(health.body);
+ if (!engine) return { state: "failed", summary: "本机 Agent 还没就绪" };
+ const sessions = await apiRequest(`/sessions?positionId=${encodeURIComponent(positionId)}`);
+ let sessionId = sessions.body && sessions.body.activeSessionId;
+ if (!sessionId) {
+ const created = await apiRequest("/sessions", { method: "POST", body: { positionId } });
+ if (created.status !== 201 && created.status !== 200) {
+ return { state: "failed", summary: created.body && created.body.message ? created.body.message : "无法创建会话" };
+ }
+ sessionId = created.body.sessionId;
+ }
+ const turn = await apiRequest(`/sessions/${sessionId}/turns`, {
+ method: "POST",
+ body: { input: text, engine },
+ });
+ if (turn.status === 409) return { state: "busy", summary: "该员工正在处理别的任务" };
+ if (turn.status === 400 && turn.body && /approval/i.test(String(turn.body.message ?? ""))) {
+ return { state: "needs_approval", summary: "请在电脑上确认这次操作" };
+ }
+ if (turn.status !== 200 && turn.status !== 201 && turn.status !== 202) {
+ return { state: "failed", summary: turn.body && turn.body.message ? String(turn.body.message) : "回合没有启动" };
+ }
+ if (turn.body && turn.body.status === "failed") {
+ return { state: "failed", summary: summarizeTurn(turn.body) };
+ }
+ return { state: "completed", summary: summarizeTurn(turn.body) };
+}
+
+function startPhoneLinkHost({
+ port,
+ hostToken,
+ apiRequest,
+ preferredPositionId,
+ onPairCode,
+ WebSocketImpl = loadWebSocket(),
+} = {}) {
+ if (!port || typeof hostToken !== "string" || !/^[a-f0-9]{64}$/.test(hostToken)) {
+ return { stop() {} };
+ }
+ let socket = null;
+ let stopped = false;
+ let retryTimer = null;
+ let orgTimer = null;
+
+ const publishOrg = async () => {
+ if (stopped || !socket) return;
+ try {
+ const snapshot = await collectLiveOrgSnapshot(apiRequest);
+ if (stopped || !socket) return;
+ socket.send(JSON.stringify({ v: 1, type: "org.snapshot", snapshot }));
+ } catch {
+ // A missed org refresh must not drop the command channel.
+ }
+ };
+
+ const connect = () => {
+ if (stopped) return;
+ socket = new WebSocketImpl(`ws://127.0.0.1:${port}/phone-link/host`);
+ bind(socket, "open", () => {
+ socket.send(JSON.stringify({ v: 1, type: "host.hello", token: hostToken }));
+ });
+ bind(socket, "message", async (event) => {
+ let message;
+ try {
+ message = JSON.parse(String(event.data));
+ } catch {
+ return;
+ }
+ if (message.type === "host.accepted") {
+ socket.send(JSON.stringify({ v: 1, type: "pair.start" }));
+ void publishOrg();
+ if (orgTimer) clearInterval(orgTimer);
+ orgTimer = setInterval(() => { void publishOrg(); }, 5000);
+ return;
+ }
+ if (message.type === "pair.ready" && typeof onPairCode === "function") {
+ onPairCode(message.code, message.expiresAt);
+ return;
+ }
+ if (message.type !== "command.submit") return;
+ socket.send(JSON.stringify({
+ v: 1, type: "command.status", commandId: message.commandId,
+ deviceId: message.deviceId, state: "running",
+ }));
+ let result;
+ try {
+ result = await dispatchPhoneCommand(
+ apiRequest,
+ message.text,
+ message.positionId || preferredPositionId,
+ );
+ } catch (error) {
+ result = { state: "failed", summary: error instanceof Error ? error.message : "dispatch failed" };
+ }
+ if (stopped || !socket) return;
+ socket.send(JSON.stringify({
+ v: 1, type: "command.status", commandId: message.commandId,
+ deviceId: message.deviceId, state: result.state, summary: result.summary,
+ }));
+ });
+ bind(socket, "close", () => {
+ if (orgTimer) {
+ clearInterval(orgTimer);
+ orgTimer = null;
+ }
+ if (stopped) return;
+ retryTimer = setTimeout(connect, 2000);
+ });
+ bind(socket, "error", () => {});
+ };
+
+ connect();
+ return {
+ stop() {
+ stopped = true;
+ if (retryTimer) clearTimeout(retryTimer);
+ if (orgTimer) clearInterval(orgTimer);
+ if (socket) socket.close();
+ },
+ };
+}
+
+function newCommandId() {
+ return randomUUID();
+}
+
+module.exports = {
+ dispatchPhoneCommand,
+ collectLiveOrgSnapshot,
+ summarizeTurn,
+ firstReadyEngine,
+ firstPositionId,
+ startPhoneLinkHost,
+ newCommandId,
+};
diff --git a/apps/desktop/test/phone-link-host.test.cjs b/apps/desktop/test/phone-link-host.test.cjs
new file mode 100644
index 00000000..29988b8b
--- /dev/null
+++ b/apps/desktop/test/phone-link-host.test.cjs
@@ -0,0 +1,89 @@
+const assert = require("node:assert/strict");
+const test = require("node:test");
+const {
+ collectLiveOrgSnapshot,
+ dispatchPhoneCommand,
+ firstPositionId,
+ firstReadyEngine,
+ summarizeTurn,
+} = require("../src/phone-link-host.cjs");
+
+test("picks the first org-tree position unless one is bound", () => {
+ const tree = { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] };
+ assert.equal(firstPositionId(tree, "issue-researcher"), "issue-researcher");
+ assert.equal(firstPositionId(tree), "repo-owner");
+});
+
+test("picks a ready engine without inventing host ids", () => {
+ assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false }, "claude-local": { ready: true } } }), "claude-local");
+ assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false } } }), null);
+});
+
+test("summarizes a completed turn from visible text", () => {
+ assert.equal(summarizeTurn({ output: { text: "合了 #198" } }), "合了 #198");
+});
+
+test("dispatch creates a session and posts a turn", async () => {
+ const calls = [];
+ const apiRequest = async (pathname, options = {}) => {
+ calls.push({ pathname, method: options.method ?? "GET", body: options.body ?? null });
+ if (pathname === "/workspace") return { status: 200, body: { open: true } };
+ if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } };
+ if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
+ if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: null, sessions: [] } };
+ if (pathname === "/sessions") return { status: 201, body: { sessionId: "sess-1" } };
+ if (pathname === "/sessions/sess-1/turns") {
+ return { status: 200, body: { status: "completed", output: { text: "好的,我去处理" } } };
+ }
+ throw new Error(pathname);
+ };
+ const result = await dispatchPhoneCommand(apiRequest, "看一下 PR");
+ assert.equal(result.state, "completed");
+ assert.equal(result.summary, "好的,我去处理");
+ assert.equal(calls.some((call) => call.pathname === "/sessions/sess-1/turns" && call.body.input === "看一下 PR"), true);
+});
+
+test("dispatch uses the selected position id", async () => {
+ const paths = [];
+ const apiRequest = async (pathname, options = {}) => {
+ paths.push(pathname);
+ if (pathname === "/workspace") return { status: 200, body: { open: true } };
+ if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] } };
+ if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
+ if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-r" } };
+ if (pathname === "/sessions/sess-r/turns") {
+ assert.equal(options.body.input, "去调研");
+ return { status: 200, body: { status: "completed", output: "ok" } };
+ }
+ throw new Error(pathname);
+ };
+ const result = await dispatchPhoneCommand(apiRequest, "去调研", "issue-researcher");
+ assert.equal(result.state, "completed");
+ assert.equal(paths.includes("/sessions?positionId=issue-researcher"), true);
+});
+
+test("live org snapshot omits the workspace path", async () => {
+ const closed = await collectLiveOrgSnapshot(async () => ({ status: 200, body: { open: false } }));
+ assert.equal(closed.source, "closed");
+ const dir = "/secret/workspace-path";
+ const live = await collectLiveOrgSnapshot(
+ async () => ({ status: 200, body: { open: true, path: dir } }),
+ async () => ({ name: "current", owner: "repo-owner", description: "", roles: [{ id: "repo-owner", name: "仓库负责人" }] }),
+ );
+ assert.equal(live.source, "live");
+ assert.equal(live.name, "current");
+ assert.equal(JSON.stringify(live).includes(dir), false);
+});
+
+test("busy employee is reported instead of queued", async () => {
+ const apiRequest = async (pathname) => {
+ if (pathname === "/workspace") return { status: 200, body: { open: true } };
+ if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } };
+ if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
+ if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-1" } };
+ if (pathname === "/sessions/sess-1/turns") return { status: 409, body: { code: "session_conflict" } };
+ throw new Error(pathname);
+ };
+ const result = await dispatchPhoneCommand(apiRequest, "再来一条");
+ assert.equal(result.state, "busy");
+});
diff --git a/deploy/command.css b/deploy/command.css
new file mode 100644
index 00000000..0e7ecd17
--- /dev/null
+++ b/deploy/command.css
@@ -0,0 +1,34 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, system-ui, sans-serif;
+ --bg: #101116;
+ --ink: #f5f6fa;
+ --muted: #a7adbb;
+ --line: #363946;
+ --accent: #7aa2ff;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
+body { padding: max(20px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); }
+main { max-width: 28rem; margin: 0 auto; }
+.eyebrow { margin: 0 0 8px; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; font-size: 12px; }
+h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; }
+.lead, #status { color: var(--muted); line-height: 1.5; }
+form { display: grid; gap: 10px; margin: 24px 0; }
+label { font-size: 14px; }
+input, textarea, button {
+ width: 100%; font: inherit; color: var(--ink);
+ border: 1px solid var(--line); border-radius: 12px;
+ background: #1c1e26;
+}
+input, textarea { padding: 14px; }
+input { letter-spacing: 0.4em; font-size: 22px; text-align: center; }
+button {
+ min-height: 48px; background: var(--accent); color: #101116;
+ border: 0; font-weight: 650; cursor: pointer;
+}
+button:disabled { opacity: 0.55; cursor: not-allowed; }
+#summary {
+ white-space: pre-wrap; background: #1c1e26; border: 1px solid var(--line);
+ border-radius: 12px; padding: 14px; line-height: 1.45;
+}
diff --git a/deploy/command.html b/deploy/command.html
new file mode 100644
index 00000000..eafc5e0b
--- /dev/null
+++ b/deploy/command.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ 发指令 · RoleWeave
+
+
+
+
+ RoleWeave
+ 发一条指令
+ 发给电脑上已打开的 RoleWeave。同一产品地址可一键连接;也可以输入电脑弹窗里的 6 位码。
+ 正在查看电脑是否在线…
+
+
+
+
+
+
+
+ 尚未配对。
+
+
+
+
+
diff --git a/deploy/command.mjs b/deploy/command.mjs
new file mode 100644
index 00000000..0fa680cb
--- /dev/null
+++ b/deploy/command.mjs
@@ -0,0 +1,144 @@
+const statusEl = document.getElementById("status");
+const summaryEl = document.getElementById("summary");
+const pairForm = document.getElementById("pair-form");
+const claimForm = document.getElementById("claim-form");
+const hostStatus = document.getElementById("host-status");
+const commandForm = document.getElementById("command-form");
+const storageKey = "roleweave-phone-link-v1";
+
+let socket = null;
+let deviceToken = localStorage.getItem(storageKey);
+
+function setStatus(text) {
+ statusEl.textContent = text;
+}
+
+function showSummary(text) {
+ if (!text) {
+ summaryEl.hidden = true;
+ summaryEl.textContent = "";
+ return;
+ }
+ summaryEl.hidden = false;
+ summaryEl.textContent = text;
+}
+
+function connectedUi() {
+ pairForm.hidden = true;
+ claimForm.hidden = true;
+ commandForm.hidden = false;
+}
+
+function disconnectedUi() {
+ pairForm.hidden = false;
+ claimForm.hidden = false;
+ commandForm.hidden = true;
+}
+
+function connect() {
+ if (!deviceToken) return;
+ socket?.close();
+ const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/phone-link/phone`;
+ socket = new WebSocket(url);
+ socket.addEventListener("open", () => {
+ socket.send(JSON.stringify({ v: 1, type: "phone.hello", deviceToken }));
+ });
+ socket.addEventListener("message", (event) => {
+ let message;
+ try {
+ message = JSON.parse(String(event.data));
+ } catch {
+ return;
+ }
+ if (message.type === "phone.accepted") {
+ connectedUi();
+ setStatus("已连上电脑,可以发指令。");
+ return;
+ }
+ if (message.type === "command.status") {
+ const labels = {
+ accepted: "电脑已接到",
+ running: "员工正在处理",
+ completed: "完成",
+ failed: "失败",
+ busy: "该员工正在忙",
+ needs_approval: "请在电脑上确认",
+ };
+ setStatus(labels[message.state] ?? message.state);
+ if (message.summary) showSummary(message.summary);
+ return;
+ }
+ if (message.type === "error") {
+ if (message.code === "unauthorized" || message.code === "revoked") {
+ localStorage.removeItem(storageKey);
+ deviceToken = null;
+ disconnectedUi();
+ }
+ setStatus(message.message ?? "出错了");
+ }
+ });
+ socket.addEventListener("close", () => {
+ if (deviceToken) setStatus("连接断开,正在重试…");
+ });
+}
+
+async function acceptGrant(body) {
+ if (!body?.deviceToken) return false;
+ deviceToken = body.deviceToken;
+ localStorage.setItem(storageKey, deviceToken);
+ connect();
+ return true;
+}
+
+async function refreshHostStatus() {
+ if (!hostStatus) return;
+ try {
+ const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } });
+ const body = await response.json();
+ hostStatus.textContent = body.hostOnline ? "电脑在线,可以连接。" : "电脑还没连上。先打开 RoleWeave 桌面。";
+ } catch {
+ hostStatus.textContent = "暂时读不到电脑状态。";
+ }
+}
+
+claimForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ setStatus("正在连接电脑…");
+ const response = await fetch("/phone-link/v1/claim", { method: "POST" });
+ const body = await response.json();
+ if (!response.ok || !(await acceptGrant(body))) {
+ setStatus(body.message ?? "连接失败");
+ }
+});
+
+pairForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const code = document.getElementById("code").value.trim();
+ setStatus("正在配对…");
+ const response = await fetch("/phone-link/v1/pair", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ code }),
+ });
+ const body = await response.json();
+ if (!response.ok || !(await acceptGrant(body))) {
+ setStatus(body.message ?? "配对失败");
+ }
+});
+
+commandForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const text = document.getElementById("text").value.trim();
+ if (!text || !socket || socket.readyState !== WebSocket.OPEN) {
+ setStatus("还没连上电脑");
+ return;
+ }
+ showSummary("");
+ const commandId = crypto.randomUUID();
+ socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text }));
+ setStatus("已发出,等电脑受理…");
+});
+
+if (deviceToken) connect();
+void refreshHostStatus();
+setInterval(() => { void refreshHostStatus(); }, 4000);
diff --git a/deploy/mobile-surface.mjs b/deploy/mobile-surface.mjs
new file mode 100644
index 00000000..6a668e25
--- /dev/null
+++ b/deploy/mobile-surface.mjs
@@ -0,0 +1,157 @@
+import fs from "node:fs";
+import path from "node:path";
+
+const MOBILE_UA = /Android|iPhone|iPod|iPad|Mobile|ASteamApp|webOS|BlackBerry|IEMobile|Opera Mini/i;
+const SKILL_EXCERPT_CHARS = 480;
+const PHONE_PLATFORMS = new Set(["ios", "android", "harmony"]);
+
+export function isMobileUserAgent(userAgent) {
+ return typeof userAgent === "string" && MOBILE_UA.test(userAgent);
+}
+
+export function detectPlatform(userAgent) {
+ const ua = typeof userAgent === "string" ? userAgent : "";
+ if (/OpenHarmony|ArkWeb|HarmonyOS/i.test(ua)) return "harmony";
+ if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
+ if (/Android/i.test(ua)) return "android";
+ if (isMobileUserAgent(ua)) return "android";
+ return "desktop";
+}
+
+export function choosePlatform({ userAgent, searchParams }) {
+ const params = searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams();
+ const requestedPlatform = params.get("platform");
+ if (requestedPlatform === "desktop" || PHONE_PLATFORMS.has(requestedPlatform)) return requestedPlatform;
+ if (params.get("surface") === "desktop") return "desktop";
+ const detected = detectPlatform(userAgent);
+ if (params.get("surface") === "mobile" && detected === "desktop") return "android";
+ return detected;
+}
+
+export function chooseSurface({ userAgent, searchParams }) {
+ return choosePlatform({ userAgent, searchParams }) === "desktop" ? "desktop" : "mobile";
+}
+
+function safeJoin(root, relative) {
+ const candidate = path.resolve(root, relative);
+ if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null;
+ return candidate;
+}
+
+function phoneDir(deployDir, platform) {
+ return path.join(deployDir, "mobile", PHONE_PLATFORMS.has(platform) ? platform : "android");
+}
+
+export function resolvePublicAsset(urlPath, { deployDir, noVncDir, userAgent, searchParams }) {
+ const platform = choosePlatform({ userAgent, searchParams });
+ if (urlPath === "/") {
+ return platform === "desktop"
+ ? path.join(deployDir, "index.html")
+ : path.join(phoneDir(deployDir, platform), "index.html");
+ }
+ if (urlPath === "/mobile" || urlPath === "/mobile/") {
+ return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "index.html");
+ }
+ if (urlPath === "/desktop" || urlPath === "/desktop/") return path.join(deployDir, "index.html");
+ const phoneAsset = /^\/(ios|android|harmony)\/(index\.html|app\.css|app\.mjs)$/.exec(urlPath);
+ if (phoneAsset) return path.join(deployDir, "mobile", phoneAsset[1], phoneAsset[2]);
+ if (urlPath === "/ios" || urlPath === "/ios/") return path.join(deployDir, "mobile", "ios", "index.html");
+ if (urlPath === "/android" || urlPath === "/android/") return path.join(deployDir, "mobile", "android", "index.html");
+ if (urlPath === "/harmony" || urlPath === "/harmony/") return path.join(deployDir, "mobile", "harmony", "index.html");
+ if (urlPath === "/shared/shell.mjs") return path.join(deployDir, "mobile", "shared", "shell.mjs");
+ if (urlPath === "/mobile/app.css") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.css");
+ if (urlPath === "/mobile/app.mjs") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.mjs");
+ if (urlPath === "/command" || urlPath === "/command.html") return path.join(deployDir, "command.html");
+ if (urlPath === "/command.css") return path.join(deployDir, "command.css");
+ if (urlPath === "/command.mjs") return path.join(deployDir, "command.mjs");
+ if (urlPath === "/client.mjs") return path.join(deployDir, "client.mjs");
+ if (!urlPath.startsWith("/novnc/")) return null;
+ const relative = decodeURIComponent(urlPath.slice("/novnc/".length));
+ if (relative.includes("\0") || relative.split(/[\\/]/).includes("..")) return null;
+ const candidate = path.resolve(noVncDir, relative);
+ return candidate.startsWith(`${noVncDir}${path.sep}`) ? candidate : null;
+}
+
+function findNamedDirectory(root, name) {
+ if (!fs.existsSync(root)) return null;
+ const stack = [root];
+ while (stack.length > 0) {
+ const current = stack.pop();
+ let entries;
+ try {
+ entries = fs.readdirSync(current, { withFileTypes: true });
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const full = path.join(current, entry.name);
+ if (entry.name === name) return full;
+ stack.push(full);
+ }
+ }
+ return null;
+}
+
+function skillExcerpt(positionDir) {
+ const skillPath = path.join(positionDir, "SKILL.md");
+ if (!fs.existsSync(skillPath)) return "";
+ const text = fs.readFileSync(skillPath, "utf8").replace(/\r\n/g, "\n").trim();
+ if (text.length <= SKILL_EXCERPT_CHARS) return text;
+ return `${text.slice(0, SKILL_EXCERPT_CHARS).trimEnd()}…`;
+}
+
+export function loadWorkspaceSnapshot(workspaceDir) {
+ const workspaceFile = path.join(workspaceDir, "workspace.json");
+ const orgFile = path.join(workspaceDir, "organization.v1alpha1.json");
+ if (!fs.existsSync(workspaceFile) || !fs.existsSync(orgFile)) {
+ throw new Error("directory is not a RoleWeave workspace");
+ }
+ const workspace = JSON.parse(fs.readFileSync(workspaceFile, "utf8"));
+ const org = JSON.parse(fs.readFileSync(orgFile, "utf8"));
+ if (!workspace || typeof workspace.name !== "string" || !org || !Array.isArray(org.roles)) {
+ throw new Error("directory is not a RoleWeave workspace");
+ }
+ const positionsRoot = path.join(workspaceDir, "positions");
+ const roles = org.roles.map((role) => {
+ const id = typeof role.id === "string" ? role.id : "";
+ const positionDir = findNamedDirectory(positionsRoot, id);
+ return {
+ id,
+ name: typeof role.name === "string" ? role.name : id,
+ description: typeof role.description === "string" ? role.description : "",
+ reportTo: role.reportTo ?? null,
+ budget: role.budget && typeof role.budget === "object" ? {
+ perTask: role.budget.perTask ?? null,
+ perDay: role.budget.perDay ?? null,
+ } : null,
+ skillExcerpt: positionDir ? skillExcerpt(positionDir) : "",
+ };
+ });
+ return {
+ name: workspace.name,
+ description: typeof workspace.description === "string" ? workspace.description : "",
+ owner: typeof org.owner === "string" ? org.owner : null,
+ roles,
+ };
+}
+
+export function closedOrgSnapshot() {
+ return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
+}
+
+export function previewOrgSnapshot(snapshot) {
+ return { ...snapshot, open: false, source: "preview" };
+}
+
+export function liveOrgSnapshot(snapshot, workspacePath) {
+ const live = { ...snapshot, open: true, source: "live" };
+ if (typeof workspacePath === "string" && workspacePath.length > 0 && JSON.stringify(live).includes(workspacePath)) {
+ throw new Error("snapshot_leaks_path");
+ }
+ return live;
+}
+
+export function defaultExampleDir(productDir) {
+ return safeJoin(productDir, path.join("examples", "oss-maintainer"));
+}
diff --git a/deploy/mobile/android/app.css b/deploy/mobile/android/app.css
new file mode 100644
index 00000000..71dd88d4
--- /dev/null
+++ b/deploy/mobile/android/app.css
@@ -0,0 +1,72 @@
+:root {
+ color-scheme: dark;
+ --md-bg: #131316;
+ --md-surface: #1b1b1f;
+ --md-ink: #e6e1e5;
+ --md-muted: #cac4d0;
+ --md-line: #49454f;
+ --md-accent: #7aa2ff;
+ --md-on-accent: #001b3f;
+ font-family: Roboto, "Noto Sans SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--md-bg); color: var(--md-ink); }
+body {
+ min-height: 100dvh;
+ padding: 0 0 calc(80px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.md-bar {
+ padding: max(12px, env(safe-area-inset-top)) 16px 16px;
+ background: var(--md-surface);
+ box-shadow: 0 1px 3px rgb(0 0 0 / 40%);
+}
+.md-bar__over { margin: 0; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--md-accent); }
+.md-bar h1 { margin: 6px 0 0; font-size: 22px; font-weight: 500; }
+.md-body, .host-status, #command-status { margin: 16px; color: var(--md-muted); line-height: 1.5; }
+.panel[hidden] { display: none; }
+.md-list { list-style: none; margin: 0; padding: 0; }
+.md-list .role, .md-settings li {
+ width: 100%; min-height: 72px; padding: 16px;
+ border: 0; border-bottom: 1px solid var(--md-line);
+ background: var(--md-surface); color: inherit; font: inherit; text-align: left;
+ display: grid; gap: 4px;
+}
+.md-list .role:active { background: #2b2930; }
+.md-sheet { margin: 16px; padding: 16px; background: var(--md-surface); border-radius: 12px; display: grid; gap: 8px; }
+.md-stack { display: grid; gap: 12px; margin: 16px; }
+label { font-size: 12px; color: var(--md-muted); }
+input, textarea {
+ width: 100%; padding: 16px; border-radius: 4px 4px 0 0; border: 0; border-bottom: 2px solid var(--md-accent);
+ background: #2b2930; color: inherit; font: inherit;
+}
+input { letter-spacing: 0.35em; font-size: 22px; text-align: center; }
+.md-fab {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; border-radius: 16px; background: var(--md-accent); color: var(--md-on-accent);
+ font: inherit; font-weight: 600; text-decoration: none;
+}
+.md-text {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; background: transparent; color: var(--md-accent); font: inherit; text-decoration: none;
+}
+.md-nav {
+ position: fixed; left: 0; right: 0; bottom: 0;
+ display: grid; grid-template-columns: repeat(4, 1fr);
+ height: calc(80px + env(safe-area-inset-bottom));
+ padding-bottom: env(safe-area-inset-bottom);
+ background: var(--md-surface); border-top: 1px solid var(--md-line);
+}
+.tab {
+ min-height: 48px; border: 0; background: transparent; color: var(--md-muted);
+ font-size: 12px; font-weight: 500;
+}
+.tab.is-active { color: var(--md-accent); background: #2b2930; }
+.error { color: #f2b8b5; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.chip { font-size: 12px; color: var(--md-muted); }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--md-muted); font: 13px/1.45 ui-monospace, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--md-accent); outline-offset: 2px;
+}
diff --git a/deploy/mobile/android/app.mjs b/deploy/mobile/android/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/deploy/mobile/android/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/deploy/mobile/android/index.html b/deploy/mobile/android/index.html
new file mode 100644
index 00000000..c79384ab
--- /dev/null
+++ b/deploy/mobile/android/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ RoleWeave
+
+
+
+
+ RoleWeave · Android
+ 工作区
+
+
+
+
+ 发给电脑上已打开的 RoleWeave。员工在电脑上跑。
+ 正在查看电脑是否在线…
+
+
+
+ 尚未连接。
+
+
+
+ 完整工作台在电脑上。需要时再打开远程桌面。
+ 打开远程桌面
+
+
+
+ - 平台Android
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/deploy/mobile/app.css b/deploy/mobile/app.css
new file mode 100644
index 00000000..fac0c44e
--- /dev/null
+++ b/deploy/mobile/app.css
@@ -0,0 +1,235 @@
+:root {
+ color-scheme: dark;
+ --bg: #12141b;
+ --bg-elev: #1b1d26;
+ --ink: #f3f4f8;
+ --muted: #b0b5c4;
+ --line: #323644;
+ --accent: #7aa2ff;
+ --accent-ink: #101116;
+ --danger: #f0a8a8;
+ --radius: 16px;
+ --pad: 16px;
+ --tab: 64px;
+ font-family: Inter, "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
+body {
+ min-height: 100dvh;
+ padding:
+ max(12px, env(safe-area-inset-top))
+ max(var(--pad), env(safe-area-inset-right))
+ calc(var(--tab) + env(safe-area-inset-bottom))
+ max(var(--pad), env(safe-area-inset-left));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+
+.skip {
+ position: absolute;
+ left: -999px;
+ top: 8px;
+}
+.skip:focus {
+ left: 12px;
+ z-index: 20;
+ padding: 8px 12px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-radius: 8px;
+}
+
+.top { margin-bottom: 18px; }
+.brand { display: flex; gap: 12px; align-items: center; }
+.mark { width: 36px; height: 36px; flex: none; }
+.eyebrow {
+ margin: 0;
+ color: var(--accent);
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+h1 {
+ margin: 2px 0 0;
+ font-size: 22px;
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+}
+.lead, #command-status { color: var(--muted); line-height: 1.5; font-size: 15px; }
+
+.panel[hidden] { display: none; }
+.panel.is-active { display: block; }
+
+.roles {
+ list-style: none;
+ margin: 16px 0 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+.role {
+ width: 100%;
+ text-align: left;
+ min-height: 72px;
+ padding: 14px 16px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-elev);
+ color: inherit;
+ font: inherit;
+ display: grid;
+ gap: 4px;
+}
+.role:focus-visible, .button:focus-visible, .tab:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.role strong { font-size: 16px; }
+.role span { color: var(--muted); font-size: 13px; }
+.role:active { transform: scale(0.99); }
+
+.detail {
+ margin-top: 14px;
+ padding: 16px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-elev);
+ display: grid;
+ gap: 10px;
+}
+.detail pre {
+ white-space: pre-wrap;
+ margin: 0;
+ color: var(--muted);
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.chip {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 4px 10px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.stack { display: grid; gap: 10px; margin: 20px 0; }
+label { font-size: 14px; }
+input, textarea, button, .button {
+ width: 100%;
+ font: inherit;
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--bg-elev);
+}
+input, textarea { padding: 14px; }
+input { letter-spacing: 0.35em; font-size: 22px; text-align: center; }
+button, .button {
+ min-height: 48px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent);
+ color: var(--accent-ink);
+ border: 0;
+ font-weight: 650;
+ text-decoration: none;
+ cursor: pointer;
+}
+button:disabled { opacity: 0.55; cursor: not-allowed; }
+.button.ghost {
+ background: transparent;
+ color: var(--ink);
+ border: 1px solid var(--line);
+}
+.button.danger {
+ width: 100%;
+ margin-bottom: 12px;
+ background: #3a2428;
+ color: var(--danger);
+ border: 1px solid #5a343c;
+}
+#command-summary {
+ white-space: pre-wrap;
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 14px;
+ line-height: 1.45;
+}
+
+.settings {
+ list-style: none;
+ margin: 0 0 16px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.settings li {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ min-height: 48px;
+ border-bottom: 1px solid var(--line);
+}
+.settings li:last-child { border-bottom: 0; }
+.settings span { color: var(--muted); }
+.settings strong { font-weight: 600; text-align: right; }
+
+.tabs {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 4px;
+ padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
+ background: rgb(18 20 27 / 94%);
+ border-top: 1px solid var(--line);
+ backdrop-filter: blur(12px);
+}
+.tab {
+ min-height: 48px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ display: grid;
+ justify-items: center;
+ gap: 2px;
+ font-size: 11px;
+ font-weight: 600;
+ width: auto;
+ border-radius: 12px;
+}
+.tab svg { width: 22px; height: 22px; fill: currentColor; }
+.tab.is-active { color: var(--ink); background: #262936; }
+
+.error { color: var(--danger); }
+.host-status { margin: 0 0 16px; color: var(--muted); }
+details {
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 8px 12px 12px;
+ background: var(--bg-elev);
+}
+summary {
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ font-weight: 650;
+}
+details .stack { margin-top: 8px; }
+
+@media (prefers-reduced-motion: reduce) {
+ .role:active { transform: none; }
+}
+
+@media (min-width: 720px) {
+ main, .top { max-width: 42rem; margin-left: auto; margin-right: auto; }
+}
diff --git a/deploy/mobile/app.mjs b/deploy/mobile/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/deploy/mobile/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/deploy/mobile/harmony/app.css b/deploy/mobile/harmony/app.css
new file mode 100644
index 00000000..be35030a
--- /dev/null
+++ b/deploy/mobile/harmony/app.css
@@ -0,0 +1,63 @@
+:root {
+ color-scheme: dark;
+ --hw-bg: #16141a;
+ --hw-card: #221f28;
+ --hw-ink: #f4f0f7;
+ --hw-muted: #b7b0c2;
+ --hw-line: #3a3544;
+ --hw-accent: #c9a7ff;
+ --hw-on-accent: #2b1658;
+ font-family: "HarmonyOS Sans", "Noto Sans SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--hw-bg); color: var(--hw-ink); }
+body {
+ min-height: 100dvh;
+ padding: max(16px, env(safe-area-inset-top)) 16px calc(72px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.hw-hero { margin-bottom: 18px; }
+.hw-kicker { margin: 0; color: var(--hw-accent); font-size: 12px; letter-spacing: 0.12em; }
+.hw-hero h1 { margin: 8px 0 0; font-size: 28px; font-weight: 650; }
+.hw-copy, .host-status, #command-status { color: var(--hw-muted); line-height: 1.55; }
+.panel[hidden] { display: none; }
+.hw-cards { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; }
+.hw-cards .role, .hw-settings li {
+ width: 100%; min-height: 88px; padding: 18px;
+ border: 1px solid var(--hw-line); border-radius: 20px;
+ background: var(--hw-card); color: inherit; font: inherit; text-align: left;
+ display: grid; gap: 6px;
+}
+.hw-settings li { display: flex; justify-content: space-between; align-items: center; min-height: 56px; }
+.hw-panel { margin-top: 14px; padding: 18px; border-radius: 20px; background: var(--hw-card); display: grid; gap: 10px; }
+.hw-stack { display: grid; gap: 12px; margin: 18px 0; }
+label { font-size: 13px; color: var(--hw-muted); }
+input, textarea {
+ width: 100%; padding: 14px; border-radius: 16px; border: 1px solid var(--hw-line);
+ background: #2a2631; color: inherit; font: inherit;
+}
+input { letter-spacing: 0.32em; font-size: 22px; text-align: center; }
+.hw-primary, .hw-ghost {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border-radius: 24px; font: inherit; font-weight: 650; text-decoration: none;
+}
+.hw-primary { border: 0; background: var(--hw-accent); color: var(--hw-on-accent); }
+.hw-ghost { border: 1px solid var(--hw-line); background: transparent; color: var(--hw-ink); }
+.hw-dock {
+ position: fixed; left: 12px; right: 12px; bottom: calc(8px + env(safe-area-inset-bottom));
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
+ padding: 8px; border-radius: 28px; background: var(--hw-card); border: 1px solid var(--hw-line);
+}
+.tab {
+ min-height: 48px; border: 0; border-radius: 20px; background: transparent; color: var(--hw-muted);
+ font-size: 12px; font-weight: 650;
+}
+.tab.is-active { color: var(--hw-on-accent); background: var(--hw-accent); }
+.error { color: #ffb4ab; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.chip { font-size: 12px; color: var(--hw-muted); }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--hw-muted); font: 13px/1.45 ui-monospace, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--hw-accent); outline-offset: 2px;
+}
diff --git a/deploy/mobile/harmony/app.mjs b/deploy/mobile/harmony/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/deploy/mobile/harmony/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/deploy/mobile/harmony/index.html b/deploy/mobile/harmony/index.html
new file mode 100644
index 00000000..5cadd36d
--- /dev/null
+++ b/deploy/mobile/harmony/index.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ RoleWeave 鸿蒙
+
+
+
+
+
+
+
+ 发给电脑上已打开的 RoleWeave。员工在电脑上跑。
+ 正在查看电脑是否在线…
+
+
+
+ 尚未连接。
+
+
+
+ 完整工作台在电脑上。需要时再打开远程桌面。
+ 打开远程桌面
+
+
+
+ - 平台HarmonyOS
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/deploy/mobile/index.html b/deploy/mobile/index.html
new file mode 100644
index 00000000..feb17863
--- /dev/null
+++ b/deploy/mobile/index.html
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+ RoleWeave
+
+
+
+ 跳到内容
+
+
+
+
+ 发给电脑上已打开的 RoleWeave。员工在电脑上跑,不在手机上跑。
+ 正在查看电脑是否在线…
+
+
+
+ 尚未连接。
+
+
+
+ 完整工作台仍是桌面应用。手机上可以打开远程桌面,但组织与发指令才是给手指用的主路径。
+ 打开远程桌面
+
+
+
+ -
+ 入口
+ 手机壳优先,桌面走 VNC
+
+ -
+ 组织数据
+ 尚未同步
+
+ -
+ 这台手机
+ 未连接
+
+ -
+ 回合
+ 经配对后的电脑宿主执行
+
+
+
+ 强制打开桌面版
+
+
+
+
+
+
diff --git a/deploy/mobile/ios/app.css b/deploy/mobile/ios/app.css
new file mode 100644
index 00000000..651b1a78
--- /dev/null
+++ b/deploy/mobile/ios/app.css
@@ -0,0 +1,73 @@
+:root {
+ color-scheme: dark;
+ --ios-bg: #000;
+ --ios-group: #1c1c1e;
+ --ios-ink: #f5f5f7;
+ --ios-muted: #8e8e93;
+ --ios-line: #38383a;
+ --ios-accent: #0a84ff;
+ font-family: -apple-system, "SF Pro Text", "PingFang SC", system-ui, sans-serif;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; min-height: 100%; background: var(--ios-bg); color: var(--ios-ink); }
+body {
+ min-height: 100dvh;
+ padding: max(8px, env(safe-area-inset-top)) 16px calc(56px + env(safe-area-inset-bottom));
+ overscroll-behavior: contain;
+ touch-action: manipulation;
+}
+.ios-nav { margin: 8px 4px 18px; }
+.ios-nav__product { margin: 0; color: var(--ios-muted); font-size: 13px; }
+.ios-nav h1 { margin: 4px 0 0; font-size: 34px; letter-spacing: -0.04em; }
+.ios-footnote, .host-status, #command-status { color: var(--ios-muted); font-size: 13px; line-height: 1.4; }
+.panel[hidden] { display: none; }
+.ios-group {
+ list-style: none; margin: 12px 0; padding: 0;
+ background: var(--ios-group); border-radius: 12px; overflow: hidden;
+}
+.ios-group .role, .ios-settings li {
+ width: 100%; min-height: 48px; padding: 12px 16px;
+ border: 0; border-bottom: 0.5px solid var(--ios-line);
+ background: transparent; color: inherit; font: inherit; text-align: left;
+ display: flex; justify-content: space-between; gap: 12px; align-items: center;
+}
+.ios-group li:last-child .role, .ios-settings li:last-child { border-bottom: 0; }
+.ios-group .role { display: grid; justify-items: start; }
+.ios-group .role strong { font-size: 17px; }
+.ios-group .role span { color: var(--ios-muted); font-size: 13px; }
+.ios-card {
+ margin-top: 12px; padding: 16px; background: var(--ios-group); border-radius: 12px;
+ display: grid; gap: 8px;
+}
+.ios-stack { display: grid; gap: 10px; margin: 16px 0; }
+label { font-size: 13px; color: var(--ios-muted); }
+input, textarea {
+ width: 100%; padding: 12px 14px; border-radius: 12px; border: 0;
+ background: var(--ios-group); color: inherit; font: inherit;
+}
+input { letter-spacing: 0.3em; font-size: 22px; text-align: center; }
+.ios-btn {
+ min-height: 48px; display: flex; align-items: center; justify-content: center;
+ border: 0; border-radius: 12px; background: var(--ios-accent); color: #fff;
+ font: inherit; font-weight: 600; text-decoration: none;
+}
+.ios-btn--plain { background: var(--ios-group); color: var(--ios-accent); }
+.ios-tabbar {
+ position: fixed; left: 0; right: 0; bottom: 0;
+ display: grid; grid-template-columns: repeat(4, 1fr);
+ min-height: 49px; padding-bottom: env(safe-area-inset-bottom);
+ background: rgb(28 28 30 / 92%); border-top: 0.5px solid var(--ios-line);
+ backdrop-filter: blur(16px);
+}
+.tab {
+ min-height: 44px; border: 0; background: transparent; color: var(--ios-muted);
+ font-size: 10px; font-weight: 600;
+}
+.tab.is-active { color: var(--ios-accent); }
+.error { color: #ff453a; }
+.chip { color: var(--ios-muted); font-size: 12px; }
+.meta { display: flex; gap: 8px; flex-wrap: wrap; }
+.detail pre, #command-summary { white-space: pre-wrap; color: var(--ios-muted); font: 13px/1.45 ui-monospace, Menlo, monospace; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible {
+ outline: 2px solid var(--ios-accent); outline-offset: 2px;
+}
diff --git a/deploy/mobile/ios/app.mjs b/deploy/mobile/ios/app.mjs
new file mode 100644
index 00000000..04023123
--- /dev/null
+++ b/deploy/mobile/ios/app.mjs
@@ -0,0 +1 @@
+import "/shared/shell.mjs";
diff --git a/deploy/mobile/ios/index.html b/deploy/mobile/ios/index.html
new file mode 100644
index 00000000..743b6e5b
--- /dev/null
+++ b/deploy/mobile/ios/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ 组织 · RoleWeave
+
+
+
+
+
+
+
+
+
+
+ - 平台iOS
+ - 组织数据尚未同步
+ - 这台手机未连接
+ - 回合电脑宿主执行
+
+
+ 打开桌面版
+
+
+
+
+
+
diff --git a/deploy/mobile/shared/shell.mjs b/deploy/mobile/shared/shell.mjs
new file mode 100644
index 00000000..4b9b6b0c
--- /dev/null
+++ b/deploy/mobile/shared/shell.mjs
@@ -0,0 +1,284 @@
+const workspaceName = document.getElementById("workspace-name");
+const orgLead = document.getElementById("org-lead");
+const roleList = document.getElementById("role-list");
+const roleDetail = document.getElementById("role-detail");
+const pairForm = document.getElementById("pair-form");
+const claimForm = document.getElementById("claim-form");
+const hostStatus = document.getElementById("host-status");
+const commandForm = document.getElementById("command-form");
+const commandStatus = document.getElementById("command-status");
+const commandSummary = document.getElementById("command-summary");
+const storageKey = "roleweave-phone-link-v1";
+
+let socket = null;
+let deviceToken = localStorage.getItem(storageKey);
+let selectedRoleId = null;
+
+function setCommandStatus(text) {
+ commandStatus.textContent = text;
+}
+
+function showSummary(text) {
+ if (!text) {
+ commandSummary.hidden = true;
+ commandSummary.textContent = "";
+ return;
+ }
+ commandSummary.hidden = false;
+ commandSummary.textContent = text;
+}
+
+function formatBudget(role) {
+ const tokens = role.budget?.perTask?.tokens;
+ return typeof tokens === "number" ? `每任务 ${tokens.toLocaleString("zh-CN")} tokens` : "预算未声明";
+}
+
+function renderDetail(role, names) {
+ const reports = role.reportTo ? `汇报给 ${names.get(role.reportTo) ?? role.reportTo}` : "组织负责人";
+ roleDetail.hidden = false;
+ roleDetail.innerHTML = `
+ ${escapeHtml(role.name)}
+ ${escapeHtml(role.description)}
+
+ ${escapeHtml(reports)}
+ ${escapeHtml(formatBudget(role))}
+
+ ${escapeHtml(role.skillExcerpt || "这份岗位还没有说明书摘录。")}
+
+ `;
+ document.getElementById("talk")?.addEventListener("click", () => selectTab("command"));
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+function describeSource(snapshot) {
+ if (snapshot.source === "live") return "电脑当前工作区";
+ if (snapshot.source === "closed") return "电脑未打开工作区";
+ return "示例预览,电脑打开工作区后会换成当前组织";
+}
+
+function setDeviceState(connected) {
+ const state = document.getElementById("device-state");
+ const revoke = document.getElementById("revoke");
+ if (state) state.textContent = connected ? "已连接" : "未连接";
+ if (revoke) revoke.hidden = !connected;
+}
+
+function renderRoles(snapshot) {
+ const sourceEl = document.getElementById("org-source");
+ if (sourceEl) sourceEl.textContent = describeSource(snapshot);
+ if (snapshot.source === "closed") {
+ workspaceName.textContent = "未打开工作区";
+ orgLead.textContent = "在电脑上打开一个工作区后,这里会列出当前员工。";
+ orgLead.classList.remove("error");
+ roleList.replaceChildren();
+ roleDetail.hidden = true;
+ return;
+ }
+ workspaceName.textContent = snapshot.name;
+ orgLead.textContent = snapshot.source === "live"
+ ? (snapshot.description || "这是电脑上正在打开的工作区。")
+ : (snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。");
+ const names = new Map(snapshot.roles.map((role) => [role.id, role.name]));
+ roleList.replaceChildren();
+ for (const role of snapshot.roles) {
+ const item = document.createElement("li");
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "role";
+ button.innerHTML = `${escapeHtml(role.name)}${escapeHtml(role.description)}`;
+ button.addEventListener("click", () => {
+ selectedRoleId = role.id;
+ renderDetail(role, names);
+ roleDetail.scrollIntoView({ block: "nearest" });
+ });
+ item.append(button);
+ roleList.append(item);
+ }
+ const initial = snapshot.roles.find((role) => role.id === selectedRoleId) ?? snapshot.roles[0];
+ if (initial) renderDetail(initial, names);
+}
+
+async function loadWorkspace() {
+ try {
+ const response = await fetch("/api/mobile/workspace", { headers: { accept: "application/json" } });
+ if (!response.ok) throw new Error("workspace unavailable");
+ renderRoles(await response.json());
+ } catch {
+ orgLead.textContent = "组织预览暂时读不到。桌面工作台仍可用。";
+ orgLead.classList.add("error");
+ }
+}
+
+function selectTab(id) {
+ for (const panel of document.querySelectorAll(".panel")) {
+ const active = panel.dataset.panel === id;
+ panel.classList.toggle("is-active", active);
+ panel.hidden = !active;
+ }
+ for (const tab of document.querySelectorAll(".tab")) {
+ const active = tab.dataset.tab === id;
+ tab.classList.toggle("is-active", active);
+ if (active) tab.setAttribute("aria-current", "page");
+ else tab.removeAttribute("aria-current");
+ }
+}
+
+function connect() {
+ if (!deviceToken) return;
+ socket?.close();
+ const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/phone-link/phone`;
+ socket = new WebSocket(url);
+ socket.addEventListener("open", () => {
+ socket.send(JSON.stringify({ v: 1, type: "phone.hello", deviceToken }));
+ });
+ socket.addEventListener("message", (event) => {
+ let message;
+ try {
+ message = JSON.parse(String(event.data));
+ } catch {
+ return;
+ }
+ if (message.type === "phone.accepted") {
+ pairForm.hidden = true;
+ claimForm.hidden = true;
+ commandForm.hidden = false;
+ setDeviceState(true);
+ setCommandStatus("已连上电脑,可以发指令。");
+ return;
+ }
+ if (message.type === "command.status") {
+ const labels = {
+ accepted: "电脑已接到",
+ running: "员工正在处理",
+ completed: "完成",
+ failed: "失败",
+ busy: "该员工正在忙",
+ needs_approval: "请在电脑上确认",
+ };
+ setCommandStatus(labels[message.state] ?? message.state);
+ if (message.summary) showSummary(message.summary);
+ return;
+ }
+ if (message.type === "error") {
+ if (message.code === "unauthorized" || message.code === "revoked") {
+ localStorage.removeItem(storageKey);
+ deviceToken = null;
+ pairForm.hidden = false;
+ claimForm.hidden = false;
+ commandForm.hidden = true;
+ setDeviceState(false);
+ }
+ setCommandStatus(message.message ?? "出错了");
+ }
+ });
+ socket.addEventListener("close", () => {
+ if (deviceToken) setCommandStatus("连接断开,正在重试…");
+ });
+}
+
+async function acceptGrant(body) {
+ if (!body?.deviceToken) return false;
+ deviceToken = body.deviceToken;
+ localStorage.setItem(storageKey, deviceToken);
+ connect();
+ return true;
+}
+
+async function refreshHostStatus() {
+ try {
+ const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } });
+ const body = await response.json();
+ if (body.hostOnline) {
+ hostStatus.textContent = "电脑在线,可以连接。";
+ hostStatus.classList.remove("error");
+ } else {
+ hostStatus.textContent = "电脑还没连上。先打开 RoleWeave 桌面。";
+ hostStatus.classList.add("error");
+ }
+ } catch {
+ hostStatus.textContent = "暂时读不到电脑状态。";
+ hostStatus.classList.add("error");
+ }
+}
+
+claimForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ setCommandStatus("正在连接电脑…");
+ const response = await fetch("/phone-link/v1/claim", { method: "POST" });
+ const body = await response.json();
+ if (!response.ok || !(await acceptGrant(body))) {
+ setCommandStatus(body.message ?? "连接失败");
+ }
+});
+
+pairForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const code = document.getElementById("code").value.trim();
+ setCommandStatus("正在配对…");
+ const response = await fetch("/phone-link/v1/pair", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ code }),
+ });
+ const body = await response.json();
+ if (!response.ok || !(await acceptGrant(body))) {
+ setCommandStatus(body.message ?? "配对失败");
+ }
+});
+
+commandForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const text = document.getElementById("text").value.trim();
+ if (!text || !socket || socket.readyState !== WebSocket.OPEN) {
+ setCommandStatus("还没连上电脑");
+ return;
+ }
+ showSummary("");
+ const commandId = crypto.randomUUID();
+ socket.send(JSON.stringify({
+ v: 1,
+ type: "command.submit",
+ commandId,
+ text,
+ ...(selectedRoleId ? { positionId: selectedRoleId } : {}),
+ }));
+ setCommandStatus("已发出,等电脑受理…");
+});
+
+document.querySelector("nav[aria-label='主要功能']")?.addEventListener("click", (event) => {
+ const tab = event.target.closest("[data-tab]");
+ if (tab) selectTab(tab.dataset.tab);
+});
+
+document.getElementById("revoke")?.addEventListener("click", async () => {
+ if (!deviceToken) return;
+ const response = await fetch("/phone-link/v1/revoke", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ deviceToken }),
+ });
+ localStorage.removeItem(storageKey);
+ deviceToken = null;
+ socket?.close();
+ pairForm.hidden = false;
+ claimForm.hidden = false;
+ commandForm.hidden = true;
+ setDeviceState(false);
+ setCommandStatus(response.ok ? "已断开这台手机。" : "已在本机退出,电脑侧可能仍需确认。");
+});
+
+if (deviceToken) connect();
+else setDeviceState(false);
+void loadWorkspace();
+void refreshHostStatus();
+setInterval(() => {
+ void refreshHostStatus();
+ void loadWorkspace();
+}, 4000);
diff --git a/deploy/web-server.mjs b/deploy/web-server.mjs
new file mode 100644
index 00000000..0fe64166
--- /dev/null
+++ b/deploy/web-server.mjs
@@ -0,0 +1,249 @@
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { createServer } from "node:http";
+import net from "node:net";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { WebSocket, WebSocketServer } from "ws";
+import { createPhoneLinkRelay } from "../packages/phone-link/src/index.mjs";
+import {
+ defaultExampleDir,
+ loadWorkspaceSnapshot,
+ previewOrgSnapshot,
+ resolvePublicAsset,
+} from "./mobile-surface.mjs";
+
+const port = Number.parseInt(process.argv[2] ?? "", 10);
+const vncPort = Number.parseInt(process.argv[3] ?? "", 10);
+if (!Number.isInteger(port) || port < 1 || port > 65535 ||
+ !Number.isInteger(vncPort) || vncPort < 1 || vncPort > 65535) {
+ process.stderr.write("usage: node web-server.mjs \n");
+ process.exit(64);
+}
+
+const deployDir = path.dirname(fileURLToPath(import.meta.url));
+const productDir = path.resolve(deployDir, "..");
+const exampleDir = defaultExampleDir(productDir);
+const noVncDir = path.join(deployDir, "node_modules", "@novnc", "novnc");
+const contentTypes = new Map([
+ [".css", "text/css; charset=utf-8"],
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".mjs", "text/javascript; charset=utf-8"],
+ [".png", "image/png"],
+ [".svg", "image/svg+xml"],
+]);
+
+function resolvedAsset(urlPath, request) {
+ const url = new URL(request.url ?? "/", "http://localhost");
+ return resolvePublicAsset(urlPath, {
+ deployDir,
+ noVncDir,
+ userAgent: request.headers["user-agent"] ?? "",
+ searchParams: url.searchParams,
+ });
+}
+
+const hostToken = process.env.ROLEWEAVE_PHONE_LINK_HOST_TOKEN ?? "";
+const phoneLink = /^[a-f0-9]{64}$/.test(hostToken) ? createPhoneLinkRelay({ hostToken }) : null;
+
+function json(response, status, body) {
+ const payload = JSON.stringify(body);
+ response.writeHead(status, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ "content-length": Buffer.byteLength(payload),
+ });
+ response.end(payload);
+}
+
+async function readJson(request) {
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ size += buf.length;
+ if (size > 16 * 1024) throw new Error("too large");
+ chunks.push(buf);
+ }
+ if (chunks.length === 0) return {};
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+}
+
+function parseSocketJson(raw) {
+ const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
+ return JSON.parse(text);
+}
+
+const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? "/", "http://localhost");
+ if (url.pathname === "/healthz") {
+ response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
+ response.end('{"ok":true,"app":"RoleWeave"}\n');
+ return;
+ }
+ if (url.pathname === "/phone-link/v1/protocol" && request.method === "GET") {
+ json(response, 200, phoneLink ? phoneLink.protocol() : { schema: "phone-link.v1", enabled: false });
+ return;
+ }
+ if (url.pathname === "/phone-link/v1/status" && request.method === "GET") {
+ json(response, 200, phoneLink ? phoneLink.status() : { schema: "phone-link.v1", hostOnline: false, deviceCount: 0 });
+ return;
+ }
+ if (url.pathname === "/phone-link/v1/claim" && request.method === "POST") {
+ if (!phoneLink) {
+ json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" });
+ return;
+ }
+ const result = phoneLink.claim();
+ json(response, result.ok ? 200 : result.code === "host_offline" ? 503 : 400, result.ok
+ ? { deviceId: result.deviceId, deviceToken: result.deviceToken }
+ : { code: result.code, message: result.message });
+ return;
+ }
+ if (url.pathname === "/phone-link/v1/revoke" && request.method === "POST") {
+ if (!phoneLink) {
+ json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" });
+ return;
+ }
+ try {
+ const body = await readJson(request);
+ const result = phoneLink.revokeByToken(body.deviceToken);
+ json(response, result.ok ? 200 : result.code === "unauthorized" ? 401 : 400, result.ok
+ ? { revoked: true, deviceId: result.deviceId }
+ : { code: result.code, message: result.message });
+ } catch {
+ json(response, 400, { code: "bad_request", message: "revoke body must be JSON" });
+ }
+ return;
+ }
+ if (url.pathname === "/phone-link/v1/pair" && request.method === "POST") {
+ if (!phoneLink) {
+ json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" });
+ return;
+ }
+ try {
+ const body = await readJson(request);
+ const result = phoneLink.pair(body.code);
+ json(response, result.ok ? 200 : 400, result.ok ? { deviceId: result.deviceId, deviceToken: result.deviceToken } : { code: result.code, message: result.message });
+ } catch {
+ json(response, 400, { code: "bad_request", message: "pairing body must be JSON" });
+ }
+ return;
+ }
+ if (url.pathname === "/api/mobile/workspace" && request.method === "GET") {
+ const live = phoneLink?.orgSnapshot();
+ if (live) {
+ json(response, 200, live);
+ return;
+ }
+ try {
+ json(response, 200, previewOrgSnapshot(loadWorkspaceSnapshot(exampleDir)));
+ } catch {
+ json(response, 503, { code: "unavailable", message: "mobile workspace preview is not available" });
+ }
+ return;
+ }
+ const asset = resolvedAsset(url.pathname, request);
+ if (asset === null) {
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
+ response.end("Not found\n");
+ return;
+ }
+ try {
+ const details = await stat(asset);
+ if (!details.isFile()) throw new Error("not a file");
+ response.writeHead(200, {
+ "content-type": contentTypes.get(path.extname(asset)) ?? "application/octet-stream",
+ "content-length": details.size,
+ "cache-control": url.pathname.startsWith("/novnc/") ? "public, max-age=86400" : "no-store",
+ "content-security-policy": "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'",
+ "x-content-type-options": "nosniff",
+ "referrer-policy": "no-referrer",
+ });
+ createReadStream(asset).pipe(response);
+ } catch {
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
+ response.end("Not found\n");
+ }
+});
+
+const websocketServer = new WebSocketServer({ noServer: true, perMessageDeflate: false });
+server.on("upgrade", (request, socket, head) => {
+ const url = new URL(request.url ?? "/", "http://localhost");
+ const origin = request.headers.origin;
+ let sameOrigin = true;
+ try {
+ sameOrigin = !origin || new URL(origin).host === request.headers.host;
+ } catch {
+ sameOrigin = false;
+ }
+ if (url.pathname === "/phone-link/host" || url.pathname === "/phone-link/phone") {
+ if (!phoneLink || !sameOrigin) {
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ websocketServer.handleUpgrade(request, socket, head, (websocket) => {
+ websocketServer.emit("phone-link", websocket, url.pathname);
+ });
+ return;
+ }
+ if (url.pathname !== "/websockify" || !sameOrigin) {
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
+ socket.destroy();
+ return;
+ }
+ websocketServer.handleUpgrade(request, socket, head, (websocket) => {
+ websocketServer.emit("connection", websocket);
+ });
+});
+
+websocketServer.on("phone-link", (websocket, pathname) => {
+ if (!phoneLink) {
+ websocket.close(1011, "phone-link disabled");
+ return;
+ }
+ if (pathname === "/phone-link/host") {
+ websocket.on("message", (data) => {
+ try {
+ phoneLink.handleHostMessage(websocket, parseSocketJson(data));
+ } catch {
+ websocket.send(JSON.stringify({ v: 1, type: "error", code: "bad_request", message: "invalid JSON" }));
+ }
+ });
+ websocket.on("close", () => phoneLink.disconnectHost(websocket));
+ return;
+ }
+ let deviceToken = null;
+ websocket.on("message", (data) => {
+ try {
+ const message = parseSocketJson(data);
+ if (message.type === "phone.hello") {
+ deviceToken = typeof message.deviceToken === "string" ? message.deviceToken : "";
+ phoneLink.connectPhone(websocket, deviceToken);
+ return;
+ }
+ if (deviceToken) phoneLink.handlePhoneMessage(websocket, deviceToken, message);
+ } catch {
+ websocket.send(JSON.stringify({ v: 1, type: "error", code: "bad_request", message: "invalid JSON" }));
+ }
+ });
+ websocket.on("close", () => phoneLink.disconnectPhone(websocket));
+});
+
+websocketServer.on("connection", (websocket) => {
+ const upstream = net.createConnection({ host: "127.0.0.1", port: vncPort });
+ upstream.on("data", (chunk) => {
+ if (websocket.readyState === WebSocket.OPEN) websocket.send(chunk, { binary: true });
+ });
+ upstream.on("error", () => websocket.close(1011, "VNC server unavailable"));
+ upstream.on("close", () => websocket.close());
+ websocket.on("message", (data) => upstream.write(data));
+ websocket.on("close", () => upstream.destroy());
+ websocket.on("error", () => upstream.destroy());
+});
+
+server.listen(port, "0.0.0.0", () => {
+ process.stdout.write(`RoleWeave web desktop listening on 0.0.0.0:${port}\n`);
+});
diff --git a/docs/design/mobile-shell.md b/docs/design/mobile-shell.md
new file mode 100644
index 00000000..8effd4f6
--- /dev/null
+++ b/docs/design/mobile-shell.md
@@ -0,0 +1,43 @@
+# RoleWeave 手机壳
+
+日期:2026-09-17
+状态:已在托管入口落地第一刀
+
+## 问题
+
+ASteam 上的 RoleWeave 原先把 1240×800 的 Electron 桌面经 VNC 投到浏览器。手机打开后是一块缩过的桌面:点不准、键进不去、组织树和对话都不是给拇指用的。
+
+## 决定
+
+手机走**原生网页壳**,不再默认投桌面。
+
+| 入口 | 谁看到 | 内容 |
+| --- | --- | --- |
+| `/`(手机 UA 或 `ASteamApp`) | 手机 / App | 组织、指令、桌面入口、设置 |
+| `/`(桌面 UA) | 宽屏浏览器 | 现有 noVNC 桌面 |
+| `/mobile`、`/?surface=mobile` | 任何人 | 强制手机壳 |
+| `/desktop`、`/?surface=desktop` | 任何人 | 强制远程桌面 |
+| `/command` | 已配对手机 | 单页发指令(旧入口保留) |
+
+## 信息架构
+
+底栏四个入口,不超过五:
+
+1. **组织**:电脑打开工作区后显示当前组织;未打开时提示,宿主掉线则回落到示例预览。
+2. **指令**:电脑在线时一键 `claim` 连上;也可以输入电脑弹窗里的 6 位码。一句话交给当前选中岗位(或组织树第一个员工)。电脑必须在线,不在手机上跑员工。
+3. **桌面**:需要完整工作台时再打开 VNC。
+4. **设置**:说明数据来源,并可「断开这台手机」。
+
+## 约束
+
+- 触控目标 ≥ 44px,底栏计入安全区。
+- 颜色沿用桌面:背景 `#12141b`,强调 `#7aa2ff`,不用另一套 AI 紫。
+- `/api/mobile/workspace` 只返回岗位名、描述、预算和说明书摘录,不回传主机路径。
+- 控制面 API 仍绑 `127.0.0.1`;手机不直连 boot token。
+- 危险操作仍在电脑上批准;手机最多看到 `needs_approval`。
+
+## 非目标(本刀不做)
+
+- 在手机上招聘、改组织树、改预算。
+- 把控制面端口暴露到公网。
+- 重做 Electron 四区桌面布局。
diff --git a/docs/issues/001-mobile-shell.md b/docs/issues/001-mobile-shell.md
new file mode 100644
index 00000000..fd0903c5
--- /dev/null
+++ b/docs/issues/001-mobile-shell.md
@@ -0,0 +1,22 @@
+# 手机打开 RoleWeave 应是原生壳,而不是缩过的桌面
+
+- Type: story
+- Priority: P1
+- Surface: ASteam 托管产品 / 手机 App
+
+## What to build
+
+手机或 ASteam App 打开产品根路径时,看到组织、发指令、桌面入口和设置,而不是 1240×800 的 VNC 桌面。桌面浏览器行为保持不变。
+
+## Acceptance criteria
+
+- [ ] iPhone / Android / `ASteamApp` 访问 `/` 得到手机壳(`data-surface="mobile"`)
+- [ ] Macintosh 等桌面 UA 访问 `/` 仍是远程桌面
+- [ ] `/?surface=mobile` 与 `/?surface=desktop` 可强制切换
+- [ ] 组织页列出 oss-maintainer 四个岗位,响应不含主机路径
+- [ ] 底栏四个入口,触控高度 ≥ 44px,尊重安全区
+- [ ] `/healthz` 不变
+
+## Blocked by
+
+None - can start immediately
diff --git a/package-lock.json b/package-lock.json
index 20a6ec80..89e92a44 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2386,6 +2386,10 @@
"resolved": "apps/desktop",
"link": true
},
+ "node_modules/@roleweave/phone-link": {
+ "resolved": "packages/phone-link",
+ "link": true
+ },
"node_modules/@roleweave/server": {
"resolved": "apps/server",
"link": true
@@ -9195,6 +9199,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "packages/phone-link": {
+ "name": "@roleweave/phone-link",
+ "version": "1.0.0",
+ "license": "Apache-2.0"
+ },
"packages/shared": {
"name": "@roleweave/shared",
"version": "0.0.0",
diff --git a/package.json b/package.json
index df7db1b9..073b1a49 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"type": "git",
"url": "git+https://github.com/bytefolk/roleweave.git"
},
- "description": "RoleWeave — file tree is the org chart. Electron shell + local control plane for digital-employee workspaces.",
+ "description": "RoleWeave \u2014 file tree is the org chart. Electron shell + local control plane for digital-employee workspaces.",
"author": "RoleWeave Contributors",
"license": "Apache-2.0",
"type": "module",
@@ -61,7 +61,8 @@
"check": "npm run build && npm run prepare:updater && npm run test:scripts && npm run typecheck:ui && npm run test:ui && node --test --test-timeout=120000 \"apps/server/dist/test/*.test.js\" && npm run typecheck:renderer && npm run test:renderer && npm run test:desktop-main && npm run security:check",
"dev:server": "npm run build && node apps/server/dist/src/index.js",
"dev:desktop": "npm run build && npm run build:renderer && npm run prepare:updater && electron apps/desktop/src/main.js",
- "typecheck": "tsc -b --force"
+ "typecheck": "tsc -b --force",
+ "test:phone-link": "node --test --test-timeout=30000 \"packages/phone-link/test/*.test.mjs\" \"apps/desktop/test/phone-link-host.test.cjs\""
},
"devDependencies": {
"@types/node": "^22.10.0",
diff --git a/packages/phone-link/README.md b/packages/phone-link/README.md
new file mode 100644
index 00000000..0606a8f2
--- /dev/null
+++ b/packages/phone-link/README.md
@@ -0,0 +1,48 @@
+# phone-link.v1
+
+手机发一条指令,已经打开的 RoleWeave(或任何实现本协议的宿主)立刻处理。
+
+电脑不对外开端口:宿主和手机都**主动连**中继。中继只转发,不跑员工、不长期存正文。
+
+## 以后的软件怎么用
+
+1. 电脑进程连 `ws(s):///phone-link/host`,首条消息:
+
+ ```json
+ { "v": 1, "type": "host.hello", "token": "<64-hex host token>" }
+ ```
+
+2. 发 `{ "v": 1, "type": "pair.start" }`,把返回的 6 位 `code` 显示给用户。
+
+3. 手机打开产品(手机壳「指令」或 `/command`):
+
+ - 同一产品地址:`POST /phone-link/v1/claim` → `{ "deviceId", "deviceToken" }`(电脑必须已连中继)
+ - 或 `POST /phone-link/v1/pair` `{ "code": "482910" }`(电脑弹窗里的 6 位码)
+
+ 发现:`GET /phone-link/v1/status` → `{ "schema", "hostOnline", "deviceCount" }`
+
+ 然后连 `ws(s):///phone-link/phone`,首条:
+
+ ```json
+ { "v": 1, "type": "phone.hello", "deviceToken": "" }
+ ```
+
+4. 提交指令:
+
+ ```json
+ { "v": 1, "type": "command.submit", "commandId": "", "text": "…", "positionId": "optional-role-id" }
+ ```
+
+5. 状态回推 `command.status`:`accepted` → `running` → `completed` | `failed` | `busy` | `needs_approval`。`completed` 可带 `summary`。
+
+发现:`GET /phone-link/v1/protocol` → `{ "schema": "phone-link.v1" }`。
+
+## 不变式
+
+- 电脑必须已经在跑;宿主掉线则指令失败,不排队。
+- 一个员工同一时刻只接一条(忙则 `busy`)。
+- 危险操作仍在电脑上批准;手机只看到 `needs_approval`。
+- 电脑打开工作区后,宿主推送 `org.snapshot`;手机 `GET /api/mobile/workspace` 读这份快照,不含主机路径。
+- 手机可 `POST /phone-link/v1/revoke` `{ "deviceToken" }` 撤销自己。
+- 配对码 5 分钟作废;设备许可可 `pair.revoke`。
+- 指令正文 ≤ 8 KiB UTF-8。
diff --git a/packages/phone-link/package.json b/packages/phone-link/package.json
new file mode 100644
index 00000000..d1a84769
--- /dev/null
+++ b/packages/phone-link/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@roleweave/phone-link",
+ "version": "1.0.0",
+ "description": "phone-link.v1 — phone commands a running RoleWeave host through an outbound relay. Other software can speak the same protocol.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.mjs"
+ },
+ "files": [
+ "src",
+ "README.md"
+ ]
+}
diff --git a/packages/phone-link/src/index.mjs b/packages/phone-link/src/index.mjs
new file mode 100644
index 00000000..a9a4a125
--- /dev/null
+++ b/packages/phone-link/src/index.mjs
@@ -0,0 +1,6 @@
+export {
+ SCHEMA,
+ PAIR_TTL_MS,
+ MAX_COMMAND_BYTES,
+ createPhoneLinkRelay,
+} from "./relay.mjs";
diff --git a/packages/phone-link/src/relay.mjs b/packages/phone-link/src/relay.mjs
new file mode 100644
index 00000000..db7855d7
--- /dev/null
+++ b/packages/phone-link/src/relay.mjs
@@ -0,0 +1,275 @@
+import { randomBytes, randomInt } from "node:crypto";
+
+export const SCHEMA = "phone-link.v1";
+export const PAIR_TTL_MS = 5 * 60 * 1000;
+export const MAX_COMMAND_BYTES = 8 * 1024;
+const MAX_DEVICES = 8;
+
+function isRecord(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function newId() {
+ return randomBytes(16).toString("hex");
+}
+
+function sixDigitCode() {
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
+}
+
+function send(socket, payload) {
+ if (socket && typeof socket.send === "function" && socket.readyState !== 3) {
+ socket.send(JSON.stringify(payload));
+ }
+}
+
+export function createPhoneLinkRelay(options = {}) {
+ const hostToken = options.hostToken;
+ if (typeof hostToken !== "string" || !/^[a-f0-9]{64}$/.test(hostToken)) {
+ throw new Error("hostToken must be 64 lowercase hex characters");
+ }
+ const now = options.now ?? (() => Date.now());
+
+ let hostSocket = null;
+ const devices = new Map();
+ const phones = new Map();
+ let pairing = null;
+ let orgSnapshot = null;
+
+ function hostOnline() {
+ return hostSocket !== null;
+ }
+
+ function expirePairing() {
+ if (pairing && pairing.expiresAt <= now()) pairing = null;
+ }
+
+ const relay = {
+ schema: SCHEMA,
+
+ protocol() {
+ return { schema: SCHEMA };
+ },
+
+ status() {
+ expirePairing();
+ return {
+ schema: SCHEMA,
+ hostOnline: hostOnline(),
+ deviceCount: devices.size,
+ };
+ },
+
+ issueDevice() {
+ if (!hostOnline()) {
+ return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" };
+ }
+ if (devices.size >= MAX_DEVICES) {
+ return { ok: false, code: "device_limit", message: "too many paired phones" };
+ }
+ const deviceId = newId();
+ const deviceToken = randomBytes(32).toString("hex");
+ devices.set(deviceToken, { deviceId, createdAt: now() });
+ send(hostSocket, { v: 1, type: "pair.completed", deviceId });
+ return { ok: true, deviceId, deviceToken };
+ },
+
+ claim() {
+ return this.issueDevice();
+ },
+
+ connectHost(socket, token) {
+ if (token !== hostToken) {
+ send(socket, { v: 1, type: "error", code: "unauthorized", message: "invalid host token" });
+ socket.close?.();
+ return false;
+ }
+ if (hostSocket && hostSocket !== socket) hostSocket.close?.();
+ hostSocket = socket;
+ send(socket, { v: 1, type: "host.accepted", schema: SCHEMA });
+ return true;
+ },
+
+ disconnectHost(socket) {
+ if (hostSocket === socket) {
+ hostSocket = null;
+ orgSnapshot = null;
+ }
+ },
+
+ publishOrg(snapshot) {
+ if (!isRecord(snapshot) || !Array.isArray(snapshot.roles)) {
+ return { ok: false, code: "snapshot_invalid", message: "org snapshot must include roles" };
+ }
+ orgSnapshot = snapshot;
+ return { ok: true };
+ },
+
+ orgSnapshot() {
+ return orgSnapshot;
+ },
+
+ revokeByToken(deviceToken) {
+ const device = devices.get(deviceToken);
+ if (!device) return { ok: false, code: "unauthorized", message: "invalid device token" };
+ return this.revoke(device.deviceId);
+ },
+
+ startPair() {
+ if (!hostOnline()) {
+ return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" };
+ }
+ const code = sixDigitCode();
+ pairing = { code, expiresAt: now() + PAIR_TTL_MS };
+ const result = { ok: true, code, expiresAt: pairing.expiresAt };
+ send(hostSocket, { v: 1, type: "pair.ready", code, expiresAt: pairing.expiresAt });
+ return result;
+ },
+
+ pair(code) {
+ expirePairing();
+ if (!hostOnline()) {
+ return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" };
+ }
+ if (typeof code !== "string" || !/^[0-9]{6}$/.test(code) || !pairing || pairing.code !== code) {
+ return { ok: false, code: "pair_invalid", message: "pairing code is invalid or expired" };
+ }
+ if (devices.size >= MAX_DEVICES) {
+ return { ok: false, code: "device_limit", message: "too many paired phones" };
+ }
+ pairing = null;
+ return this.issueDevice();
+ },
+
+ revoke(deviceId) {
+ for (const [token, device] of devices) {
+ if (device.deviceId === deviceId) {
+ devices.delete(token);
+ const phone = phones.get(token);
+ if (phone) {
+ send(phone, { v: 1, type: "error", code: "revoked", message: "device grant revoked" });
+ phone.close?.();
+ phones.delete(token);
+ }
+ return { ok: true, deviceId };
+ }
+ }
+ return { ok: false, code: "not_found", message: "device not paired" };
+ },
+
+ connectPhone(socket, deviceToken) {
+ const device = devices.get(deviceToken);
+ if (!device) {
+ send(socket, { v: 1, type: "error", code: "unauthorized", message: "invalid device token" });
+ socket.close?.();
+ return false;
+ }
+ const previous = phones.get(deviceToken);
+ if (previous && previous !== socket) previous.close?.();
+ phones.set(deviceToken, socket);
+ send(socket, { v: 1, type: "phone.accepted", deviceId: device.deviceId, schema: SCHEMA });
+ return true;
+ },
+
+ disconnectPhone(socket) {
+ for (const [token, current] of phones) {
+ if (current === socket) phones.delete(token);
+ }
+ },
+
+ submitCommand(deviceToken, commandId, text, positionId) {
+ const device = devices.get(deviceToken);
+ if (!device) return { ok: false, code: "unauthorized", message: "invalid device token" };
+ if (!hostOnline()) return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" };
+ if (typeof commandId !== "string" || commandId.length < 8 || commandId.length > 128) {
+ return { ok: false, code: "command_invalid", message: "commandId is invalid" };
+ }
+ if (typeof text !== "string" || text.trim().length === 0) {
+ return { ok: false, code: "command_invalid", message: "text is required" };
+ }
+ if (Buffer.byteLength(text, "utf8") > MAX_COMMAND_BYTES) {
+ return { ok: false, code: "command_invalid", message: "text exceeds 8 KiB" };
+ }
+ const payload = {
+ v: 1,
+ type: "command.submit",
+ commandId,
+ text: text.trim(),
+ deviceId: device.deviceId,
+ };
+ if (typeof positionId === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(positionId)) {
+ payload.positionId = positionId;
+ }
+ send(hostSocket, payload);
+ const phone = phones.get(deviceToken);
+ send(phone, { v: 1, type: "command.status", commandId, state: "accepted" });
+ return { ok: true, commandId };
+ },
+
+ publishStatus(commandId, state, summary, deviceId) {
+ const allowed = new Set(["accepted", "running", "completed", "failed", "busy", "needs_approval"]);
+ if (!allowed.has(state)) return { ok: false, code: "status_invalid", message: "unknown command state" };
+ const message = {
+ v: 1,
+ type: "command.status",
+ commandId,
+ state,
+ ...(typeof summary === "string" && summary.length > 0 ? { summary: summary.slice(0, 2000) } : {}),
+ };
+ if (!hostOnline()) return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" };
+ for (const [token, device] of devices) {
+ if (deviceId && device.deviceId !== deviceId) continue;
+ send(phones.get(token), message);
+ }
+ return { ok: true };
+ },
+
+ handleHostMessage(socket, raw) {
+ if (!isRecord(raw) || raw.v !== 1 || typeof raw.type !== "string") {
+ send(socket, { v: 1, type: "error", code: "bad_request", message: "invalid message" });
+ return;
+ }
+ if (raw.type === "host.hello") {
+ this.connectHost(socket, raw.token);
+ return;
+ }
+ if (hostSocket !== socket) {
+ send(socket, { v: 1, type: "error", code: "unauthorized", message: "host not accepted" });
+ return;
+ }
+ if (raw.type === "pair.start") {
+ const result = this.startPair();
+ if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message });
+ return;
+ }
+ if (raw.type === "pair.revoke") {
+ const result = this.revoke(raw.deviceId);
+ send(socket, result.ok
+ ? { v: 1, type: "pair.revoked", deviceId: raw.deviceId }
+ : { v: 1, type: "error", code: result.code, message: result.message });
+ return;
+ }
+ if (raw.type === "command.status") {
+ this.publishStatus(raw.commandId, raw.state, raw.summary, raw.deviceId);
+ return;
+ }
+ if (raw.type === "org.snapshot") {
+ const result = this.publishOrg(raw.snapshot);
+ if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message });
+ }
+ },
+
+ handlePhoneMessage(socket, deviceToken, raw) {
+ if (!isRecord(raw) || raw.v !== 1 || typeof raw.type !== "string") {
+ send(socket, { v: 1, type: "error", code: "bad_request", message: "invalid message" });
+ return;
+ }
+ if (raw.type === "command.submit") {
+ const result = this.submitCommand(deviceToken, raw.commandId, raw.text, raw.positionId);
+ if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message });
+ }
+ },
+ };
+
+ return relay;
+}
diff --git a/packages/phone-link/test/relay.test.mjs b/packages/phone-link/test/relay.test.mjs
new file mode 100644
index 00000000..fc67f293
--- /dev/null
+++ b/packages/phone-link/test/relay.test.mjs
@@ -0,0 +1,133 @@
+import assert from "node:assert/strict";
+import { randomBytes } from "node:crypto";
+import test from "node:test";
+import { SCHEMA, createPhoneLinkRelay } from "../src/index.mjs";
+
+function socket() {
+ const inbox = [];
+ return {
+ inbox,
+ readyState: 1,
+ send(text) {
+ inbox.push(JSON.parse(text));
+ },
+ close() {
+ this.readyState = 3;
+ },
+ };
+}
+
+function hostToken() {
+ return randomBytes(32).toString("hex");
+}
+
+test("protocol discovery", () => {
+ const relay = createPhoneLinkRelay({ hostToken: hostToken() });
+ assert.deepEqual(relay.protocol(), { schema: SCHEMA });
+});
+
+test("rejects a bad host token", () => {
+ const relay = createPhoneLinkRelay({ hostToken: hostToken() });
+ const host = socket();
+ assert.equal(relay.connectHost(host, "00".repeat(32)), false);
+ assert.equal(host.inbox[0].code, "unauthorized");
+});
+
+test("host hello uses the constructor token", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ const phone = socket();
+ assert.equal(relay.connectHost(host, token), true);
+ assert.equal(host.inbox[0].type, "host.accepted");
+ const started = relay.startPair();
+ assert.equal(started.ok, true);
+ assert.match(started.code, /^[0-9]{6}$/);
+ const paired = relay.pair(started.code);
+ assert.equal(paired.ok, true);
+ assert.equal(relay.connectPhone(phone, paired.deviceToken), true);
+ const submitted = relay.submitCommand(paired.deviceToken, "cmd-00000001", "总结今天的 PR");
+ assert.equal(submitted.ok, true);
+ assert.equal(host.inbox.some((m) => m.type === "command.submit" && m.text === "总结今天的 PR"), true);
+ assert.equal(phone.inbox.some((m) => m.type === "command.status" && m.state === "accepted"), true);
+ relay.publishStatus("cmd-00000001", "completed", "已处理 2 条 PR", paired.deviceId);
+ assert.equal(phone.inbox.some((m) => m.state === "completed" && m.summary === "已处理 2 条 PR"), true);
+});
+
+test("expired or wrong pairing code fails closed", () => {
+ let t = 1_000;
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token, now: () => t });
+ const host = socket();
+ relay.connectHost(host, token);
+ const started = relay.startPair();
+ assert.equal(relay.pair("000000").ok, false);
+ t += 6 * 60 * 1000;
+ assert.equal(relay.pair(started.code).ok, false);
+});
+
+test("commands fail when the host is offline", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ relay.connectHost(host, token);
+ const code = relay.startPair().code;
+ const paired = relay.pair(code);
+ relay.disconnectHost(host);
+ const result = relay.submitCommand(paired.deviceToken, "cmd-offline1", "还在吗");
+ assert.equal(result.ok, false);
+ assert.equal(result.code, "host_offline");
+});
+
+test("claim pairs without a typed code when the host is online", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ const phone = socket();
+ assert.equal(relay.claim().ok, false);
+ relay.connectHost(host, token);
+ const claimed = relay.claim();
+ assert.equal(claimed.ok, true);
+ assert.equal(relay.status().hostOnline, true);
+ assert.equal(relay.connectPhone(phone, claimed.deviceToken), true);
+ const submitted = relay.submitCommand(claimed.deviceToken, "cmd-claim01", "合 PR", "issue-researcher");
+ assert.equal(submitted.ok, true);
+ assert.equal(host.inbox.some((m) => m.positionId === "issue-researcher"), true);
+});
+
+test("host org snapshot is served until the host disconnects", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ relay.connectHost(host, token);
+ assert.equal(relay.orgSnapshot(), null);
+ assert.equal(relay.publishOrg({ name: "live-ws", roles: [{ id: "repo-owner" }] }).ok, true);
+ assert.equal(relay.orgSnapshot().name, "live-ws");
+ relay.disconnectHost(host);
+ assert.equal(relay.orgSnapshot(), null);
+});
+
+test("phone can revoke its own grant by token", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ const phone = socket();
+ relay.connectHost(host, token);
+ const claimed = relay.claim();
+ relay.connectPhone(phone, claimed.deviceToken);
+ assert.equal(relay.revokeByToken("nope").ok, false);
+ assert.equal(relay.revokeByToken(claimed.deviceToken).ok, true);
+ assert.equal(relay.submitCommand(claimed.deviceToken, "cmd-after-revoke", "hi").ok, false);
+});
+
+test("revoke drops the phone grant", () => {
+ const token = hostToken();
+ const relay = createPhoneLinkRelay({ hostToken: token });
+ const host = socket();
+ const phone = socket();
+ relay.connectHost(host, token);
+ const paired = relay.pair(relay.startPair().code);
+ relay.connectPhone(phone, paired.deviceToken);
+ assert.equal(relay.revoke(paired.deviceId).ok, true);
+ assert.equal(relay.submitCommand(paired.deviceToken, "cmd-revoked1", "hello").ok, false);
+});
diff --git a/scripts/test/mobile-surface.test.mjs b/scripts/test/mobile-surface.test.mjs
new file mode 100644
index 00000000..fca20b04
--- /dev/null
+++ b/scripts/test/mobile-surface.test.mjs
@@ -0,0 +1,136 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import {
+ choosePlatform,
+ chooseSurface,
+ detectPlatform,
+ isMobileUserAgent,
+ liveOrgSnapshot,
+ loadWorkspaceSnapshot,
+ previewOrgSnapshot,
+ resolvePublicAsset,
+} from "../../deploy/mobile-surface.mjs";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const productDir = path.resolve(here, "../..");
+const deployDir = path.join(productDir, "deploy");
+const noVncDir = path.join(deployDir, "node_modules", "@novnc", "novnc");
+const exampleDir = path.join(productDir, "examples", "oss-maintainer");
+
+test("splits phone platforms: iOS, Android, HarmonyOS", () => {
+ assert.equal(detectPlatform("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), "ios");
+ assert.equal(detectPlatform("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"), "ios");
+ assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile"), "android");
+ assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 12; HUAWEI) AppleWebKit/537.36 Mobile"), "android");
+ assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 12; HarmonyOS) AppleWebKit/537.36 Mobile"), "harmony");
+ assert.equal(detectPlatform("Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 ArkWeb/5.0.0.0"), "harmony");
+ assert.equal(detectPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), "desktop");
+});
+
+test("platform query overrides user agent", () => {
+ assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("platform=android") }), "android");
+ assert.equal(choosePlatform({ userAgent: "Macintosh", searchParams: new URLSearchParams("platform=harmony") }), "harmony");
+ assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop");
+});
+
+test("classifies phone and ASteam app user agents as mobile", () => {
+ assert.equal(isMobileUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), true);
+ assert.equal(isMobileUserAgent("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile"), true);
+ assert.equal(isMobileUserAgent("Mozilla/5.0 ASteamApp/0.15"), true);
+ assert.equal(isMobileUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), false);
+ assert.equal(isMobileUserAgent(""), false);
+ assert.equal(isMobileUserAgent(undefined), false);
+});
+
+test("surface query overrides user agent", () => {
+ assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop");
+ assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams("surface=mobile") }), "mobile");
+ assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams() }), "mobile");
+ assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams() }), "desktop");
+});
+
+test("phone home serves the matching platform shell instead of the VNC page", () => {
+ const ios = resolvePublicAsset("/", {
+ deployDir,
+ noVncDir,
+ userAgent: "Mozilla/5.0 (iPhone)",
+ searchParams: new URLSearchParams(),
+ });
+ const android = resolvePublicAsset("/", {
+ deployDir,
+ noVncDir,
+ userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile",
+ searchParams: new URLSearchParams(),
+ });
+ const harmony = resolvePublicAsset("/", {
+ deployDir,
+ noVncDir,
+ userAgent: "Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0",
+ searchParams: new URLSearchParams(),
+ });
+ const desktop = resolvePublicAsset("/", {
+ deployDir,
+ noVncDir,
+ userAgent: "Mozilla/5.0 (Macintosh)",
+ searchParams: new URLSearchParams(),
+ });
+ assert.equal(ios, path.join(deployDir, "mobile", "ios", "index.html"));
+ assert.equal(android, path.join(deployDir, "mobile", "android", "index.html"));
+ assert.equal(harmony, path.join(deployDir, "mobile", "harmony", "index.html"));
+ assert.equal(desktop, path.join(deployDir, "index.html"));
+});
+
+test("explicit desktop and platform paths stay available", () => {
+ assert.equal(
+ resolvePublicAsset("/desktop", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }),
+ path.join(deployDir, "index.html"),
+ );
+ assert.equal(
+ resolvePublicAsset("/ios/app.css", { deployDir, noVncDir, userAgent: "Macintosh", searchParams: new URLSearchParams() }),
+ path.join(deployDir, "mobile", "ios", "app.css"),
+ );
+ assert.equal(
+ resolvePublicAsset("/android/app.mjs", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }),
+ path.join(deployDir, "mobile", "android", "app.mjs"),
+ );
+ assert.equal(
+ resolvePublicAsset("/harmony", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }),
+ path.join(deployDir, "mobile", "harmony", "index.html"),
+ );
+});
+
+test("workspace snapshot lists oss-maintainer roles without leaking host paths", () => {
+ const snapshot = loadWorkspaceSnapshot(exampleDir);
+ assert.equal(snapshot.name, "oss-maintainer");
+ assert.equal(snapshot.owner, "repo-owner");
+ const ids = snapshot.roles.map((role) => role.id);
+ assert.deepEqual(ids.sort(), ["community-operator", "issue-researcher", "release-engineer", "repo-owner"]);
+ const owner = snapshot.roles.find((role) => role.id === "repo-owner");
+ assert.equal(owner.name, "仓库负责人");
+ assert.match(owner.skillExcerpt, /路线图/);
+ assert.equal(owner.reportTo, null);
+ assert.equal(typeof owner.budget.perTask.tokens, "number");
+ assert.equal(JSON.stringify(snapshot).includes("/workspace/positions"), false);
+});
+
+test("live snapshot refuses to embed the workspace path", () => {
+ const snapshot = loadWorkspaceSnapshot(exampleDir);
+ const live = liveOrgSnapshot(snapshot, exampleDir);
+ assert.equal(live.source, "live");
+ assert.equal(JSON.stringify(live).includes(exampleDir), false);
+ assert.throws(() => liveOrgSnapshot({ name: exampleDir, roles: [] }, exampleDir), /snapshot_leaks_path/);
+ assert.equal(previewOrgSnapshot(snapshot).source, "preview");
+});
+
+test("snapshot refuses a directory that is not a workspace", () => {
+ const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "roleweave-mobile-"));
+ try {
+ assert.throws(() => loadWorkspaceSnapshot(root), /workspace/);
+ } finally {
+ fs.rmSync(root, { force: true, recursive: true });
+ }
+});
diff --git a/scripts/test/mobile-web-server.test.mjs b/scripts/test/mobile-web-server.test.mjs
new file mode 100644
index 00000000..fb206e4f
--- /dev/null
+++ b/scripts/test/mobile-web-server.test.mjs
@@ -0,0 +1,104 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import net from "node:net";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+
+function unusedPort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve(address.port);
+ });
+ });
+ server.on("error", reject);
+ });
+}
+
+async function startWebServer(t) {
+ const port = await unusedPort();
+ const vncPort = await unusedPort();
+ const child = spawn(process.execPath, [path.join(productDir, "deploy", "web-server.mjs"), String(port), String(vncPort)], {
+ cwd: productDir,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ t.after(() => {
+ child.kill("SIGTERM");
+ });
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("web server did not start")), 8000);
+ let stdout = "";
+ child.stdout.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ if (stdout.includes("listening")) {
+ clearTimeout(timeout);
+ resolve();
+ }
+ });
+ child.stderr.setEncoding("utf8");
+ child.stderr.on("data", (chunk) => {
+ if (chunk.includes("Error")) {
+ clearTimeout(timeout);
+ reject(new Error(chunk));
+ }
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timeout);
+ reject(new Error(`web server exited ${code}`));
+ });
+ });
+ return port;
+}
+
+async function request(port, pathname, headers = {}) {
+ const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { headers });
+ const text = await response.text();
+ return { status: response.status, text, type: response.headers.get("content-type") };
+}
+
+test("phone user agents receive the matching platform shell at /", async (t) => {
+ const port = await startWebServer(t);
+ const ios = await request(port, "/", { "user-agent": "Mozilla/5.0 (iPhone) Mobile" });
+ assert.equal(ios.status, 200);
+ assert.match(ios.text, /data-platform="ios"/);
+ assert.doesNotMatch(ios.text, /正在连接 RoleWeave/);
+
+ const android = await request(port, "/", { "user-agent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile" });
+ assert.equal(android.status, 200);
+ assert.match(android.text, /data-platform="android"/);
+
+ const harmony = await request(port, "/", { "user-agent": "Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0" });
+ assert.equal(harmony.status, 200);
+ assert.match(harmony.text, /data-platform="harmony"/);
+
+ const desktop = await request(port, "/", { "user-agent": "Mozilla/5.0 (Macintosh)" });
+ assert.equal(desktop.status, 200);
+ assert.match(desktop.text, /正在连接 RoleWeave/);
+});
+
+test("phone-link status is discoverable without a host token", async (t) => {
+ const port = await startWebServer(t);
+ const response = await request(port, "/phone-link/v1/status");
+ assert.equal(response.status, 200);
+ const body = JSON.parse(response.text);
+ assert.equal(body.schema, "phone-link.v1");
+ assert.equal(body.hostOnline, false);
+});
+
+test("mobile workspace API returns the example org", async (t) => {
+ const port = await startWebServer(t);
+ const response = await request(port, "/api/mobile/workspace");
+ assert.equal(response.status, 200);
+ const body = JSON.parse(response.text);
+ assert.equal(body.name, "oss-maintainer");
+ assert.equal(body.source, "preview");
+ assert.equal(body.roles.length, 4);
+ assert.equal(body.roles.some((role) => role.id === "repo-owner"), true);
+});
diff --git a/start.sh b/start.sh
new file mode 100755
index 00000000..e9ee64b0
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+: "${PORT:?ASTEAM must provide PORT}"
+case "$PORT" in
+ ''|*[!0-9]*)
+ printf 'RoleWeave: PORT must be numeric\n' >&2
+ exit 64
+ ;;
+esac
+
+product_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+runtime_dir="$product_dir/.runtime"
+native_runtime="$product_dir/vendor/runtime"
+display_number="$((100 + PORT % 1000))"
+vnc_port="$((20000 + PORT % 20000))"
+display=":$display_number"
+runtime_libs="$native_runtime/lib/x86_64-linux-gnu:$native_runtime/usr/lib/x86_64-linux-gnu"
+
+required_files=(
+ "$product_dir/node_modules/electron/dist/electron"
+ "$product_dir/deploy/node_modules/@novnc/novnc/core/rfb.js"
+ "$product_dir/deploy/node_modules/ws/index.js"
+ "$product_dir/deploy/mobile/ios/index.html"
+ "$product_dir/deploy/mobile/android/index.html"
+ "$product_dir/deploy/mobile/harmony/index.html"
+ "$product_dir/deploy/mobile-surface.mjs"
+ "$product_dir/apps/server/dist/src/index.js"
+ "$product_dir/apps/desktop/dist/renderer/index.html"
+ "$product_dir/apps/desktop/src/vendor/electron-updater.cjs"
+ "$native_runtime/usr/bin/x11vnc"
+)
+for required_file in "${required_files[@]}"; do
+ if [[ ! -e "$required_file" ]]; then
+ printf 'RoleWeave: deployment is not built; missing %s\n' "$required_file" >&2
+ printf 'Run bash scripts/build-deployment.sh from the product directory.\n' >&2
+ exit 69
+ fi
+done
+
+mkdir -p "$runtime_dir/user-data" "$runtime_dir/logs"
+
+export ROLEWEAVE_PHONE_LINK_PORT="$PORT"
+if [[ -z "${ROLEWEAVE_PHONE_LINK_HOST_TOKEN:-}" ]]; then
+ ROLEWEAVE_PHONE_LINK_HOST_TOKEN="$(node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')"
+ export ROLEWEAVE_PHONE_LINK_HOST_TOKEN
+fi
+
+child_pids=()
+electron_pid=""
+cleanup() {
+ local pid
+ trap - EXIT INT TERM HUP
+ if [[ -n "$electron_pid" ]]; then
+ kill -TERM -- "-$electron_pid" 2>/dev/null || true
+ fi
+ for pid in "${child_pids[@]}"; do
+ kill "$pid" 2>/dev/null || true
+ done
+ wait 2>/dev/null || true
+}
+trap cleanup EXIT INT TERM HUP
+
+export DISPLAY="$display"
+export XDG_RUNTIME_DIR="$runtime_dir/xdg-$PORT"
+if [[ -z "${ROLEWEAVE_DEFAULT_WORKSPACE:-}" && -f "$product_dir/examples/oss-maintainer/workspace.json" ]]; then
+ export ROLEWEAVE_DEFAULT_WORKSPACE="$product_dir/examples/oss-maintainer"
+fi
+export ELECTRON_OZONE_PLATFORM_HINT=x11
+export LD_LIBRARY_PATH="$runtime_libs${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+export GSETTINGS_SCHEMA_DIR="$native_runtime/usr/share/glib-2.0/schemas"
+export XDG_DATA_DIRS="$native_runtime/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}:/usr/local/share:/usr/share"
+mkdir -p "$XDG_RUNTIME_DIR"
+chmod 700 "$XDG_RUNTIME_DIR"
+
+Xvfb "$display" -screen 0 1240x800x24 -nolisten tcp -ac \
+ >"$runtime_dir/logs/xvfb.log" 2>&1 &
+child_pids+=("$!")
+
+for _ in $(seq 1 100); do
+ [[ -S "/tmp/.X11-unix/X$display_number" ]] && break
+ sleep 0.05
+done
+if [[ ! -S "/tmp/.X11-unix/X$display_number" ]]; then
+ printf 'RoleWeave: X display failed to start\n' >&2
+ exit 70
+fi
+
+"$native_runtime/usr/bin/x11vnc" -display "$display" -forever -shared \
+ -nopw -localhost -rfbport "$vnc_port" -noxdamage -quiet \
+ >"$runtime_dir/logs/x11vnc.log" 2>&1 &
+child_pids+=("$!")
+
+node "$product_dir/deploy/web-server.mjs" "$PORT" "$vnc_port" \
+ >"$runtime_dir/logs/web-server.log" 2>&1 &
+child_pids+=("$!")
+
+cd "$product_dir"
+setsid "$product_dir/node_modules/electron/dist/electron" \
+ --no-sandbox \
+ --disable-gpu \
+ --disable-dev-shm-usage \
+ --user-data-dir="$runtime_dir/user-data" \
+ apps/desktop \
+ >"$runtime_dir/logs/electron.log" 2>&1 &
+electron_pid="$!"
+child_pids+=("$electron_pid")
+
+wait "$electron_pid"