commit 132b274768d659661d3d5ed4ae54f519ab35beed
Author: jiangchen <1703935164@qq.com>
Date: Tue Sep 1 14:25:35 2026 +0800
Initial DSHWatch DSHWatch_Server scaffold
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..34b7754
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+config.json
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..80d2d5a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,70 @@
+# DSHWatch_Server
+
+DSHWatch 中继服务器(Node.js)。它作为一个受控的 Broker:
+
+- **DSH 插件**(dsh-watch,运行在 DeepSeek Harness 里)主动 **外连** 到本服务器并注册 userId。
+- **手机客户端**(原生 Android APP,DSHWatchPhone)连接本服务器,用同一个 userId/token 获得对该 Harness 的控制权。
+- 手环/手表(DSHWatch)只能走蓝牙,所以由手机 APP 作为网关:手机把从服务器来的状态/事件通过 system.interconnect 发给手表,也把手表上的指令/输入转发回服务器。
+
+服务器本身不保存任何会话内容,只做**鉴权 + 转发 + 在线状态广播**。
+
+## 启动
+
+ cd DSHWatch_Server
+ cp config.example.json config.json # 然后修改 token
+ npm install
+ npm start
+
+默认监听 0.0.0.0:8787,健康检查:GET /health。
+
+## WebSocket 协议
+
+所有消息都是 JSON 文本帧。
+
+### 握手(Hello)
+
+DSH 插件(Harness 侧):
+
+ { "type": "hello", "role": "dsh", "userId": "someone", "token": "..." }
+
+客户端(手机 APP / 手表网关):
+
+ { "type": "hello", "role": "client", "userId": "someone", "token": "..." }
+
+服务器回:
+
+ { "type": "hello", "role": "client", "online": true, "harness": true, "status": { ... } }
+
+### 客户端 -> 服务器 -> Harness
+
+客户端发送请求:
+
+ { "type": "request", "requestId": "abc", "method": "session.prompt", "params": { ... } }
+
+服务器转发给 Harness(requestId 保持不变),Harness 回:
+
+ { "type": "response", "requestId": "abc", "ok": true, "result": { ... } }
+
+服务器按客户端原始 requestId 回给客户端。
+
+### Harness -> 服务器 -> 客户端
+
+Harness 推送状态快照 / 会话事件:
+
+ { "type": "status", "status": { ... } }
+ { "type": "event", "event": { "type": "session/event", "sessionId": "...", "event": { ... } } }
+
+服务器广播给该 userId 下的所有客户端。
+
+### 心跳
+
+任意一端都可发 { "type": "ping" },服务器回 { "type": "pong" }。
+
+## 部署
+
+公网部署(腾讯云等)时:
+
+- 把 host 设为 0.0.0.0;
+- 用防火墙/安全组放行 8787;
+- 强烈建议在 Nginx/Caddy 后面挂 TLS(wss://),token 不要走明文;
+- 不要把 token 写进 git;用环境变量 DSHWATCH_TOKEN 或本地 config.json(加进 .gitignore)。
diff --git a/config.example.json b/config.example.json
new file mode 100644
index 0000000..cb50d8a
--- /dev/null
+++ b/config.example.json
@@ -0,0 +1,6 @@
+{
+ "port": 8787,
+ "host": "0.0.0.0",
+ "token": "CHANGE_ME_TO_A_LONG_RANDOM_SECRET",
+ "heartbeatMs": 30000
+}
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
new file mode 100644
index 0000000..3fb072c
--- /dev/null
+++ b/docs/DESIGN.md
@@ -0,0 +1,282 @@
+# DSHWatch 设计与颗粒度对齐报告
+
+> 版本:v0.1(讨论稿)
+> 目标:让小米手环 10 Pro(VelaOS 快应用)通过手机网关远程管理 DeepSeek Harness;手机端为完整客户端,手表端为阉割版但核心功能可用。
+
+---
+
+## 1. 一句话目标
+
+把 DeepSeek Harness 变成"可被远程控制的服务":DSH 里装一个插件(dsh-watch-bridge),插件主动连到一个 Node 中继服务器(DSHWatch_Server),手机 APP(DSHWatchPhone)连同一个服务器;手表(DSHWatch)没有网络只能走蓝牙,由手机 APP 作为网关转发。这样手表和手机都能看状态、选会话、发指令、输入文字、中断任务。
+
+---
+
+## 2. 总体架构
+
+ ┌─────────────────────────────┐
+ │ DeepSeek Harness(本机) │
+ │ + dsh-watch-bridge (插件) │
+ └──────────────┬──────────────┘
+ │ ws/wss 主动外连
+ ▼
+ ┌─────────────────────────────┐
+ │ DSHWatch_Server (Node) │
+ │ · 鉴权 / 转发 / 在线广播 │
+ └───┬─────────────────┬───────┘
+ │ ws/wss │ ws/wss
+ ┌────────────▼────────┐ ┌─────▼──────────────┐
+ │ DSHWatchPhone │ │ 其他客户端(可选) │
+ │ Android APP │ └────────────────────┘
+ └──────────┬─────────┘
+ │ BLE / 小米穿戴 SDK (MessageApi)
+ ┌──────────▼─────────┐
+ │ DSHWatch │
+ │ Vela 手表快应用 │
+ └────────────────────┘
+
+**关键事实(调研结论)**:
+
+- 手表(VelaOS)与手机之间的数据通道是 system.interconnect(BLE)。它要求手机侧是一个 **原生 Android APP**,且该 APP 的包名和签名必须与手表 rpk 的一致(参考官方 demo interconnect_image_demo:android_program/ 是原生 Android 工程,src/ 是手表快应用)。
+- 手机快应用(system.bluetooth 等)无法直接充当该互联对端,因此 **DSHWatchPhone 采用原生 Android APP**(用户已确认)。
+- 麦克风:Vela 提供 system.record 录音 API,但官方"支持明细"表里 **小米手环 8 Pro / 9 / 9 Pro / 10 均标为"不支持"**,小米手环 10 Pro 未单独列出,按同系列判断大概率也不支持应用级麦克风。→ 手表语音输入先不承诺,作为"真机验证后再说"的可选增强。
+
+---
+
+## 3. 四个组成部分
+
+### 3.1 DSHWatch_Server(Node 中继)
+
+- 技术:Node + ws。
+- 职责:鉴权(userId + token)、请求/响应转发、事件广播、在线状态维护。
+- 不存会话内容,纯转发。
+- 健康检查:GET /health。
+- 部署:腾讯云/公网,建议 nginx/caddy 挂 TLS(wss://)。
+
+### 3.2 dsh-watch-bridge(DSH 插件)
+
+- 运行在 Harness 内,apply(ctx, config)。
+- 配置:userId / token / serverUrl / reconnectMs / timeoutMs。
+- 主动外连服务器,注册 userId。
+- 把 DSH apiProxy 的方法透传成远程 RPC:
+ - harness.status、harness.prompt
+ - session.*(list/create/history/prompt/cancel/rename/fork/models/selectModel/attachment)
+ - goal.*、subagent.*、workspace.*、host.*
+ - skill.list、agentPreset.*、llm.*、settings.*、credentials.*
+- 推送事件:session/event、session/projection、status 快照。
+
+### 3.3 DSHWatch(Vela 手表快应用)
+
+- 只能 BLE,因此不直接连服务器;通过 system.interconnect 与手机 APP 通信,手机 APP 代它与服务器交互。
+- 页面结构(按用户要求):
+ - **主页(中间)**:DeepSeek 吉祥物大图 + 状态提示。
+ - **左页**:进入具体会话(会话列表 / 历史摘要)。
+ - **右页**:设置(服务器地址、userId、token、连接状态、余额显示预留等)。
+ - 左右滑动 3 页;底部指示器:当前页为长条,其他为小点。
+ - 支持唤出键盘输入、快捷指令、中断。
+
+### 3.4 DSHWatchPhone(Android APP)
+
+- 技术:Android 原生 + 小米穿戴 SDK(Wearable / MessageApi / AuthApi / NodeApi / NotifyApi)+ WebSocket(okhttp-ws)联服务器。
+- 职责:
+ 1. 作为手表 BLE 互联对端(接收/发送 connect.send 数据)。
+ 2. 作为服务器客户端(userId + token),显示完整 DSH 状态、会话、命令流。
+ 3. 双向转发:服务器 ↔ 手表。
+- UI 相对自由,规划见 §6。
+
+---
+
+## 4. 通信协议设计
+
+### 4.1 手表 ↔ 手机(BLE/interconnect)
+
+手表通过 @system.interconnect 的 connect.send({ data }) 发给手机;手机通过小米穿戴 SDK MessageApi.sendMessage(nodeId, byte[]) 发给手表。数据用 **JSON 文本 + base64 分片**(参考官方 demo 的分片协议)。
+
+定义消息信封(手表→手机 / 手机→手表):
+
+- 方向:to = "watch" | "phone"
+- type:hello / status / event / command / text / ack / cancel / settings / heartbeat
+- seq:序号(手表端简单递增)
+- payload:具体负载,可含 sessionId、content、text、settings 等。
+
+示例:
+
+ { "to":"phone", "type":"command", "seq":3, "payload":{ "sessionId":null, "kind":"prompt", "content":"帮我看看今天的任务" } }
+
+### 4.2 手机 ↔ 服务器(WebSocket)
+
+- 握手:{ "type":"hello", "role":"client", "userId":"...", "token":"..." }
+- 请求:{ "type":"request", "requestId":"...", "method":"session.prompt", "params":{...} }
+- 响应:{ "type":"response", "requestId":"...", "ok":true, "result":{...} }
+- 事件推送:{ "type":"event", "event":{ "type":"session/event", "sessionId":"...", "event":{...} } }
+- 状态推送:{ "type":"status", "status":{...} }
+- 心跳:{ "type":"ping" } / { "type":"pong" }
+
+### 4.3 DSH 插件 ↔ 服务器(WebSocket)
+
+- 握手:{ "type":"hello", "role":"dsh", "userId":"...", "token":"..." }
+- 服务器转发客户端 request 给插件;插件回 response。
+- 插件主动推 event / status。
+
+---
+
+## 5. 手表端 UI 与交互细则
+
+### 5.1 首页切换(对齐用户需求)
+
+- 三页:左(会话)— 中(主页/吉祥物)— 右(设置)。
+- 左右滑动切换(用 Vela swiper 组件或手动 swipe 手势 + translate)。
+- 底部指示器:
+ - 当前页:长条(圆角矩形,宽约 20px)。
+ - 非当前页:小圆点(约 6px,半透明)。
+ - 居中对齐,位于页面下方。
+
+> 技术落地:Vela 提供原生 swiper(container/swiper),支持 index / loop / previousmargin / nextmargin / change 事件 / swipeTo 方法。内置 indicator 只有统一圆点,做不成"当前页长条",所以把 indicator=false,自己在 swiper 下方用 3 个条目渲染指示条,靠 change 事件驱动高亮。详见 docs/WATCH_UI.md。
+
+### 5.2 主页(中间页)
+
+- 中心:DeepSeek 吉祥物大图(复用 dsh-whale-widget 的 DSniang1.png,蓝色 maid 妹;低分辨率屏幕用压缩版)。
+- 吉祥物下方:当前状态一行字:
+ - 在线 / 离线 / 思考中 / 执行中 / 等待批准。
+- 顶部:小字显示"当前会话 id 前 6 位 + 标题"。
+- 点击吉祥物:进入"详情"(分骨架:状态卡片 / 最近事件 / 快捷操作)。
+
+### 5.3 左页(会话)
+
+- 会话列表(最近 N 条,后台 session.list)。
+- 每行:会话标题(或 id 前几位)、是否 running、最近活动时间。
+- 点击某行:设为当前会话,并跳到主页。
+
+### 5.4 右页(设置)
+
+- 服务器地址(input)。
+- userId(input)。
+- token(input,可显示/隐藏)。
+- 连接状态(已连接/重连中/离线)。
+- 手环与手机连接状态(getReadyState / 轮询)。
+- 余额显示预留(暂不实现,用户稍后定)。
+- 主题/动效开关。
+
+### 5.5 输入与指令
+
+- 键盘:唤起 watch 键盘(参考 Vela 输入法组件 /pages/keyboard),支持中英文。
+- 快捷指令:预设几条常用命令(如"查看今日任务"、"总结当前进度"、"停止")。
+- 语音:预留,但默认关闭(system.record 大概率不支持)。
+
+### 5.6 动效
+
+- 状态变化时吉祥物切换:DSniang02.png(带气泡)作"思考"态,rua.gif 作交互彩蛋。
+- 列表更新时用 tid 避免闪烁(官方 FAQ 建议)。
+
+---
+
+## 6. 手机端 UI(相对自由,规划版)
+
+首页:
+- 顶部:连接状态徽标(服务器 / Harness / 手表)。
+- 中部:当前会话卡片 + 吉祥物缩略图 + 最近事件流。
+- 底部:输入框 + 发送 + 快捷指令 + 取消。
+
+二层:
+- 会话列表页(搜索、归档、新建)。
+- 设置页(服务器地址、userId、token、主题、通知开关)。
+- 事件/日志页(session/event 原始流,便于调试)。
+
+交互:
+- 与手表操作 1:1 联动,手机可作为完整控制台。
+- 支持会话选择、模型切换(session.models / session.selectModel)、Goal 管理(goal.*)、子代理(subagent.*)。
+
+---
+
+## 7. 功能矩阵(手表 W / 手机 P)
+
+| 功能 | 手表 | 手机 | 说明 |
+| --- | --- | --- | --- |
+| 连接状态 | W | P | 服务器 + 手表连接 |
+| 查看当前 session | W | P | 列表/摘要 |
+| 选择会话 | W | P | |
+| 发送文本指令 | W | P | 手表用键盘/快捷键 |
+| 中断/取消 | W | P | session.cancel |
+| 查看会话历史 | W(简) | P | |
+| 查看余额/用量 | 预留 | 预留 | 稍后定 |
+| Goal 管理 | 快捷 | P | |
+| 子代理管理 | 不 | P | |
+| 模型切换 | 不 | P | |
+| 通知 | W(提醒) | P | 手机推送 + 手表震动 |
+| 语音输入 | 可能不可用 | 可选 | system.record 风险 |
+
+---
+
+## 8. 颗粒度 / 里程碑
+
+> 对齐"先搞定哪一层":建议按 P0→P3 推进,每层都有可验收的标准。
+
+### P0:链路打通(最小闭环)
+
+- [ ] DSHWatch_Server 可启动,/health 正常。
+- [ ] dsh-watch-bridge 装入 DSH,配置 userId/token/serverUrl 后连上服务器。
+- [ ] Android APP 能连服务器、发一条 session.list / harness.status,收到响应 & 状态推送。
+- 验收:手机看到 Harness 在线状态和当前会话,能发送一条命令并看到返回。
+
+### P1:手表侧 UI + 网关
+
+- [ ] DSHWatch 三页滑动 + 底部指示器 + 主页吉祥物。
+- [ ] 手表通过 interconnect 与手机 APP 打通(需包名/签名一致,使用配套证书)。
+- [ ] 手机 APP 把服务器状态/事件转发给手表;手表显示在线/思考/执行状态。
+- [ ] 手表快捷指令能发出命令,手机转发到服务器,结果回显。
+- 验收:手表能看状态、选会话、发预设指令。
+
+### P2:完整输入 & 设置
+
+- [ ] 手表键盘输入文本指令。
+- [ ] 手表设置页(server/userId/token/连接状态)。
+- [ ] 手机端会话列表、事件流、取消、模型/Goal。
+- 验收:手表可手动输入任意指令;手机端体验完整。
+
+### P3:增强
+
+- [ ] 余额/用量展示(用户稍后定方案)。
+- [ ] 通知/震动、主题、动效、语音(真机验证后)。
+- 验收:设备侧体验接近"1:1 串流"。
+
+---
+
+## 9. 资源复用(来自 dsh-whale-widget)
+
+插件目录:D:\project\dsh-whale-widget\assets
+
+| 文件 | 尺寸 | 建议用途 |
+| --- | --- | --- |
+| DSniang1.png | 610x610 | 主页主吉祥物(压缩后放入手表) |
+| DSniang02.png | 1026x1026 | "思考/等待"气泡态 |
+| rua.gif | 128x128 | 交互彩蛋/摸头 |
+| DSH2.png | 2048x1024 | 手机端/卡片背景(可裁剪) |
+
+占位:手表端用图需压缩到合适尺寸(如 240x240 或 320x320,PNG/带透明)。可写一个脚本用 sharp 从原图生成,或者手选。
+
+---
+
+## 10. 技术风险 & 待定项
+
+1. **麦克风**:system.record 对小米手环 10 / 10 Pro 大概率不支持 → 语音输入默认关闭,真机验证后再决定。
+2. **包名/签名**:手表 rpk 与手机 APP 必须一致,且用同一套证书;官方文档明确"签名不正确会被拒绝通信"。需要先确定一个包名(如 com.dshwatch.app)。
+3. **SDK 依赖**:手机侧需要用小米穿戴 SDK(XMS Wearable),来自官方 demo 的 android_program/libs。需要先从官方 demo 下载拿 jar/aar。
+4. **公网部署**:建议 TLS + 长短 token;不要把 token 写进 git。
+5. **Vela 组件能力**:swiper、system.record、system.file 等是否都满足,需在真机确认。
+6. **余额显示**:用户说可能改,先预留,不做死。
+
+---
+
+## 11. 已搭骨架(代码暂停,待颗粒度对齐后继续完善)
+
+- DSHWatch_Server/server.js:中继 Broker(可跑,已语法检查)。
+- DSHWatch_Server/plugin/:dsh-watch-bridge 插件骨架。
+- 后续:Android 工程(DSHWatchPhone 改为原生 Android)、Vela 手表 UI、桌面 profile 插件挂载。
+
+---
+
+## 12. 待确认问题
+
+1. 手表端"分骨架"具体指什么?是状态卡片/功能骨架,还是"分包/子代理"列表?→ 需要用户明确(默认理解为状态 + 最近事件 + 快捷操作)。
+2. 包名用 com.dshwatch.app 可以吗?(决定后统一改)。
+3. 余额显示方案(等用户稍后给)。
+4. 是否要手环本地缓存(离线也能看最近状态)?
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
new file mode 100644
index 0000000..67070c3
--- /dev/null
+++ b/docs/PROTOCOL.md
@@ -0,0 +1,168 @@
+# DSHWatch 通信协议细化(v0.1)
+
+> 本文件是 docs/DESIGN.md §4 的细化,定义三段链路的字段级协议,便于后续按此写代码。仍属"对齐粒度"文档。
+
+---
+
+## 0. 概述
+
+三跳:
+
+1. 手表(DSHWatch) <-> 手机(DSHWatchPhone):BLE / @system.interconnect / Wearable MessageApi。
+2. 手机 <-> 服务器(DSHWatch_Server):WebSocket。
+3. 服务器 <-> DSH 插件(dsh-watch-bridge):WebSocket。
+
+手机在 1 和 2 之间做双向网关翻译。手表看到的永远是"整理过的短文本",不直接透传原始大 JSON。
+
+---
+
+## 1. 手表 <-> 手机(BLE / interconnect)
+
+### 1.1 物理通道
+
+- 手表:@system.interconnect 的 connect.send({ data: obj })。
+- 手机:小米穿戴 SDK MessageApi.sendMessage(nodeId, byte[])。
+- 大数据(文本 > 约 1KB):按 base64 分片。官方 demo 用 header/data/end 三段;本项目沿用:
+
+ 开始帧:{ "type":"header", "totalChunks": N, "id":"..." }
+ 数据帧:{ "type":"data", "id":"...", "index": i, "chunk":"base64..." }
+ 结束帧:{ "type":"end", "id":"..." }
+
+### 1.2 消息信封
+
+ {
+ "to": "phone" | "watch",
+ "type": "hello" | "status" | "event" | "command" | "text" | "cancel" | "settings" | "ack" | "heartbeat",
+ "seq": 123,
+ "ts": 1760000000000,
+ "payload": { ... }
+ }
+
+### 1.3 type 与 payload
+
+- hello:{ "role":"watch|phone", "version":"0.1" }
+- status:{ "online":bool, "harnessOnline":bool, "running":bool, "sessionId":"...", "sessionTitle":"...", "model":"...", "server":"..." }
+- event:{ "kind":"session/event|session/projection", "sessionId":"...", "summary":"...", "seq":123 }
+- command:{ "kind":"prompt|quick|select_session", "sessionId":"...", "content":"...", "quick":"..." }
+- text:{ "sessionId":"...", "text":"..." }
+- cancel:{ "sessionId":"..." }
+- settings:{ "serverUrl":"...", "userId":"...", "token":"..." }
+- ack:{ "ok":bool, "error":{ "code":"...", "message":"..." } }
+- heartbeat:{}
+
+### 1.4 顺序与可靠性
+
+- seq 手表端单调递增。
+- 手机端收到后原样回 ack(ok)。
+- 分片用 id 关联;缺失分片则丢弃该条并 ack 失败。
+
+---
+
+## 2. 手机 <-> 服务器(WebSocket)
+
+连接地址:ws(s)://host:port/ws(或 /)。
+
+### 2.1 握手
+
+手机发送:
+
+ { "type":"hello", "role":"client", "userId":"...", "token":"..." }
+
+服务器回:
+
+ { "type":"hello", "role":"client", "online":true, "harness":true, "status":{...} }
+
+### 2.2 请求/响应
+
+手机 -> 服务器:
+
+ { "type":"request", "requestId":"uuid", "method":"session.prompt", "timeoutMs":30000, "params":{...} }
+
+服务器 -> 手机(转发给 Harness 后回):
+
+ { "type":"response", "requestId":"uuid", "ok":true, "result":{...} }
+ { "type":"response", "requestId":"uuid", "ok":false, "error":{ "code":"...", "message":"..." } }
+
+### 2.3 推送(服务器 -> 手机)
+
+ { "type":"harness", "online":true }
+ { "type":"status", "status":{...} }
+ { "type":"event", "event":{ "type":"session/event", "sessionId":"...", "event":{...} } }
+ { "type":"notice", "code":"...", "message":"..." }
+
+### 2.4 method 白名单(插件透传)
+
+- harness.status / harness.prompt
+- session.list/create/history/prompt/cancel/rename/fork/models/selectModel/attachment
+- goal.create/edit/pause/resume/complete/clear
+- subagent.list/history/prompt/interrupt
+- workspace.list/create/rename/delete/insertBefore/insertSessionBefore/archiveSession
+- host.describe/pickDirectory/listDirectory/createDirectory/openPath
+- skill.list
+- agentPreset.list/select/read/copy/openDocument/remove
+- llm.providers/models/discoverModels
+- settings.describe/openDocument/update/replace/mutate
+- credentials.describe/set/unset
+
+---
+
+## 3. 服务器 <-> DSH 插件(WebSocket)
+
+### 3.1 握手
+
+插件 -> 服务器:
+
+ { "type":"hello", "role":"dsh", "userId":"...", "token":"..." }
+
+服务器回:
+
+ { "type":"hello", "role":"dsh", "online":true, "status":{ "userId":"..." } }
+
+### 3.2 转发
+
+服务器 -> 插件(来自手机):
+
+ { "type":"request", "requestId":"uuid", "method":"...", "params":{...} }
+
+插件 -> 服务器:
+
+ { "type":"response", "requestId":"uuid", "ok":true, "result":{...} }
+ { "type":"response", "requestId":"uuid", "ok":false, "error":{...} }
+
+### 3.3 推送
+
+插件 -> 服务器:
+
+ { "type":"status", "status":{...} }
+ { "type":"event", "event":{...} }
+ { "type":"ping" } # 心跳
+
+### 3.4 状态快照形态
+
+ {
+ "online": true,
+ "ts": 1760000000000,
+ "version": "0.0.1",
+ "cwd": "D:/...",
+ "provider": "deepseek",
+ "model": "deepseek-chat",
+ "attachedSessions": 2,
+ "activeSessionId": "session-xxx",
+ "running": false,
+ "sessions": [ { "sessionId":"...", "updatedAt":123, "running":false, "blank":false, "agentPreset":"..." } ]
+ }
+
+---
+
+## 4. 错误码约定
+
+auth-error / missing-userId / bad-request / harness-offline / harness-timeout / invoke-error / duplicate-requestId / invalid-JSON / unknown-path
+
+---
+
+## 5. 备注
+
+- 服务器不落盘,纯内存转发。
+- token 建议走 wss,且在服务端用环境变量或本地 config.json,不入 git。
+- 客户端重连:指数退避(1s,2s,4s,8s,上限 30s)。
+- 事件流可能很大,手表端只渲染 kind=session/event 且是 text 的摘要;其余在手机端可看原始。
diff --git a/docs/WATCH_UI.md b/docs/WATCH_UI.md
new file mode 100644
index 0000000..1a75195
--- /dev/null
+++ b/docs/WATCH_UI.md
@@ -0,0 +1,142 @@
+# DSHWatch 手表端 UI 规范(v0.1)
+
+> 本文件是对 docs/DESIGN.md §5 的细化,用于对齐手表端 UI 颗粒度。仍属"报告/规范"阶段,不含最终可运行代码。
+
+---
+
+## 1. 主页骨架:三页 + Swiper + 自定义指示点
+
+Vela 原生提供 swiper 组件(container/swiper),关键能力:
+
+- index(当前页)
+- indicator(内置圆点,可关闭)
+- loop(是否循环)
+- previousmargin / nextmargin(可露出相邻页一小部分)
+- change 事件({ index })
+- swipeTo({ index }) 方法(程序切页)
+- indicator-color / indicator-selected-color / indicator-size(内置点样式)
+
+用户要求:当前页为长条、其他为小点。内置 indicator 是统一圆点,无法做成"当前页长条"。因此方案是:
+
+- swiper 的 indicator 置为 false(不显示内置点)。
+- 在 swiper 下方自己放一行指示条:3 个条目(左/中/右页)。
+- 监听 swiper 的 change 事件更新 currentIndex。
+- 渲染时:currentIndex 对应的条目为长条(宽 20px、高 6px、圆角),其余为小圆点(6px、半透明)。
+
+### 伪模板(仅示意,非最终代码)
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+
+
+
+
+
+
+
+
+
+### 交互要点
+
+- 左右滑动手势由 swiper 原生支持(enableswipe 默认 true)。
+- change 事件把 index 同步到 data,驱动 dots 高亮。
+- 从会话页/设置页点击返回主页:调用 swiper 实例的 swipeTo({ index: 1 })。
+- loop=false,避免三页循环造成"底部指示器错位"。
+
+---
+
+## 2. 中间页(主页/吉祥物)
+
+- 中心大图:DSniang1.png(压缩到 240~320px)。
+- 状态文字(在线/离线/思考中/执行中/等待批准)用吉祥物下方一行字。
+- 顶部小字:当前会话 id 前 6 位 + 标题。
+- 点击吉祥物 → 详情/骨架页:
+ - 状态卡片(在线、当前会话、模型、运行中/空闲)。
+ - 最近事件摘要(最近 5~8 条 session/event 的 text 摘要)。
+ - 快捷操作:重跑/停止/新会话。
+- 状态变化时吉祥物可切换:
+ - 思考/等待:DSniang02.png(带气泡)。
+ - 交互彩蛋:rua.gif(点按/摸头)。
+
+---
+
+## 3. 左页(会话)
+
+- 列表数据源:session.list(经手机网关转发)。
+- 每行显示:
+ - 标题(无标题则取 id 前 6 位)
+ - running 标记(绿色小点 / "执行中")
+ - 最近活动时间(转成相对时间,如 3 分钟前)
+- 点击行为:设置当前会话 → 切回主页 → 刷新状态。
+- 需要滚动列表:用 list 组件 + for/tid 避免闪烁(官方 FAQ)。
+
+---
+
+## 4. 右页(设置)
+
+- 服务器地址(手机/用户填)
+- userId
+- token(可切换明文/掩码)
+- 连接状态:服务器状态 + 手环↔手机连接状态(connect.getReadyState,轮询;官方 FAQ 建议进页面轮询,别只取一次)
+- 余额显示预留(占位,方案后定)
+- 主题/动效开关(预留)
+
+---
+
+## 5. 输入法 / 键盘
+
+- 参考 Vela 输入法组件(open source:NEORUAA/Vela_input_method),支持中英文、圆/方屏。
+- 在输入指令页面使用该自定义组件。
+- 快捷指令(预设按钮):如 "查看今日任务"、"总结当前进度"、"停止"。
+- 语音输入:预留,默认关闭(system.record 对 Band 10/10 Pro 大概率不支持)。
+
+---
+
+## 6. 与手机/服务器的状态同步
+
+手表不直接连服务器,所有的请求/事件经手机转发。
+
+手表 → 手机(interconnect):
+- 类型 command / text / cancel / settings / heartbeat
+- 负载含 sessionId、content、text 等。
+
+手机 → 手表(interconnect):
+- 类型 status / event / ack / settings
+- 手机把服务器 status/event 整理成手表可读的短文本(手表端不渲染原始大 JSON)。
+
+---
+
+## 7. 验收标准(手表侧 P1)
+
+1. 三个页面能左右滑动切换;底部指示器当前页长条、其他小点。
+2. 主页显示吉祥物 + 在线/离线/思考/执行状态;点吉祥物能进详情骨架。
+3. 左页能列出会话并选中;右页能填 server/userId/token 并显示连接状态。
+4. 手表通过 interconnect 与手机 APP 打通,能收到服务器状态/事件。
+5. 手表能发快捷指令,经手机→服务器→Harness 执行并回显。
+
+---
+
+## 8. 待定
+
+- "分骨架"具体指状态页还是子代理列表(见 DESIGN.md §12)。
+- 是否手环本地缓存最近状态(离线可见)。
+- 包名(建议 com.dshwatch.app)待确认。
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9f902ed
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "dshwatch-server",
+ "version": "0.1.0",
+ "description": "DSHWatch relay broker: bridges DeepSeek Harness instances (dsh-watch plugin) with phone/watch clients.",
+ "type": "module",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node --watch server.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "dependencies": {
+ "ws": "^8.18.0"
+ },
+ "license": "MIT"
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..4d95a3c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,257 @@
+// DSHWatch relay broker
+// Bridges DeepSeek Harness instances (DSH plugins) with phone/watch clients over WebSocket.
+//
+// Roles:
+// dsh - the DeepSeek Harness plugin (one per userId)
+// client - phone app (and through the phone app, the watch)
+//
+// Protocol is a tiny JSON envelope. See README.md.
+
+import { createServer } from 'node:http';
+import { WebSocketServer } from 'ws';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import process from 'node:process';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function loadConfig() {
+ const configPath = process.env.DSHWATCH_SERVER_CONFIG || path.join(__dirname, 'config.json');
+ let config = {};
+ try {
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
+ } catch {
+ // use defaults
+ }
+ return {
+ port: Number(config.port || process.env.PORT || 8787),
+ host: config.host || process.env.HOST || '0.0.0.0',
+ token: config.token || process.env.DSHWATCH_TOKEN || 'change-me',
+ heartbeatMs: Number(config.heartbeatMs || 30000)
+ };
+}
+
+const config = loadConfig();
+
+const harnesses = new Map();
+const clients = new Map();
+const pending = new Map();
+const lastStatus = new Map();
+const wsUserId = new Map();
+
+function bindUserId(ws, userId) { wsUserId.set(ws, userId); }
+function userIdOf(ws) { return wsUserId.get(ws); }
+
+function clientsFor(userId) {
+ let set = clients.get(userId);
+ if (!set) {
+ set = new Set();
+ clients.set(userId, set);
+ }
+ return set;
+}
+
+function send(ws, obj) {
+ if (ws.readyState === ws.OPEN) {
+ try { ws.send(JSON.stringify(obj)); } catch { /* ignore */ }
+ }
+}
+
+function broadcastToClients(userId, obj) {
+ for (const ws of clientsFor(userId)) send(ws, obj);
+}
+
+function replyError(clientWs, requestId, message, code) {
+ send(clientWs, { type: 'response', requestId, ok: false, error: { code: code || 'unreachable', message } });
+}
+
+function registerHarness(userId, ws) {
+ const prev = harnesses.get(userId);
+ if (prev && prev !== ws) {
+ send(prev, { type: 'notice', code: 'replaced', message: 'A newer harness connection took over this userId.' });
+ try { prev.close(); } catch {}
+ }
+ harnesses.set(userId, ws);
+ const status = lastStatus.get(userId) || null;
+ send(ws, { type: 'hello', role: 'dsh', online: true, status: status ? { userId: status.userId } : {} });
+ broadcastToClients(userId, { type: 'harness', online: true });
+}
+
+function unregisterHarness(userId, ws) {
+ if (harnesses.get(userId) === ws) {
+ harnesses.delete(userId);
+ broadcastToClients(userId, { type: 'harness', online: false });
+ }
+}
+
+function handleDshHello(userId, ws, msg) {
+ bindUserId(ws, userId);
+ if (msg.token !== config.token) {
+ send(ws, { type: 'auth-error', message: 'invalid token' });
+ ws.close();
+ return;
+ }
+ ws.role = 'dsh';
+ registerHarness(userId, ws);
+}
+
+function handleClientHello(userId, ws, msg) {
+ bindUserId(ws, userId);
+ if (msg.token !== config.token) {
+ send(ws, { type: 'auth-error', message: 'invalid token' });
+ ws.close();
+ return;
+ }
+ ws.role = 'client';
+ clientsFor(userId).add(ws);
+ const harness = harnesses.get(userId);
+ const status = lastStatus.get(userId) || null;
+ send(ws, {
+ type: 'hello',
+ role: 'client',
+ online: Boolean(harness),
+ harness: Boolean(harness),
+ status
+ });
+}
+
+function handleHarnessMessage(userId, ws, msg) {
+ if (msg.type === 'response') {
+ const req = pending.get(msg.requestId);
+ if (req) {
+ clearTimeout(req.timer);
+ pending.delete(msg.requestId);
+ send(req.clientWs, {
+ type: 'response',
+ requestId: req.clientRequestId,
+ ok: msg.ok !== false,
+ result: msg.result,
+ error: msg.error
+ });
+ }
+ } else if (msg.type === 'event') {
+ broadcastToClients(userId, { type: 'event', event: msg.event });
+ } else if (msg.type === 'status') {
+ lastStatus.set(userId, msg.status || null);
+ broadcastToClients(userId, { type: 'status', status: msg.status });
+ } else if (msg.type === 'ping') {
+ send(ws, { type: 'pong' });
+ }
+}
+
+function handleClientMessage(userId, ws, msg) {
+ if (msg.type === 'request') {
+ const harness = harnesses.get(userId);
+ if (!harness) {
+ replyError(ws, msg.requestId, 'harness offline');
+ return;
+ }
+ const requestId = msg.requestId || ('req-' + Date.now() + '-' + Math.random().toString(16).slice(2));
+ if (pending.has(requestId)) {
+ replyError(ws, msg.requestId, 'duplicate requestId', 'bad-request');
+ return;
+ }
+ const timer = setTimeout(() => {
+ pending.delete(requestId);
+ replyError(ws, msg.requestId, 'harness timeout');
+ }, Number(msg.timeoutMs) || 30000);
+ pending.set(requestId, { userId, clientWs: ws, clientRequestId: msg.requestId, timer });
+ send(harness, { type: 'request', requestId, method: msg.method, params: msg.params || {} });
+ } else if (msg.type === 'ping') {
+ send(ws, { type: 'pong' });
+ }
+}
+
+function handleMessage(ws, raw) {
+ let msg;
+ try { msg = JSON.parse(String(raw)); } catch {
+ send(ws, { type: 'error', message: 'invalid JSON' });
+ return;
+ }
+ if (!msg || typeof msg !== 'object') return;
+ const userId = msg.userId;
+ if (typeof userId !== 'string' || userId.length === 0) {
+ send(ws, { type: 'auth-error', message: 'missing userId' });
+ ws.close();
+ return;
+ }
+ if (msg.type === 'hello' && !ws.role) {
+ if (msg.role === 'dsh') handleDshHello(userId, ws, msg);
+ else handleClientHello(userId, ws, msg);
+ return;
+ }
+ if (ws.role === 'dsh') {
+ handleHarnessMessage(userId, ws, msg);
+ } else if (ws.role === 'client') {
+ handleClientMessage(userId, ws, msg);
+ } else {
+ send(ws, { type: 'auth-error', message: 'hello required' });
+ ws.close();
+ }
+}
+
+function handleConnection(ws, req) {
+ const url = new URL(req.url || '/', 'http://localhost');
+ if (url.pathname !== '/ws' && url.pathname !== '/') {
+ ws.close(4001, 'unknown path');
+ return;
+ }
+ ws.isAlive = true;
+ ws.on('pong', () => { ws.isAlive = true; });
+ ws.on('message', (raw) => {
+ try { handleMessage(ws, raw); } catch (err) {
+ send(ws, { type: 'error', message: String(err && err.message || err) });
+ ws.close();
+ }
+ });
+ ws.on('close', () => {
+ const userId = userIdOf(ws);
+ if (userId) {
+ if (ws.role === 'dsh') unregisterHarness(userId, ws);
+ else {
+ const set = clients.get(userId);
+ if (set) { set.delete(ws); if (set.size === 0) clients.delete(userId); }
+ }
+ for (const [id, req] of pending) {
+ if (req.clientWs === ws) {
+ clearTimeout(req.timer);
+ pending.delete(id);
+ }
+ }
+ }
+ wsUserId.delete(ws);
+ });
+ ws.on('error', () => {});
+}
+
+const server = createServer((req, res) => {
+ const url = new URL(req.url || '/', 'http://localhost');
+ if (url.pathname === '/health') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ ok: true,
+ harnesses: Array.from(harnesses.entries()).map(([userId]) => ({ userId })),
+ clients: Array.from(clients.entries()).map(([userId, set]) => ({ userId, count: set.size }))
+ }));
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
+ res.end('DSHWatch relay broker running. Connect clients to /ws\n');
+});
+
+const wss = new WebSocketServer({ server });
+wss.on('connection', handleConnection);
+
+setInterval(() => {
+ for (const ws of wss.clients) {
+ if (ws.isAlive === false) { ws.terminate(); continue; }
+ ws.isAlive = false;
+ ws.ping();
+ }
+}, config.heartbeatMs);
+
+server.listen(config.port, config.host, () => {
+ console.log(String('[dshwatch-server] listening on ws://' + config.host + ':' + config.port + '/ws'));
+ console.log(String('[dshwatch-server] health: http://' + config.host + ':' + config.port + '/health'));
+});