dsh-plugin-mobile-gateway 0.7.1 → 0.7.2

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.7.1)。
3
+ 移动端通过经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.7.2)。
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", "commands", "tasks", "goals", "file-downloads"], "authenticated": true,
80
+ { "kind": "hello", "protocol": 3, "capabilities": ["split-channels", "images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"], "authenticated": true,
81
81
  "device": { "id": "...", "name": "iPhone" }, "port": 3080, "clients": 1 }
82
82
  ```
83
83
 
@@ -530,6 +530,73 @@ let image = [
530
530
  → { "kind": "sent", "sessionId": "session-abc", "mode": "queue", "command": { "kind": "success", "text": "..." } } // 斜杠命令时
531
531
  ```
532
532
 
533
+ ### 排队消息同步与修改
534
+
535
+ `hello.capabilities` 包含 `queue-control` 时,控制连接会收到 Host 当前待处理消息。连接初始化或 Host 控制流重连后,网关发送完整快照:
536
+
537
+ ```json
538
+ {
539
+ "kind": "session-queues",
540
+ "queues": {
541
+ "session-abc": [
542
+ {
543
+ "id": "message-1",
544
+ "placement": "queued",
545
+ "rpcId": "prompt-1",
546
+ "message": {
547
+ "id": "message-1",
548
+ "content": [{ "type": "text", "text": "稍后处理这件事" }]
549
+ }
550
+ }
551
+ ]
552
+ }
553
+ }
554
+ ```
555
+
556
+ 之后某个 Session 的队列发生变化时,网关发送该 Session 的完整替换值:
557
+
558
+ ```json
559
+ { "kind": "session-queue", "sessionId": "session-abc", "items": [] }
560
+ ```
561
+
562
+ - `session-queues.queues` 是全量快照,客户端必须整体替换所有 Session 的本地队列;快照里消失的 Session 也要清除。
563
+ - `session-queue.items` 是单个 Session 的全量队列,不能当作追加事件;空数组表示已无待处理消息。
564
+ - `id` 是后续编辑操作使用的 `itemId`。`placement` 为 `queued`(下一回合)、`steering`(当前回合最近的下一步)或 `context`(系统上下文)。
565
+ - `rpcId` 是 Host 的 Prompt 提交标识,网关会原样转发且它可能不存在。当前 `sent` 回执尚未把该值返回给移动端,因此“发送中本地回显与队列项的精确关联”仍是待实现能力。
566
+
567
+ 编辑排队文本:
568
+
569
+ ```json
570
+ { "type": "queue-update", "sessionId": "session-abc", "itemId": "message-1",
571
+ "action": "edit", "text": "修改后的消息" }
572
+ → { "kind": "queue-item-updated", "sessionId": "session-abc", "itemId": "message-1",
573
+ "action": "edit", "accepted": true }
574
+ ```
575
+
576
+ 删除排队项或将下一回合消息立即插入当前回答:
577
+
578
+ ```json
579
+ { "type": "queue-update", "sessionId": "session-abc", "itemId": "message-1", "action": "remove" }
580
+ { "type": "queue-update", "sessionId": "session-abc", "itemId": "message-1", "action": "steer" }
581
+ ```
582
+
583
+ `queue-item-updated` 只是 Host 已提交操作的回执,不是队列最终状态。App 应始终以最新的 `session-queue` 更新界面;该推送可能在回执之前或之后到达。编辑只接受非空文本;`steer` 只适用于仍处于 `queued` 且 Session 正在运行的消息。目标已经开始处理或消失时,Host 返回 `session/queue-item-not-found`;当前回合已不能插入时返回 `session/steer-unavailable`。
584
+
585
+ ### 停止当前生成与稍后继续
586
+
587
+ 停止当前 Session 正在执行的 Agent 回合:
588
+
589
+ ```json
590
+ { "type": "session-cancel", "sessionId": "session-abc" }
591
+ → { "kind": "session-cancelled", "sessionId": "session-abc", "accepted": true }
592
+ ```
593
+
594
+ - `accepted: true` 表示 Host 已接受取消请求;最终停止状态仍以该 Session 后续实时事件和 `sessions.running` 为准。
595
+ - 取消只停止当前回合,不会删除 Session 历史,也不会清空已经进入 Host inbox 的待处理消息。
596
+ - 稍后继续不需要专用恢复请求。客户端等待 Session 停止后,使用相同 `sessionId` 发送普通 `message` 即可继续已有上下文。
597
+ - 如果 Session 不存在、未附加或属于不能由普通 Session API 控制的子 Agent,服务端返回对应 Host 错误。
598
+ - `session-cancel` 与 `question-cancel` 不同:前者停止整个 Agent 回合,后者只取消一次 `ask_user_question`。
599
+
533
600
  ---
534
601
 
535
602
  ## 5. 会话与历史查询
@@ -537,6 +604,8 @@ let image = [
537
604
  | type | 参数 | 说明 |
538
605
  |---|---|---|
539
606
  | `sessions` | — | 会话列表(`updatedAt/running/blank/cwd/agentPreset`) |
607
+ | `session-archive` | `sessionId` | 将 Session 加入 Host 的完整归档集合(隐藏但不删除) |
608
+ | `session-rename` | `sessionId`, `title` | 写入用户指定的持久化 Session 名称 |
540
609
  | `history` | `sessionId`, `beforeSeq?`, `maxMessages?`, `maxBytes?`, `view?` | 历史事件页(见下) |
541
610
  | `attachment` | `sessionId`, `attachmentId` | 读取历史中属于该会话的图片字节 |
542
611
  | `file-list` | `sessionId`, `path?`, `requestId?` | 列出会话工作目录内的一层文件与文件夹 |
@@ -549,6 +618,41 @@ let image = [
549
618
  | `tasks` | `sessionId` | 当前任务列表(`todos` projection) |
550
619
  | `goal` | `sessionId` | 当前目标及其 CAS 版本(`goal` projection) |
551
620
 
621
+ ### Session 归档与重命名
622
+
623
+ 在 App 端归档 Session:
624
+
625
+ ```json
626
+ { "type": "session-archive", "sessionId": "session-abc" }
627
+ → { "kind": "session-archived", "sessionId": "session-abc",
628
+ "archivedSessionIds": ["session-abc", "session-old"] }
629
+ ```
630
+
631
+ 归档仅将 Session 从 Workspace 分组界面隐藏,不会删除历史。`archivedSessionIds` 始终是 Host 确认后的**完整归档集合**,客户端应使用它整体替换本地集合,而不是只追加本次 `sessionId`。
632
+
633
+ 在 App 端重命名 Session:
634
+
635
+ ```json
636
+ { "type": "session-rename", "sessionId": "session-abc", "title": "新的会话名称" }
637
+ → { "kind": "session-renamed", "sessionId": "session-abc",
638
+ "title": "新的会话名称", "seq": 128 }
639
+ ```
640
+
641
+ Host 会规范化并持久化名称;空白名称返回 `bad-request`,超过 Host 限制或规范化后无效的名称返回 Host 的 `session/title-invalid` 错误。
642
+
643
+ WebUI、App 或其他客户端造成的变化通过以下帧主动推送:
644
+
645
+ ```json
646
+ { "kind": "session-archives", "archivedSessionIds": ["session-abc"] }
647
+ { "kind": "session-title-changed", "sessionId": "session-abc",
648
+ "title": "WebUI 修改后的名称", "seq": 129, "time": 1786937352,
649
+ "source": { "kind": "user" } }
650
+ ```
651
+
652
+ - `session-archives` 在网关取得 opening baseline 后缓存;新设备连接时会收到当前完整集合,之后每次 WebUI 归档都会收到新的完整集合。
653
+ - `session-title-changed` 是 Session 列表级元数据通知,即使客户端正在 `subscribe` 另一个 Session 也会收到。
654
+ - 同一名称变化仍会作为带序号的普通 `event` 帧发给订阅该 Session 的客户端;客户端可按 `seq` 幂等处理。
655
+
552
656
  ### `history` 详细
553
657
  ```json
554
658
  { "type": "history", "sessionId": "session-abc", "maxMessages": 60, "maxBytes": 4194304, "view": "conversation" }
@@ -872,8 +976,11 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
872
976
  | kind | 触发时机 |
873
977
  |---|---|
874
978
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
875
- | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","commands","tasks","goals","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
979
+ | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["split-channels","images","commands","tasks","goals","session-cancel","queue-control","session-archive","session-rename","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
876
980
  | `event` | 任意会话的 agent 输出(见下) |
981
+ | `session-queues` / `session-queue` | Host 队列完整快照 / 单个 Session 队列替换值 |
982
+ | `session-archives` | Host 的完整 Session 归档集合在连接初始化或 WebUI/App 归档后变化 |
983
+ | `session-title-changed` | 任意客户端写入新的持久化 Session 名称 |
877
984
  | `tasks-updated` / `goal-updated` | 当前会话的任务列表或目标 projection 发生变化 |
878
985
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
879
986
  | `approval-requested` / `approval-resolved` | Human-in-the-loop 操作审批请求与最终状态 |
@@ -888,6 +995,7 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
888
995
  - `user/message` → `{text, source, images?: ImageAttachmentRef[]}`
889
996
  - `assistant/chunk` → `{turn, step, chunkType: text-delta|reasoning-delta|tool-call-delta|usage|finish, text?/tool?/usage?/finish?}`
890
997
  - `assistant/message` → `{turn, step, text, reasoning, toolCalls[]}`
998
+ - `session/title` → `{title, source?}`
891
999
  - `tool/call` → `{turn, step, callId, name, arguments}`
892
1000
  - `tool/result` → `{turn, step, callId, isError, preview(≤400字符)}`
893
1001
  - `turn/start|end` / `step/start|end` → `{turn, step, reason?}`
@@ -926,6 +1034,7 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
926
1034
 
927
1035
  | 版本 | 新增 |
928
1036
  |---|---|
1037
+ | v0.7.2 | 独立对话/控制连接;空 Session 创建;停止生成与稍后继续;排队消息同步及编辑/删除/Steer;App 归档/重命名 Session;WebUI 归档集合和名称变化实时同步到 App |
929
1038
  | v0.1.5 | workspace-create / directories / host |
930
1039
  | v0.1.6 | 修复消息分发器遗漏(host/directories/workspace-create 未路由) |
931
1040
  | v0.1.7 | models / select-model / permission-options / permission / context-usage |
@@ -954,3 +1063,37 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
954
1063
  ---
955
1064
 
956
1065
  *协议与插件源码同源维护:`dsh-plugin-mobile-gateway/lib/index.mjs` 顶部注释即协议摘要。*
1066
+
1067
+ ### 提前创建空会话
1068
+
1069
+ `hello.capabilities` 中的 `session-create` 表示支持发送首条消息前创建会话。
1070
+ 客户端发送 `{"type":"session-create","requestId":"unique-id","workspaceId":"w1"}`,
1071
+ 收到 `{"kind":"session-created","requestId":"unique-id","sessionId":"..."}` 后,
1072
+ 即可使用现有的命令目录、模型和权限接口;该操作不会调用 prompt 或启动 Agent。
1073
+ `workspaceId` 与 `cwd` 均可省略,同时提供时优先使用 `workspaceId`。
1074
+ 失败返回 `kind:error`、`requestType:session-create` 和原始 `requestId`。
1075
+ 客户端不应自动重试超时的创建请求,以免重复创建会话。
1076
+
1077
+
1078
+ ### Independent conversation and control connections
1079
+
1080
+ A gateway advertising `split-channels` in `hello.capabilities` accepts the optional
1081
+ `X-DSH-Channel` upgrade header (`control` or `conversation`). Without this header,
1082
+ the connection keeps the legacy behavior. Clients must wait for the control hello
1083
+ capability before opening a second connection. Pair only on control; reuse the
1084
+ returned device token and device ID for conversation, never reuse a pairing code.
1085
+
1086
+ - Conversation: `message`, `history`, `subscribe`, `unsubscribe`; receives their
1087
+ replies and subscribed session events. History and live events share this lane
1088
+ to preserve their ordering.
1089
+ - Control: all other requests, including file downloads, rename/archive, session
1090
+ cancellation, permissions, questions and approvals. Global metadata and pending
1091
+ interactions are delivered only to this connection.
1092
+ - `ping` is accepted on either connection. A request on the wrong lane receives
1093
+ `error` with code `wrong-channel` and `requestType`.
1094
+
1095
+ Each connection has its own WebSocket send queue. A slow conversation receiver
1096
+ does not put file responses behind conversation frames. This isolates socket
1097
+ queues; the underlying network link and Host resources remain shared. On connection
1098
+ failure clients reconnect and authenticate both lanes, then resubscribe and catch up
1099
+ history. Older gateways without the capability continue using one connection.
package/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  # dsh-plugin-mobile-gateway
6
6
 
7
- DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
7
+ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排队消息同步及编辑/删除/Steer、Session 归档和重命名的双向同步、停止当前生成并稍后继续、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
8
8
 
9
- > v0.7.1:适配 DSH v0.1.2-rc.1 Remote Gateway,移除 APIProxy 依赖;移动端协议和现有功能保持兼容。
9
+ > v0.7.2:新增独立对话/控制连接、空 Session 创建、停止生成与稍后继续、排队消息同步及编辑/删除/Steer,以及 Session 归档和重命名的双向同步;兼容 DSH v0.1.2-rc.1 与 v0.1.3-alpha.1。
10
10
 
11
11
  ## 协议与 DSH 兼容层
12
12
 
@@ -0,0 +1,296 @@
1
+ # Typert Remote Gateway 功能清单
2
+
3
+ 本文用于持续跟踪 `dsh-plugin-mobile-gateway` 对 DeepSeek Harness 官方 Typert Remote Gateway 能力的适配情况。
4
+
5
+ - `[x]`:移动网关已经实现,并有对应测试或现有协议文档覆盖。
6
+ - `[ ]`:尚未实现,或当前实现不足以兼容标注的 Host 版本。
7
+ - 对于部分支持的能力,拆成已完成项和待完成子项,不使用含糊的“部分完成”标记。
8
+ - 功能只有在代码、测试和必要协议文档均完成后才能勾选。
9
+ - 每次实现本清单中的功能时,应在同一次变更中更新本文件。
10
+
11
+ ## 版本基线与兼容性
12
+
13
+ - [x] 支持 DSH `0.1.2-rc.1` 的 Typert Remote 基础契约。
14
+ - [x] 使用独立 Host Adapter 隔离 DSH Remote namespace、method 和严格参数名。
15
+ - [x] 保持移动端 `dsh-mobile-v1`、`hello.protocol = 3` 和现有字段兼容。
16
+ - [ ] 支持 DSH `0.1.3-alpha.1` 的 Typert Remote 契约。
17
+ - [ ] 同时兼容 `0.1.2-rc.1` 与 `0.1.3-alpha.1`,不要求移动端同步升级。
18
+ - [ ] 增加 Host Remote 能力探测并在进程生命周期内缓存探测结果。
19
+ - [ ] 为 `0.1.3-alpha.1` 建立独立的 fake gateway 契约测试。
20
+ - [ ] 建立 RC1 与 Alpha1 双版本回归测试矩阵。
21
+
22
+ ## 连接、鉴权与通用传输
23
+
24
+ - [x] 提供 `/ws/mobile` WebSocket 入口。
25
+ - [x] 支持本机、独立局域网监听和 TLS 反向代理公网入口。
26
+ - [x] 支持一次性配对码、长期设备 Token 和设备吊销。
27
+ - [x] 局域网监听强制设备鉴权。
28
+ - [x] 支持 `ping` / `pong` 心跳。
29
+ - [x] 支持按 Session 订阅和取消订阅实时事件。
30
+ - [x] 支持统一移动端错误帧。
31
+ - [ ] 为普通 RPC 增加移动端请求 ID 与响应关联。
32
+ - [ ] 支持取消单个正在执行的普通 RPC。
33
+ - [ ] 在移动连接断开时取消该连接拥有的未完成查询、命令和流。
34
+ - [ ] 将 Typert 的结构化错误类别完整映射为稳定的移动端错误码与详情。
35
+
36
+ ## Session 生命周期与查询
37
+
38
+ - [x] 列出 Session。
39
+ - [x] 搜索 Session 内容。
40
+ - [x] 使用 Workspace 创建 Session。
41
+ - [x] 使用 `cwd` 创建 Session。
42
+ - [x] 创建 Session 后发送第一条消息。
43
+ - [ ] 创建 Session 时显式选择 `agentPreset`。
44
+ - [ ] 创建或采用客户端预分配的 `sessionId`。
45
+ - [x] 在移动端重命名 Session。
46
+ - [x] Fork Session。
47
+ - [x] 支持指定 `atSeq` Fork。
48
+ - [ ] 在 Host 桌面打开 Session Workspace 路径。
49
+ - [x] 查询 Host 是否具备打开 Workspace 路径的能力。
50
+
51
+ ## 消息、队列与运行控制
52
+
53
+ - [x] 发送文本 Prompt。
54
+ - [x] 发送纯图片 Prompt。
55
+ - [x] 发送图文 Prompt。
56
+ - [x] 支持 `queue` 投递模式。
57
+ - [x] 支持 `steer` 投递模式。
58
+ - [x] 为 Host Prompt 生成唯一 `requestId`。
59
+ - [x] 取消当前正在执行的 Agent 回合,同时保留待处理队列。
60
+ - [x] 取消后使用相同 `sessionId` 发送新消息继续已有会话。
61
+ - [ ] 向移动端返回并持久关联 Prompt `requestId`。
62
+ - [x] 显示 Session 当前排队消息。
63
+ - [x] 编辑一条尚未处理的排队消息。
64
+ - [x] 删除一条尚未处理的排队消息。
65
+ - [x] 将指定排队消息转换为 Steer。
66
+ - [ ] 显示 Session 后台 Job 列表与运行状态。
67
+
68
+ ## 实时会话流与状态同步
69
+
70
+ - [x] 在 `0.1.2-rc.1` 上通过 Host `session/event` 转发实时 SessionEvent。
71
+ - [x] 转发 `todos` projection 的增量更新。
72
+ - [x] 转发 `goal` projection 的增量更新。
73
+ - [x] 将 WebUI 或其他客户端写入的 `session/title` 变化实时通知移动端。
74
+ - [x] 将 Workspace 归档集合变化实时通知移动端。
75
+ - [ ] 在 `0.1.3-alpha.1` 上使用 `session.follow({ assistantStream: true })` 接收实时 Assistant 流。
76
+ - [ ] 将 `assistant-stream` 的 start/chunk/end 帧转换为现有移动端实时事件。
77
+ - [ ] 处理 Assistant Stream reconnect baseline、revision、index 和 settlement。
78
+ - [ ] 使用 `session.follow` 持续接收 durable event,而不只读取 opening snapshot。
79
+ - [ ] 在断线重连或序号出现缺口时自动补齐 Session 历史。
80
+ - [x] 处理 `session.control` baseline 中的 queues,并在重连时整体替换。
81
+ - [ ] 处理 `session.control` baseline 中的 jobs。
82
+ - [ ] 处理 `session.control` baseline 中除 `todos`、`goal` 外的 projections。
83
+ - [x] 转发 `session.control` 的 queue 更新。
84
+ - [ ] 转发 `session.control` 的 jobs 更新。
85
+ - [ ] 支持除 `todos`、`goal` 之外的通用 projection 更新。
86
+ - [ ] 实时同步 Session 新增事件。
87
+ - [ ] 实时同步 Session 删除事件。
88
+ - [ ] 实时同步 Session running 状态。
89
+ - [ ] 实时同步 Session activity/排序时间。
90
+ - [ ] 实时同步 Session 非持久化错误。
91
+
92
+ ## 历史、统计与附件读取
93
+
94
+ - [x] 读取 Session opening snapshot。
95
+ - [x] 分页读取更早的历史记录。
96
+ - [x] 展开 RC1 `chunkrow/text-chunks` 历史记录。
97
+ - [x] 展开 RC1 `chunkrow/reasoning-chunks` 历史记录。
98
+ - [x] 展开 RC1 `chunkrow/tool-call-chunks` 历史记录。
99
+ - [x] 支持移动端历史帧字节预算。
100
+ - [x] 支持 conversation 裁剪视图。
101
+ - [x] 读取历史图片附件字节。
102
+ - [x] 查询 Session 统计信息。
103
+ - [x] 查询 Token Usage 与 Context Pressure。
104
+ - [ ] 完整适配 Alpha1 的 Assistant attempt 与 compact stream 历史展示。
105
+
106
+ ## Host 命令与技能
107
+
108
+ - [x] 按 Session 列出 Host 命令。
109
+ - [x] 执行 `0.1.2-rc.1` Host 命令。
110
+ - [x] 转发 `command/run` 与 `command/done` 生命周期事件。
111
+ - [x] 提供服务端驱动的命令菜单 UI 描述。
112
+ - [x] 提供模型和权限的通用二级选择菜单。
113
+ - [x] 按 Session 列出用户可调用技能。
114
+ - [x] 区分 Model 可调用技能与仅用户技能。
115
+ - [ ] 适配 Alpha1 `commands.execute.submittedAttachments` 参数。
116
+ - [ ] 同时识别 RC1 `input.images` 与 Alpha1 `input.attachments` 命令描述。
117
+ - [ ] 支持向 Host 命令附加普通文件 receipt。
118
+ - [ ] 实时同步 `commands/change`,使移动端命令目录自动刷新。
119
+
120
+ ## Human-in-the-loop
121
+
122
+ - [x] 转发 `user-questions/request`。
123
+ - [x] 支持单选、多选和自由输入答案。
124
+ - [x] 支持取消整批问题。
125
+ - [x] 校验问题答案的数量、顺序、ID 和选项。
126
+ - [x] 多设备竞争时只接受第一个合法响应。
127
+ - [x] 重放尚未处理的问题。
128
+ - [x] 没有匹配移动连接时回退给 WebUI 或后续 answerer。
129
+ - [x] 转发 `approval/request`。
130
+ - [x] 支持单次允许和拒绝审批。
131
+ - [x] 重放尚未处理的审批。
132
+ - [x] 广播问题和审批的最终状态。
133
+
134
+ ## Workspace 与目录
135
+
136
+ - [x] 读取 Workspace baseline/list。
137
+ - [x] 创建 Workspace。
138
+ - [x] 使用插件本地文件系统实现浏览 Host 目录。
139
+ - [x] 使用插件本地文件系统创建目录。
140
+ - [ ] 重命名 Workspace。
141
+ - [ ] 删除 Workspace 注册记录。
142
+ - [ ] 调整 Workspace 顺序。
143
+ - [ ] 调整一个 Workspace 内的 Session 顺序。
144
+ - [x] 在移动端归档 Session。
145
+ - [x] 持续消费 `workspace.follow` 的 baseline/archived 更新,并缓存完整归档集合供新连接恢复。
146
+ - [ ] 转发 `workspace.follow` 的 upsert/remove/order 更新。
147
+ - [x] 在 Workspace 流重连时用新 baseline 原子替换缓存的归档集合。
148
+ - [ ] 调用 Host 原生 Directory Picker;此项属于低优先级桌面能力。
149
+
150
+ ## 文件上传、下载与引用
151
+
152
+ - [x] 上传 Prompt 图片。
153
+ - [x] 下载历史图片附件。
154
+ - [x] 列出 Session Workspace 内的文件和目录。
155
+ - [x] 分块下载 Session Workspace 内的普通文件。
156
+ - [x] 校验文件下载 offset 和最终 SHA-256。
157
+ - [x] 防止通过绝对路径、`..` 或符号链接逃逸 Workspace。
158
+ - [x] 支持取消文件下载并清理空闲传输。
159
+ - [ ] 通过官方 `fileUploads.upload` 上传普通文件。
160
+ - [ ] 支持普通文件的流式 HTTP 上传。
161
+ - [ ] 使用文件 upload receipt 发送 Prompt。
162
+ - [ ] 使用文件 upload receipt 执行 Host 命令。
163
+ - [ ] 支持 `@文件` / `@目录` 候选补全。
164
+ - [ ] 支持跨 Session 引用候选补全。
165
+ - [ ] 支持发送规范化 `dsh-session:` 引用并显示引用快照状态。
166
+
167
+ ## 模型与 Provider
168
+
169
+ - [x] 获取 Host 模型目录。
170
+ - [x] 获取 Session 当前模型选择。
171
+ - [x] 切换 Session 模型。
172
+ - [x] 切换 Session Reasoning Effort。
173
+ - [x] 读取新 Session 默认模型。
174
+ - [x] 保存新 Session 默认模型。
175
+ - [x] 列出可配置 Provider。
176
+ - [ ] 调用 `llm.listProviders` 获取完整 Provider 运行时信息。
177
+ - [ ] 调用 `llm.discoverModels` 从指定 Provider 配置动态发现模型。
178
+ - [ ] 实时同步 `llm/adapters-updated` 并刷新模型目录。
179
+
180
+ ## 权限与 Settings
181
+
182
+ - [x] 读取 Settings namespace 描述。
183
+ - [x] 使用 `settings.update` 修改 namespace。
184
+ - [x] 读取权限 Preset 列表。
185
+ - [x] 读取 Session 当前权限。
186
+ - [x] 通过官方 `/permission` 命令切换 Session 权限。
187
+ - [x] 读取和修改新 Session 默认权限。
188
+ - [ ] 使用 `settings.replace` 完整替换 namespace section。
189
+ - [ ] 使用 `settings.mutate` 执行路径级 set/unset 操作。
190
+ - [ ] 完整处理 Settings revision conflict 并返回 Host 最新值。
191
+ - [ ] 实时同步 `settings/document-updated`。
192
+ - [ ] 在 Host 打开 Settings 文档;此项属于低优先级桌面能力。
193
+ - [ ] 在 Host 打开 Agent Preset 目录;此项属于低优先级桌面能力。
194
+
195
+ ## Credentials
196
+
197
+ - [ ] 查询脱敏后的 Credential 引用状态。
198
+ - [ ] 设置 Credential 值。
199
+ - [ ] 清除 Credential 值。
200
+ - [ ] 实时同步 `credentials/reference-updated`。
201
+ - [ ] 确保任何 Credential Secret 都不会通过移动端查询接口回传。
202
+
203
+ ## Agent Preset
204
+
205
+ - [x] 列出 Agent Preset 名册。
206
+ - [x] 显示默认 Agent Preset。
207
+ - [x] 修改默认 Agent Preset。
208
+ - [ ] 读取 Preset 文档或插件组成。
209
+ - [ ] 复制可编辑 Preset。
210
+ - [ ] 删除可编辑 Preset。
211
+ - [ ] 为尚未开始的空白 Session 切换 Agent Preset。
212
+ - [ ] 实时同步 `agent-preset/selected`。
213
+
214
+ ## Goal 与任务列表
215
+
216
+ - [x] 读取 `todos` 任务列表。
217
+ - [x] 实时同步 `todos` 更新。
218
+ - [x] 读取当前 Goal 和 CAS revision。
219
+ - [x] 编辑 Goal objective 或 maxGoalRounds。
220
+ - [x] 暂停 Goal。
221
+ - [x] 恢复 Goal。
222
+ - [x] 清除 Goal。
223
+ - [x] 实时同步 Goal 更新。
224
+ - [ ] 直接创建 Goal。
225
+ - [ ] 将 Goal 标记为完成。
226
+
227
+ ## 子 Agent
228
+
229
+ - [ ] 列出直接子 Agent 和后代树。
230
+ - [ ] 显示子 Agent 的模式、运行状态和血缘关系。
231
+ - [ ] 向 continuable 子 Agent 发送后续 Prompt。
232
+ - [ ] 在子 Agent 后续 Prompt 中发送图片。
233
+ - [ ] 从父 Session 中断 continuable 子 Agent。
234
+ - [ ] 订阅和展示子 Agent Session 的实时会话流。
235
+
236
+ ## 消息反馈
237
+
238
+ - [ ] 列出一个 Session 的 Assistant 消息反馈。
239
+ - [ ] 为最终 Assistant 消息添加点赞或点踩。
240
+ - [ ] 为反馈添加备注。
241
+ - [ ] 修改带版本保护的反馈。
242
+ - [ ] 删除带版本保护的反馈。
243
+ - [ ] 处理反馈 version conflict 并向移动端返回当前权威值。
244
+
245
+ ## Host 插件与动态 Cordis
246
+
247
+ - [ ] 查询 Host 插件清单和运行阶段。
248
+ - [ ] 查询 Agent Preset 的插件组成。
249
+ - [ ] 接收动态 Cordis 包运行请求。
250
+ - [ ] 允许或拒绝包含浏览器半包的动态 Cordis 运行。
251
+ - [ ] 停止或撤销动态 Cordis 包。
252
+ - [ ] 获取动态 Cordis Client 代码。
253
+ - [ ] 同步动态包发布和撤销事件。
254
+ - [ ] 支持 Cordis runtime inspect 查询。
255
+ - [ ] 上报动态 UI 渲染或 Client Guard 失败。
256
+
257
+ > 动态 Cordis 和 runtime inspect 强依赖 Web 页面生命周期,移动端优先级较低;在明确产品需求前保留未勾选状态。
258
+
259
+ ## 官方 Remote Event 转发
260
+
261
+ - [x] `approval/request`。
262
+ - [x] `user-questions/request`。
263
+ - [ ] `agent-preset/selected`。
264
+ - [ ] `api-session/activity`。
265
+ - [ ] `api-session/added`。
266
+ - [ ] `api-session/error`。
267
+ - [ ] `api-session/removed`。
268
+ - [ ] `api-session/status`。
269
+ - [ ] `commands/change`。
270
+ - [ ] `credentials/reference-updated`。
271
+ - [ ] `llm/adapters-updated`。
272
+ - [ ] `settings/document-updated`。
273
+ - [ ] `cordis/request-run`。
274
+ - [ ] `cordis/request-run-resolved`。
275
+ - [ ] `cordis/dynamic-package`。
276
+ - [ ] `cordis/dynamic-retract`。
277
+ - [ ] `cordis/inspect-query`。
278
+ - [ ] `cordis/inspect-query-resolved`。
279
+
280
+ ## 暂不纳入正式覆盖率
281
+
282
+ - [ ] 实验性 Agent Team Remote:`view`、`createTask`、`updateTask`。
283
+
284
+ > Agent Team 当前没有进入官方标准 `api-remotes` Client Assembly。只有在官方正式挂载或移动端产生明确需求后,才将其移动到正式功能章节。
285
+
286
+ ## 完成定义
287
+
288
+ 勾选一个待实现功能前,至少需要满足以下条件:
289
+
290
+ - [ ] Host Adapter 已封装对应 Remote 调用或流,不在业务层散落 namespace/method。
291
+ - [ ] 移动端协议请求、响应和错误形态已经定义。
292
+ - [ ] 对应单元测试或网关端到端测试通过。
293
+ - [ ] RC1/Alpha1 相关兼容测试通过,或明确声明该功能只在指定 Host 版本启用。
294
+ - [ ] `PROTOCOL.md` 与 README 中面向用户的能力说明已经同步。
295
+
296
+ > 本节是每个新功能的验收模板,不表示当前所有待实现功能已经满足这些条件,因此保持未勾选。
@@ -215,6 +215,9 @@ export function createDshHostAdapter(typertGateway) {
215
215
  }), signal),
216
216
  attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
217
217
  fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
218
+ cancel: (payload, signal) => invoke('session', 'cancel', requestArgs(payload), signal),
219
+ updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
220
+ rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
218
221
  selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
219
222
  history,
220
223
  async models(payload, signal) {
@@ -239,6 +242,7 @@ export function createDshHostAdapter(typertGateway) {
239
242
  return requireRecord(frame.value, 'workspace/follow baseline')
240
243
  },
241
244
  create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
245
+ archiveSession: (payload, signal) => invoke('workspace', 'archiveSession', requestArgs(payload), signal),
242
246
  },
243
247
  settings: {
244
248
  describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
@@ -300,5 +304,8 @@ export function createDshHostAdapter(typertGateway) {
300
304
  openControlStream(signal) {
301
305
  return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
302
306
  },
307
+ openWorkspaceStream(signal) {
308
+ return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
309
+ },
303
310
  }
304
311
  }
package/lib/index.mjs CHANGED
@@ -45,6 +45,12 @@
45
45
  // { "type": "directory-create", "path", "name" } -> create one child directory
46
46
  // { "type": "workspace-create", "path" } -> create workspace over a dir
47
47
  // { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
48
+ // { "type": "session-cancel", "sessionId" } -> stop the active turn and retain queued work
49
+ // { "type": "queue-update", "sessionId", "itemId",
50
+ // "action": "edit"|"remove"|"steer", "text"? }
51
+ // -> edit/remove one pending item or move it into the current turn
52
+ // { "type": "session-archive", "sessionId" } -> hide a session from workspace surfaces
53
+ // { "type": "session-rename", "sessionId", "title" } -> persist a user-owned session title
48
54
  // { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
49
55
  // { "type": "providers" } -> configurable provider list (live/dormant)
50
56
  // { "type": "commands", "sessionId" } -> slash-command catalog for this session
@@ -68,7 +74,7 @@
68
74
  // { "type": "question-cancel", "rpcId", "sessionId" }
69
75
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
70
76
  // "outcome": "allowed-once"|"rejected" }
71
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"],
77
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "session-cancel", "queue-control", "session-archive", "session-rename", "file-downloads"],
72
78
  // "authenticated", "device"?, "port", "clients" }
73
79
  // { "kind": "pong", "at" }
74
80
  // { "kind": "subscribed", "sessionId" }
@@ -88,6 +94,14 @@
88
94
  // "path", "name", "mediaType", "size", "chunkBytes" }
89
95
  // { "kind": "file-download-chunk", "transferId", "offset", "data", "eof", "sha256"? }
90
96
  // { "kind": "file-download-cancelled", "transferId" }
97
+ // { "kind": "session-cancelled", "sessionId", "accepted": true }
98
+ // { "kind": "queue-item-updated", "sessionId", "itemId", "action", "accepted": true }
99
+ // { "kind": "session-queues", "queues": { "<sessionId>": [...] } }
100
+ // { "kind": "session-queue", "sessionId", "items": [...] }
101
+ // { "kind": "session-archived", "sessionId", "archivedSessionIds" }
102
+ // { "kind": "session-renamed", "sessionId", "title", "seq" }
103
+ // { "kind": "session-archives", "archivedSessionIds" }
104
+ // { "kind": "session-title-changed", "sessionId", "title", "seq", "time", "source"? }
91
105
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
92
106
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
93
107
  // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
@@ -284,6 +298,14 @@ function buildWireEvent(session, event) {
284
298
  event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
285
299
  })
286
300
  }
301
+ case 'session/title':
302
+ return Object.assign(base, {
303
+ event: {
304
+ type: 'session/title',
305
+ title: d.title,
306
+ ...(d.source ? { source: d.source } : {}),
307
+ },
308
+ })
287
309
  case 'tool/call':
288
310
  return Object.assign(base, {
289
311
  event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
@@ -1228,6 +1250,19 @@ async function selectCommandOption(api, host, command, sessionId, optionId) {
1228
1250
 
1229
1251
  // Dispatch one mobile query frame; returns the wire frame to send back.
1230
1252
  async function handleQuery(api, host, agentDefaultModel, msg) {
1253
+ if (msg.type === 'session-create') {
1254
+ if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
1255
+ return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
1256
+ }
1257
+ const payload = {}
1258
+ if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
1259
+ payload.workspaceId = msg.workspaceId.trim()
1260
+ } else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
1261
+ payload.cwd = msg.cwd.trim()
1262
+ }
1263
+ const frame = await proxyQuery(api, 'session-created', api.sessions.create.bind(api.sessions), payload)
1264
+ return { ...frame, ...(frame.kind === 'error' ? { requestType: 'session-create' } : {}), requestId: msg.requestId }
1265
+ }
1231
1266
  if (msg.type === 'workspaces') {
1232
1267
  return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
1233
1268
  }
@@ -1331,6 +1366,96 @@ async function handleQuery(api, host, agentDefaultModel, msg) {
1331
1366
  }
1332
1367
  return frame
1333
1368
  }
1369
+ if (msg.type === 'session-cancel') {
1370
+ const sessionId = requireSessionId(msg)
1371
+ if (sessionId.error) return sessionId.error
1372
+ const frame = await proxyQuery(
1373
+ api,
1374
+ 'session-cancel',
1375
+ api.sessions.cancel.bind(api.sessions),
1376
+ { sessionId: sessionId.value },
1377
+ )
1378
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1379
+ return { ...frame, kind: 'session-cancelled', sessionId: sessionId.value }
1380
+ }
1381
+ if (msg.type === 'queue-update') {
1382
+ const sessionId = requireSessionId(msg)
1383
+ if (sessionId.error) return sessionId.error
1384
+ const itemId = typeof msg.itemId === 'string' && msg.itemId.trim() ? msg.itemId.trim() : null
1385
+ const actionKind = typeof msg.action === 'string' ? msg.action.trim() : ''
1386
+ if (!itemId || !['edit', 'remove', 'steer'].includes(actionKind)) {
1387
+ return {
1388
+ kind: 'error',
1389
+ code: 'bad-request',
1390
+ message: 'queue-update requires itemId and action edit, remove, or steer',
1391
+ requestType: 'queue-update',
1392
+ sessionId: sessionId.value,
1393
+ }
1394
+ }
1395
+ let action
1396
+ if (actionKind === 'edit') {
1397
+ if (typeof msg.text !== 'string' || !msg.text.trim()) {
1398
+ return {
1399
+ kind: 'error',
1400
+ code: 'bad-request',
1401
+ message: 'queue-update edit requires non-empty text',
1402
+ requestType: 'queue-update',
1403
+ sessionId: sessionId.value,
1404
+ itemId,
1405
+ }
1406
+ }
1407
+ action = { kind: 'edit', content: [{ type: 'text', text: msg.text }] }
1408
+ } else {
1409
+ action = { kind: actionKind }
1410
+ }
1411
+ const frame = await proxyQuery(
1412
+ api,
1413
+ 'queue-update',
1414
+ api.sessions.updateQueue.bind(api.sessions),
1415
+ { sessionId: sessionId.value, itemId, action },
1416
+ )
1417
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value, itemId }
1418
+ return {
1419
+ ...frame,
1420
+ kind: 'queue-item-updated',
1421
+ sessionId: sessionId.value,
1422
+ itemId,
1423
+ action: actionKind,
1424
+ }
1425
+ }
1426
+ if (msg.type === 'session-archive') {
1427
+ const sessionId = requireSessionId(msg)
1428
+ if (sessionId.error) return sessionId.error
1429
+ const frame = await proxyQuery(
1430
+ api,
1431
+ 'session-archive',
1432
+ api.workspace.archiveSession.bind(api.workspace),
1433
+ { sessionId: sessionId.value },
1434
+ )
1435
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1436
+ return { ...frame, kind: 'session-archived', sessionId: sessionId.value }
1437
+ }
1438
+ if (msg.type === 'session-rename') {
1439
+ const sessionId = requireSessionId(msg)
1440
+ if (sessionId.error) return sessionId.error
1441
+ if (typeof msg.title !== 'string' || !msg.title.trim()) {
1442
+ return {
1443
+ kind: 'error',
1444
+ code: 'bad-request',
1445
+ message: 'session-rename requires a non-empty title',
1446
+ requestType: 'session-rename',
1447
+ sessionId: sessionId.value,
1448
+ }
1449
+ }
1450
+ const frame = await proxyQuery(
1451
+ api,
1452
+ 'session-rename',
1453
+ api.sessions.rename.bind(api.sessions),
1454
+ { sessionId: sessionId.value, title: msg.title },
1455
+ )
1456
+ if (frame.kind === 'error') return { ...frame, sessionId: sessionId.value }
1457
+ return { ...frame, kind: 'session-renamed', sessionId: sessionId.value }
1458
+ }
1334
1459
  if (msg.type === 'workspace-create') {
1335
1460
  const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
1336
1461
  if (!wsPath) {
@@ -1900,7 +2025,9 @@ const plugin = {
1900
2025
  log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
1901
2026
  })
1902
2027
  }, Math.min(options.fileDownloadIdleMs, 30_000))
1903
- const controlStreamAbort = new AbortController()
2028
+ const backgroundStreamAbort = new AbortController()
2029
+ let archivedSessionIds = null
2030
+ let sessionQueues = null
1904
2031
  let counter = 0
1905
2032
  let gatewayEnabled = options.gatewayEnabled === true
1906
2033
  let waitExpiresAt = null
@@ -1984,12 +2111,25 @@ const plugin = {
1984
2111
  const broadcastInteractionFrame = (frame) => {
1985
2112
  const wire = JSON.stringify(frame)
1986
2113
  for (const client of clients) {
2114
+ if (client.mobileChannel === 'conversation') continue
1987
2115
  if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
1988
2116
  if (client.readyState === 1) client.send(wire)
1989
2117
  }
1990
2118
  }
1991
2119
 
2120
+ // Session-list metadata must reach every connected client even when it is
2121
+ // subscribed to one conversation. Subscriptions only scope the heavier
2122
+ // conversation and interaction streams.
2123
+ const broadcastSessionMetadataFrame = (frame) => {
2124
+ const wire = JSON.stringify(frame)
2125
+ for (const client of clients) {
2126
+ if (client.mobileChannel === 'conversation') continue
2127
+ if (client.readyState === 1) client.send(wire)
2128
+ }
2129
+ }
2130
+
1992
2131
  const replayPendingInteractions = (ws, trigger) => {
2132
+ if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
1993
2133
  let questionCount = 0
1994
2134
  let approvalCount = 0
1995
2135
  for (const pending of pendingQuestions.values()) {
@@ -2008,6 +2148,7 @@ const plugin = {
2008
2148
 
2009
2149
  const hasInteractionClient = (sessionId) => [...clients].some((client) => (
2010
2150
  client.readyState === 1
2151
+ && client.mobileChannel !== 'conversation'
2011
2152
  && (!client.filterSessionId || client.filterSessionId === sessionId)
2012
2153
  ))
2013
2154
 
@@ -2337,6 +2478,8 @@ const plugin = {
2337
2478
  wss.handleUpgrade(req, socket, head, (ws) => {
2338
2479
  const id = ++counter
2339
2480
  ws.filterSessionId = undefined
2481
+ const requestedChannel = req.headers['x-dsh-channel']
2482
+ ws.mobileChannel = ['control', 'conversation'].includes(requestedChannel) ? requestedChannel : 'legacy'
2340
2483
  ws.deviceId = device && device.id
2341
2484
  clients.add(ws)
2342
2485
  if (device) registry.connected(device.id)
@@ -2357,6 +2500,12 @@ const plugin = {
2357
2500
  return
2358
2501
  }
2359
2502
  if (!msg || typeof msg.type !== 'string') return
2503
+ const conversationRequest = ['message', 'history', 'subscribe', 'unsubscribe'].includes(msg.type)
2504
+ if ((ws.mobileChannel === 'control' && conversationRequest) ||
2505
+ (ws.mobileChannel === 'conversation' && !conversationRequest && msg.type !== 'ping')) {
2506
+ ws.send(JSON.stringify({ kind: 'error', code: 'wrong-channel', requestType: msg.type, message: 'Request belongs to the other mobile channel' }))
2507
+ return
2508
+ }
2360
2509
 
2361
2510
  if (msg.type === 'ping') {
2362
2511
  ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
@@ -2386,12 +2535,13 @@ const plugin = {
2386
2535
  })
2387
2536
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
2388
2537
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
2389
- msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2538
+ msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2390
2539
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
2391
2540
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2392
2541
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2393
2542
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2394
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2543
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2544
+ msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
2395
2545
  msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2396
2546
  msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
2397
2547
  handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
@@ -2423,12 +2573,30 @@ const plugin = {
2423
2573
  ws.send(JSON.stringify({
2424
2574
  kind: 'hello',
2425
2575
  protocol: 3,
2426
- capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2576
+ capabilities: [
2577
+ 'split-channels',
2578
+ 'images',
2579
+ 'session-create',
2580
+ 'commands',
2581
+ 'tasks',
2582
+ 'goals',
2583
+ 'session-cancel',
2584
+ 'queue-control',
2585
+ 'session-archive',
2586
+ 'session-rename',
2587
+ ...(options.fileDownloadsEnabled ? ['file-downloads'] : []),
2588
+ ],
2427
2589
  port: transport.port || webServer.port,
2428
2590
  clients: clients.size,
2429
2591
  authenticated: !!device,
2430
2592
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
2431
2593
  }))
2594
+ if (ws.mobileChannel !== 'conversation' && archivedSessionIds !== null) {
2595
+ ws.send(JSON.stringify({ kind: 'session-archives', archivedSessionIds }))
2596
+ }
2597
+ if (ws.mobileChannel !== 'conversation' && sessionQueues !== null) {
2598
+ ws.send(JSON.stringify({ kind: 'session-queues', queues: Object.fromEntries(sessionQueues) }))
2599
+ }
2432
2600
  replayPendingInteractions(ws, 'connect')
2433
2601
  })
2434
2602
  }
@@ -2486,8 +2654,19 @@ const plugin = {
2486
2654
  if (clients.size === 0) return
2487
2655
  const wire = buildWireEvent(session, event)
2488
2656
  if (!wire) return
2657
+ if (event.type === 'session/title' && typeof event.data?.title === 'string') {
2658
+ broadcastSessionMetadataFrame({
2659
+ kind: 'session-title-changed',
2660
+ sessionId: String(session.id),
2661
+ title: event.data.title,
2662
+ seq: event.seq,
2663
+ time: event.time,
2664
+ ...(event.data.source ? { source: event.data.source } : {}),
2665
+ })
2666
+ }
2489
2667
  const payload = JSON.stringify(wire)
2490
2668
  for (const client of clients) {
2669
+ if (client.mobileChannel === 'control') continue
2491
2670
  if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
2492
2671
  if (client.readyState === 1) client.send(payload)
2493
2672
  }
@@ -2548,50 +2727,138 @@ const plugin = {
2548
2727
  })
2549
2728
  }, { prepend: true })
2550
2729
 
2551
- const projectionTask = (async () => {
2552
- while (!controlStreamAbort.signal.aborted) {
2730
+ const waitForBackgroundRetry = () => new Promise((resolve) => {
2731
+ if (backgroundStreamAbort.signal.aborted) {
2732
+ resolve()
2733
+ return
2734
+ }
2735
+ const finish = () => {
2736
+ clearTimeout(timer)
2737
+ backgroundStreamAbort.signal.removeEventListener('abort', finish)
2738
+ resolve()
2739
+ }
2740
+ const timer = setTimeout(finish, 1_000)
2741
+ backgroundStreamAbort.signal.addEventListener('abort', finish, { once: true })
2742
+ })
2743
+
2744
+ const installArchivedSessionIds = (value) => {
2745
+ if (!Array.isArray(value)) throw new Error('workspace/follow returned invalid archivedSessionIds')
2746
+ const next = value.map((sessionId) => String(sessionId))
2747
+ if (archivedSessionIds !== null
2748
+ && archivedSessionIds.length === next.length
2749
+ && archivedSessionIds.every((sessionId, index) => sessionId === next[index])) return
2750
+ archivedSessionIds = next
2751
+ broadcastSessionMetadataFrame({ kind: 'session-archives', archivedSessionIds: next })
2752
+ log(`session archive state forwarded: count=${next.length}`)
2753
+ }
2754
+
2755
+ const normalizeQueueItems = (value, endpoint) => {
2756
+ if (!Array.isArray(value)) throw new Error(`${endpoint} returned invalid queue items`)
2757
+ return value.map((candidate) => {
2758
+ const message = candidate && typeof candidate === 'object' && !Array.isArray(candidate)
2759
+ ? candidate.message
2760
+ : null
2761
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
2762
+ || typeof candidate.id !== 'string' || !candidate.id
2763
+ || !['queued', 'steering', 'context'].includes(candidate.placement)
2764
+ || !message || typeof message !== 'object' || Array.isArray(message)
2765
+ || typeof message.id !== 'string' || !message.id
2766
+ || !Array.isArray(message.content)
2767
+ || (candidate.rpcId !== undefined && typeof candidate.rpcId !== 'string')) {
2768
+ throw new Error(`${endpoint} returned an invalid queue item`)
2769
+ }
2770
+ return {
2771
+ id: candidate.id,
2772
+ placement: candidate.placement,
2773
+ ...(candidate.rpcId === undefined ? {} : { rpcId: candidate.rpcId }),
2774
+ message: { id: message.id, content: message.content },
2775
+ }
2776
+ })
2777
+ }
2778
+
2779
+ const installSessionQueueBaseline = (value) => {
2780
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
2781
+ throw new Error('session/control baseline returned invalid queues')
2782
+ }
2783
+ const next = new Map()
2784
+ for (const [sessionId, items] of Object.entries(value)) {
2785
+ next.set(sessionId, normalizeQueueItems(items, 'session/control baseline'))
2786
+ }
2787
+ sessionQueues = next
2788
+ broadcastSessionMetadataFrame({ kind: 'session-queues', queues: Object.fromEntries(next) })
2789
+ log(`session queue baseline forwarded: sessions=${next.size}`)
2790
+ }
2791
+
2792
+ const installSessionQueue = (sessionIdValue, itemsValue) => {
2793
+ const sessionId = typeof sessionIdValue === 'string' && sessionIdValue ? sessionIdValue : null
2794
+ if (!sessionId) throw new Error('session/control returned an invalid queue sessionId')
2795
+ const items = normalizeQueueItems(itemsValue, 'session/control queue')
2796
+ if (sessionQueues === null) sessionQueues = new Map()
2797
+ sessionQueues.set(sessionId, items)
2798
+ broadcastSessionMetadataFrame({ kind: 'session-queue', sessionId, items })
2799
+ log(`session queue forwarded: session=${sessionId} items=${items.length}`)
2800
+ }
2801
+
2802
+ const workspaceTask = (async () => {
2803
+ while (!backgroundStreamAbort.signal.aborted) {
2553
2804
  try {
2554
- const stream = await api.openControlStream(controlStreamAbort.signal)
2805
+ const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
2555
2806
  for await (const frame of stream) {
2556
- if (frame?.type !== 'projection' || (frame.key !== 'todos' && frame.key !== 'goal')) continue
2557
- const sessionId = String(frame.sessionId)
2558
- const kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2559
- const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2560
- broadcastInteractionFrame({
2561
- kind,
2562
- sessionId,
2563
- asOfSeq: frame.seq,
2564
- [valueKey]: frame.value,
2565
- })
2566
- log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2807
+ if (frame?.type === 'baseline') {
2808
+ installArchivedSessionIds(frame.value?.archivedSessionIds)
2809
+ } else if (frame?.type === 'archived') {
2810
+ installArchivedSessionIds(frame.archivedSessionIds)
2811
+ }
2567
2812
  }
2568
- if (!controlStreamAbort.signal.aborted) log('session control stream ended; retrying')
2813
+ if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
2569
2814
  } catch (error) {
2570
- if (!controlStreamAbort.signal.aborted) {
2571
- log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2815
+ if (!backgroundStreamAbort.signal.aborted) {
2816
+ log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2572
2817
  }
2573
2818
  }
2574
- if (controlStreamAbort.signal.aborted) break
2575
- await new Promise((resolve) => {
2576
- const onAbort = () => {
2577
- clearTimeout(timer)
2578
- resolve()
2819
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2820
+ }
2821
+ })()
2822
+ void workspaceTask
2823
+
2824
+ const controlTask = (async () => {
2825
+ while (!backgroundStreamAbort.signal.aborted) {
2826
+ try {
2827
+ const stream = await api.openControlStream(backgroundStreamAbort.signal)
2828
+ for await (const frame of stream) {
2829
+ if (frame?.type === 'baseline') {
2830
+ installSessionQueueBaseline(frame.value?.queues)
2831
+ } else if (frame?.type === 'queue') {
2832
+ installSessionQueue(frame.sessionId, frame.items)
2833
+ } else if (frame?.type === 'projection' && (frame.key === 'todos' || frame.key === 'goal')) {
2834
+ const sessionId = String(frame.sessionId)
2835
+ const kind = frame.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2836
+ const valueKey = frame.key === 'todos' ? 'todos' : 'goal'
2837
+ broadcastInteractionFrame({
2838
+ kind,
2839
+ sessionId,
2840
+ asOfSeq: frame.seq,
2841
+ [valueKey]: frame.value,
2842
+ })
2843
+ log(`projection forwarded: key=${frame.key} session=${sessionId} seq=${frame.seq}`)
2844
+ }
2579
2845
  }
2580
- const timer = setTimeout(() => {
2581
- controlStreamAbort.signal.removeEventListener('abort', onAbort)
2582
- resolve()
2583
- }, 1_000)
2584
- controlStreamAbort.signal.addEventListener('abort', onAbort, { once: true })
2585
- })
2846
+ if (!backgroundStreamAbort.signal.aborted) log('session control stream ended; retrying')
2847
+ } catch (error) {
2848
+ if (!backgroundStreamAbort.signal.aborted) {
2849
+ log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
2850
+ }
2851
+ }
2852
+ if (!backgroundStreamAbort.signal.aborted) await waitForBackgroundRetry()
2586
2853
  }
2587
2854
  })()
2588
- void projectionTask
2589
- log('Host waterfall interaction listeners and session control stream attached')
2855
+ void controlTask
2856
+ log('Host waterfall interaction listeners, workspace stream, and session control stream attached')
2590
2857
 
2591
2858
  ctx.effect(() => () => {
2592
2859
  if (waitTimer) clearTimeout(waitTimer)
2593
2860
  clearInterval(fileTransferExpiryTimer)
2594
- controlStreamAbort.abort()
2861
+ backgroundStreamAbort.abort()
2595
2862
  for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
2596
2863
  for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
2597
2864
  fileTransfers.dispose().catch((error) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.7.1",
4
- "description": "Secure mobile gateway for DeepSeek Harness with realtime sessions, tasks, goals, commands, approvals, and file transfers",
3
+ "version": "0.7.2",
4
+ "description": "Secure mobile gateway for DeepSeek Harness with split realtime/control channels, editable queues, stop/resume, archive/rename sync, tasks, goals, commands, approvals, and file transfers",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",