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 +146 -3
- package/README.md +2 -2
- package/docs/typert-remote-gateway-feature-checklist.md +296 -0
- package/lib/dsh-host-adapter.mjs +7 -0
- package/lib/index.mjs +303 -36
- package/package.json +2 -2
package/PROTOCOL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh Mobile Gateway — WebSocket 协议参考
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
7
|
+
DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排队消息同步及编辑/删除/Steer、Session 归档和重命名的双向同步、停止当前生成并稍后继续、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
|
|
8
8
|
|
|
9
|
-
> v0.7.
|
|
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
|
+
> 本节是每个新功能的验收模板,不表示当前所有待实现功能已经满足这些条件,因此保持未勾选。
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -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
|
|
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 === '
|
|
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: [
|
|
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
|
|
2552
|
-
|
|
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.
|
|
2805
|
+
const stream = await api.openWorkspaceStream(backgroundStreamAbort.signal)
|
|
2555
2806
|
for await (const frame of stream) {
|
|
2556
|
-
if (frame?.type
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
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 (!
|
|
2813
|
+
if (!backgroundStreamAbort.signal.aborted) log('workspace stream ended; retrying')
|
|
2569
2814
|
} catch (error) {
|
|
2570
|
-
if (!
|
|
2571
|
-
log(`
|
|
2815
|
+
if (!backgroundStreamAbort.signal.aborted) {
|
|
2816
|
+
log(`workspace stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
|
|
2572
2817
|
}
|
|
2573
2818
|
}
|
|
2574
|
-
if (
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Secure mobile gateway for DeepSeek Harness with realtime
|
|
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",
|