dsh-plugin-mobile-gateway 0.6.9 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PROTOCOL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.9)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.7.0)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -77,7 +77,7 @@ const pairingText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base6
77
77
  ```json
78
78
  { "kind": "paired", "token": "<长期设备 token>",
79
79
  "device": { "id": "...", "name": "iPhone", "createdAt": 1787111700000 } }
80
- { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"], "authenticated": true,
80
+ { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"], "authenticated": true,
81
81
  "device": { "id": "...", "name": "iPhone" }, "port": 3080, "clients": 1 }
82
82
  ```
83
83
 
@@ -546,6 +546,8 @@ let image = [
546
546
  | `search` | `query` | 会话全文搜索 |
547
547
  | `session-stats` | `sessionId` | 执行统计投影(输入框统计条数据源) |
548
548
  | `context-usage` | `sessionId` | token 用量 + 上下文占用投影 |
549
+ | `tasks` | `sessionId` | 当前任务列表(`todos` projection) |
550
+ | `goal` | `sessionId` | 当前目标及其 CAS 版本(`goal` projection) |
549
551
 
550
552
  ### `history` 详细
551
553
  ```json
@@ -659,7 +661,93 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
659
661
 
660
662
  ---
661
663
 
662
- ## 6. 工作区与目录
664
+ ## 6. 任务列表与目标
665
+
666
+ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos` 和 `goal` session projection。移动端进入会话后应请求 `tasks` 与 `goal` 取得基线;随后以 `tasks-updated` / `goal-updated` 实时更新 UI。
667
+
668
+ ### 任务列表
669
+
670
+ ```json
671
+ { "type": "tasks", "sessionId": "session-abc" }
672
+ → {
673
+ "kind": "tasks",
674
+ "sessionId": "session-abc",
675
+ "asOfSeq": 42,
676
+ "todos": [
677
+ { "content": "检查 Android SDK", "status": "completed" },
678
+ { "content": "创建项目", "status": "in_progress" },
679
+ { "content": "构建 APK", "status": "pending" }
680
+ ]
681
+ }
682
+ ```
683
+
684
+ - `todos: null` 表示该会话尚未写入过任务列表;客户端可隐藏任务卡片。
685
+ - 任务由 Agent 的 `todo_write` 更新;移动端只读展示,不能直接改写。
686
+
687
+ ### 当前目标
688
+
689
+ ```json
690
+ { "type": "goal", "sessionId": "session-abc" }
691
+ → {
692
+ "kind": "goal",
693
+ "sessionId": "session-abc",
694
+ "asOfSeq": 42,
695
+ "goal": {
696
+ "goal": {
697
+ "id": "goal-opaque-id",
698
+ "revision": 7,
699
+ "objective": "初始化一个 Android app",
700
+ "phase": "active",
701
+ "maxGoalRounds": 12
702
+ },
703
+ "roundsStarted": 3,
704
+ "createdAt": 1787111700000,
705
+ "updatedAt": 1787111800000
706
+ }
707
+ }
708
+ ```
709
+
710
+ `goal: null` 表示没有当前目标。所有目标写操作必须携带刚读取到的 `{id, revision}`。这是 DSH 的 compare-and-set 保护:当 WebUI 或另一台设备已经修改目标时,宿主拒绝陈旧 revision,移动端应重新请求 `goal` 后再提示用户重试。
711
+
712
+ | type | 参数 | 说明 |
713
+ |---|---|---|
714
+ | `goal-edit` | `sessionId`, `ref`, `objective?`, `maxGoalRounds?` | 修改目标名称(`objective`)或轮数上限,至少提供一项 |
715
+ | `goal-pause` | `sessionId`, `ref` | 暂停当前目标 |
716
+ | `goal-resume` | `sessionId`, `ref` | 继续已暂停/阻塞的目标 |
717
+ | `goal-clear` | `sessionId`, `ref` | 删除当前目标 |
718
+
719
+ 更改目标名称:
720
+
721
+ ```json
722
+ { "type": "goal-edit", "sessionId": "session-abc",
723
+ "ref": { "id": "goal-opaque-id", "revision": 7 },
724
+ "objective": "完成 Android app 初始化" }
725
+ → { "kind": "goal-edit", "sessionId": "session-abc",
726
+ "ref": { "id": "goal-opaque-id", "revision": 8 } }
727
+ ```
728
+
729
+ 暂停、继续和删除只替换 `type`:
730
+
731
+ ```json
732
+ { "type": "goal-pause", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 8 } }
733
+ → { "kind": "goal-pause", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 9 } }
734
+
735
+ { "type": "goal-clear", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 9 } }
736
+ → { "kind": "goal-clear", "sessionId": "session-abc", "cleared": true }
737
+ ```
738
+
739
+ ### 实时更新
740
+
741
+ ```json
742
+ { "kind": "tasks-updated", "sessionId": "session-abc", "asOfSeq": 43, "todos": [ ... ] }
743
+ { "kind": "goal-updated", "sessionId": "session-abc", "asOfSeq": 44, "goal": { ... } }
744
+ ```
745
+
746
+ 服务端只转发 `todos` 与 `goal` projection;客户端按 `asOfSeq` 做高序号覆盖,避免较早推送回写较新的查询结果。
747
+
748
+ ---
749
+
750
+ ## 7. 工作区与目录
663
751
 
664
752
  | type | 参数 | 说明 |
665
753
  |---|---|---|
@@ -689,7 +777,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
689
777
 
690
778
  ---
691
779
 
692
- ## 7. 模型与思考等级
780
+ ## 8. 模型与思考等级
693
781
 
694
782
  | type | 参数 | 说明 |
695
783
  |---|---|---|
@@ -721,7 +809,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
721
809
 
722
810
  ---
723
811
 
724
- ## 8. 权限控制
812
+ ## 9. 权限控制
725
813
 
726
814
  | type | 参数 | 说明 |
727
815
  |---|---|---|
@@ -736,7 +824,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
736
824
 
737
825
  ---
738
826
 
739
- ## 9. 新会话默认配置
827
+ ## 10. 新会话默认配置
740
828
 
741
829
  | type | 参数 | 说明 |
742
830
  |---|---|---|
@@ -754,7 +842,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
754
842
 
755
843
  ---
756
844
 
757
- ## 10. 分支(fork)
845
+ ## 11. 分支(fork)
758
846
 
759
847
  ```json
760
848
  { "type": "fork", "sessionId": "session-abc", "atSeq": 42 }
@@ -765,7 +853,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
765
853
 
766
854
  ---
767
855
 
768
- ## 11. 宿主信息
856
+ ## 12. 宿主信息
769
857
 
770
858
  | type | 返回 |
771
859
  |---|---|
@@ -779,13 +867,14 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
779
867
 
780
868
  ---
781
869
 
782
- ## 12. 服务端主动推送
870
+ ## 13. 服务端主动推送
783
871
 
784
872
  | kind | 触发时机 |
785
873
  |---|---|
786
874
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
787
- | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
875
+ | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","commands","tasks","goals","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
788
876
  | `event` | 任意会话的 agent 输出(见下) |
877
+ | `tasks-updated` / `goal-updated` | 当前会话的任务列表或目标 projection 发生变化 |
789
878
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
790
879
  | `approval-requested` / `approval-resolved` | Human-in-the-loop 操作审批请求与最终状态 |
791
880
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
@@ -805,7 +894,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
805
894
 
806
895
  ---
807
896
 
808
- ## 13. 端到端示例(Postman)
897
+ ## 14. 端到端示例(Postman)
809
898
 
810
899
  1. Connect → 收到 `hello`
811
900
  2. `{"type":"sessions"}` → 挑 `sessionId`(或直接下一步自动建)
@@ -818,7 +907,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
818
907
 
819
908
  ---
820
909
 
821
- ## 14. 安全注意
910
+ ## 15. 安全注意
822
911
 
823
912
  - `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
824
913
  - 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
@@ -833,7 +922,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
833
922
 
834
923
  ---
835
924
 
836
- ## 15. 版本历史(插件)
925
+ ## 16. 版本历史(插件)
837
926
 
838
927
  | 版本 | 新增 |
839
928
  |---|---|
@@ -859,6 +948,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
859
948
  | v0.6.7 | 订阅已有 Session 时重放待处理 Human-in-the-loop 请求,并增加 Approval 端到端诊断日志与安装版本标记 |
860
949
  | v0.6.8 | 会话工作目录受限的文件列表与分块下载:支持图片、文档、IPA、APK 等普通文件,含连接归属、路径越界防护、取消、超时和 SHA-256 完整性校验 |
861
950
  | v0.6.9 | 服务端驱动的命令与技能目录:支持本地化 Hint、通用二级选项、专用命令执行,以及 command/compaction 生命周期事件;Host 命令不再作为用户 Prompt 发送 |
951
+ | v0.7.0 | 任务与 Goal 对齐:任务/Goal 基线查询、`todos`/`goal` 实时投影、Goal 改名、暂停、继续与删除 |
862
952
 
863
953
  ---
864
954
 
package/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  # dsh-plugin-mobile-gateway
6
6
 
7
- DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
7
+ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
8
+
9
+ > v0.7.0:移动端现可与 WebUI 对齐展示任务列表和当前 Goal;支持 Goal 改名、暂停、继续和删除,并通过实时 projection 与 revision 校验处理多设备并发更新。
8
10
 
9
11
  - WebSocket:`/ws/mobile`
10
12
  - 局域网:`ws://<局域网 IP>:3081/ws/mobile`
package/lib/index.mjs CHANGED
@@ -57,6 +57,10 @@
57
57
  // { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
58
58
  // { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
59
59
  // (the input-box stats strip source)
60
+ // { "type": "tasks", "sessionId" } -> current todo-list projection
61
+ // { "type": "goal", "sessionId" } -> current goal projection
62
+ // { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
63
+ // { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
60
64
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
61
65
  // { "type": "defaults" } -> default agent preset + default permission
62
66
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
@@ -64,7 +68,7 @@
64
68
  // { "type": "question-cancel", "rpcId", "sessionId" }
65
69
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
66
70
  // "outcome": "allowed-once"|"rejected" }
67
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "file-downloads"],
71
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"],
68
72
  // "authenticated", "device"?, "port", "clients" }
69
73
  // { "kind": "pong", "at" }
70
74
  // { "kind": "subscribed", "sessionId" }
@@ -86,6 +90,12 @@
86
90
  // { "kind": "file-download-cancelled", "transferId" }
87
91
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
88
92
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
93
+ // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
94
+ // { "kind": "goal", "sessionId", "asOfSeq", "goal" }
95
+ // { "kind": "goal-edit"|"goal-pause"|"goal-resume", "sessionId", "ref" }
96
+ // { "kind": "goal-clear", "sessionId", "cleared": true }
97
+ // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
98
+ // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
89
99
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
90
100
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
91
101
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
@@ -958,6 +968,54 @@ function requireSessionId(msg) {
958
968
  return { value: sessionId }
959
969
  }
960
970
 
971
+ // Goal mutations use DSH's compare-and-set reference. Returning a stale
972
+ // revision is intentionally rejected by DSH instead of overwriting a newer
973
+ // WebUI/mobile update.
974
+ function requireGoalRef(msg, sessionId) {
975
+ const ref = msg.ref
976
+ const id = ref && typeof ref.id === 'string' && ref.id.trim() !== '' ? ref.id.trim() : null
977
+ const revision = ref && typeof ref.revision === 'number' && Number.isSafeInteger(ref.revision) && ref.revision > 0
978
+ ? ref.revision
979
+ : null
980
+ if (!id || revision === null) {
981
+ return {
982
+ error: {
983
+ kind: 'error',
984
+ code: 'bad-request',
985
+ message: `${typeof msg.type === 'string' ? msg.type : 'goal mutation'} requires ref.id and a positive integer ref.revision`,
986
+ requestType: typeof msg.type === 'string' ? msg.type : 'goal',
987
+ sessionId,
988
+ },
989
+ }
990
+ }
991
+ return { value: { id, revision } }
992
+ }
993
+
994
+ async function readSessionProjection(api, type, sessionId, key) {
995
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
996
+ if (history.kind !== 'history') return history
997
+ const values = (history.projections && history.projections.values) || {}
998
+ return {
999
+ kind: type,
1000
+ sessionId,
1001
+ asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
1002
+ [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
1003
+ }
1004
+ }
1005
+
1006
+ async function mutateGoal(api, type, method, payload) {
1007
+ if (!api.goals || typeof api.goals[method] !== 'function') {
1008
+ return {
1009
+ kind: 'error',
1010
+ code: 'unsupported',
1011
+ message: 'goal management is unavailable in this DSH host',
1012
+ requestType: type,
1013
+ sessionId: payload.sessionId,
1014
+ }
1015
+ }
1016
+ return proxyQuery(api, type, api.goals[method].bind(api.goals), payload)
1017
+ }
1018
+
961
1019
  // ---------------------------------------------------------------------------
962
1020
  // History frame sizing: raw session logs can be huge (trajectory, tool output,
963
1021
  // context), so a single history response must never blow the client's
@@ -1515,6 +1573,39 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1515
1573
  contextPressure: values.contextPressure || null,
1516
1574
  }
1517
1575
  }
1576
+ if (msg.type === 'tasks') {
1577
+ const sessionId = requireSessionId(msg)
1578
+ if (sessionId.error) return sessionId.error
1579
+ return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1580
+ }
1581
+ if (msg.type === 'goal') {
1582
+ const sessionId = requireSessionId(msg)
1583
+ if (sessionId.error) return sessionId.error
1584
+ return readSessionProjection(api, 'goal', sessionId.value, 'goal')
1585
+ }
1586
+ if (msg.type === 'goal-edit') {
1587
+ const sessionId = requireSessionId(msg)
1588
+ if (sessionId.error) return sessionId.error
1589
+ const ref = requireGoalRef(msg, sessionId.value)
1590
+ if (ref.error) return ref.error
1591
+ const payload = { sessionId: sessionId.value, ref: ref.value }
1592
+ if (typeof msg.objective === 'string' && msg.objective.trim() !== '') payload.objective = msg.objective.trim()
1593
+ if (typeof msg.maxGoalRounds === 'number' && Number.isSafeInteger(msg.maxGoalRounds) && msg.maxGoalRounds > 0) {
1594
+ payload.maxGoalRounds = msg.maxGoalRounds
1595
+ }
1596
+ if (payload.objective === undefined && payload.maxGoalRounds === undefined) {
1597
+ return { kind: 'error', code: 'bad-request', message: 'goal-edit requires a non-empty objective or a positive maxGoalRounds', requestType: 'goal-edit', sessionId: sessionId.value }
1598
+ }
1599
+ return mutateGoal(api, 'goal-edit', 'edit', payload)
1600
+ }
1601
+ if (msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
1602
+ const sessionId = requireSessionId(msg)
1603
+ if (sessionId.error) return sessionId.error
1604
+ const ref = requireGoalRef(msg, sessionId.value)
1605
+ if (ref.error) return ref.error
1606
+ const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1607
+ return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1608
+ }
1518
1609
  if (msg.type === 'agent-presets') {
1519
1610
  return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1520
1611
  }
@@ -2263,7 +2354,9 @@ const plugin = {
2263
2354
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2264
2355
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2265
2356
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2266
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers') {
2357
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2358
+ msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2359
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2267
2360
  handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
2268
2361
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
2269
2362
  })
@@ -2292,7 +2385,7 @@ const plugin = {
2292
2385
  ws.send(JSON.stringify({
2293
2386
  kind: 'hello',
2294
2387
  protocol: 3,
2295
- capabilities: ['images', 'commands', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2388
+ capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2296
2389
  port: transport.port || webServer.port,
2297
2390
  clients: clients.size,
2298
2391
  authenticated: !!device,
@@ -2424,6 +2517,17 @@ const plugin = {
2424
2517
  outcome: payload.outcome,
2425
2518
  })
2426
2519
  log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2520
+ } else if (payload.type === 'session/projection' && (payload.key === 'todos' || payload.key === 'goal')) {
2521
+ const sessionId = String(payload.sessionId)
2522
+ const kind = payload.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2523
+ const valueKey = payload.key === 'todos' ? 'todos' : 'goal'
2524
+ broadcastInteractionFrame({
2525
+ kind,
2526
+ sessionId,
2527
+ asOfSeq: payload.seq,
2528
+ [valueKey]: payload.value,
2529
+ })
2530
+ log(`projection forwarded: key=${payload.key} session=${sessionId} seq=${payload.seq}`)
2427
2531
  } else {
2428
2532
  log(`interaction ignored: type=${payload.type}`)
2429
2533
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.6.9",
4
- "description": "Authenticated mobile gateway for DeepSeek Harness with server-driven commands, skills, approvals, and file transfers",
3
+ "version": "0.7.0",
4
+ "description": "Secure mobile gateway for DeepSeek Harness with realtime sessions, tasks, goals, commands, approvals, and file transfers",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",