dsh-plugin-mobile-gateway 0.7.0 → 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 +156 -12
- package/README.md +10 -2
- package/cordis.patch.yml +2 -2
- package/docs/blog-mobile-gateway.md +1 -1
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/typert-remote-gateway-feature-checklist.md +296 -0
- package/lib/dsh-host-adapter.mjs +311 -0
- package/lib/index.mjs +549 -234
- package/package.json +3 -3
|
@@ -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
|
+
> 本节是每个新功能的验收模板,不表示当前所有待实现功能已经满足这些条件,因此保持未勾选。
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
|
|
4
|
+
function requireRecord(value, endpoint) {
|
|
5
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
6
|
+
throw new Error(`${endpoint} returned an invalid object`)
|
|
7
|
+
}
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requireArray(value, endpoint) {
|
|
12
|
+
if (!Array.isArray(value)) throw new Error(`${endpoint} returned an invalid array`)
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function chunkRowLength(event) {
|
|
17
|
+
if (event.type === 'chunkrow/tool-call-chunks') return requireArray(event.data?.args, event.type).length
|
|
18
|
+
return requireArray(event.data?.texts, event.type).length
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DSH 0.1.2 compresses consecutive assistant deltas inside history pages.
|
|
22
|
+
// dsh-mobile-v1 predates that storage shape, so restore the exact logical
|
|
23
|
+
// SessionEvent sequence at this boundary instead of teaching every client a
|
|
24
|
+
// Host-specific encoding detail.
|
|
25
|
+
function expandChunkRow(event) {
|
|
26
|
+
const data = requireRecord(event.data, event.type)
|
|
27
|
+
const values = event.type === 'chunkrow/tool-call-chunks'
|
|
28
|
+
? requireArray(data.args, event.type)
|
|
29
|
+
: requireArray(data.texts, event.type)
|
|
30
|
+
const gaps = requireArray(data.dt, event.type)
|
|
31
|
+
if (gaps.length !== Math.max(0, values.length - 1)) {
|
|
32
|
+
throw new Error(`${event.type} returned invalid timing data`)
|
|
33
|
+
}
|
|
34
|
+
const events = []
|
|
35
|
+
let time = event.time
|
|
36
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
37
|
+
if (index > 0) time += gaps[index - 1]
|
|
38
|
+
let chunk
|
|
39
|
+
if (event.type === 'chunkrow/text-chunks') {
|
|
40
|
+
chunk = { type: 'text-delta', index: data.index, text: values[index] }
|
|
41
|
+
} else if (event.type === 'chunkrow/reasoning-chunks') {
|
|
42
|
+
chunk = { type: 'reasoning-delta', index: data.index, text: values[index] }
|
|
43
|
+
} else {
|
|
44
|
+
chunk = {
|
|
45
|
+
type: 'tool-call-delta',
|
|
46
|
+
index: data.index,
|
|
47
|
+
id: data.id,
|
|
48
|
+
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
|
|
49
|
+
argumentsDelta: values[index],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
events.push({
|
|
53
|
+
type: 'assistant/chunk',
|
|
54
|
+
seq: event.seq + index,
|
|
55
|
+
time,
|
|
56
|
+
data: { turn: data.turn, step: data.step, chunk },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return events
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function expandHistoryRecords(records) {
|
|
63
|
+
const events = []
|
|
64
|
+
for (const record of requireArray(records, 'session history')) {
|
|
65
|
+
const value = requireRecord(record, 'session history record')
|
|
66
|
+
const event = requireRecord(value.event, 'session history event')
|
|
67
|
+
if (value.type === 'event') {
|
|
68
|
+
events.push(event)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (value.type !== 'chunks' || ![
|
|
72
|
+
'chunkrow/text-chunks',
|
|
73
|
+
'chunkrow/reasoning-chunks',
|
|
74
|
+
'chunkrow/tool-call-chunks',
|
|
75
|
+
].includes(event.type)) {
|
|
76
|
+
throw new Error('session history returned an unsupported record')
|
|
77
|
+
}
|
|
78
|
+
if (chunkRowLength(event) === 0) throw new Error(`${event.type} returned an empty run`)
|
|
79
|
+
events.push(...expandChunkRow(event))
|
|
80
|
+
}
|
|
81
|
+
return events
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function firstStreamFrame(gateway, namespace, method, args, signal) {
|
|
85
|
+
const ownedAbort = signal === undefined ? new AbortController() : null
|
|
86
|
+
const streamSignal = signal ?? ownedAbort.signal
|
|
87
|
+
const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
|
|
88
|
+
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
89
|
+
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
90
|
+
const iterator = iteratorFactory.call(iterable)
|
|
91
|
+
try {
|
|
92
|
+
const first = await iterator.next()
|
|
93
|
+
if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
|
|
94
|
+
return first.value
|
|
95
|
+
} finally {
|
|
96
|
+
ownedAbort?.abort()
|
|
97
|
+
if (typeof iterator.return === 'function') await iterator.return()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requestArgs(request) {
|
|
102
|
+
return { request }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The RC.1 SessionController intentionally names its reserved list argument
|
|
106
|
+
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
107
|
+
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
108
|
+
function sessionListArgs(request) {
|
|
109
|
+
return { _request: request }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The only module allowed to know DSH Remote endpoint names and argument
|
|
114
|
+
* descriptors. Its public methods intentionally match mobile-gateway domain
|
|
115
|
+
* operations, not the upstream transport envelope.
|
|
116
|
+
*/
|
|
117
|
+
export function createDshHostAdapter(typertGateway) {
|
|
118
|
+
if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
|
|
119
|
+
throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const invoke = (namespace, method, args, signal) => typertGateway.invoke({
|
|
123
|
+
namespace,
|
|
124
|
+
method,
|
|
125
|
+
args,
|
|
126
|
+
...(signal === undefined ? {} : { signal }),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const sessionSnapshot = async (request, signal) => {
|
|
130
|
+
const frame = requireRecord(await firstStreamFrame(
|
|
131
|
+
typertGateway,
|
|
132
|
+
'session',
|
|
133
|
+
'follow',
|
|
134
|
+
requestArgs(request),
|
|
135
|
+
signal,
|
|
136
|
+
), 'session/follow')
|
|
137
|
+
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
|
|
138
|
+
throw new Error('session/follow returned an invalid opening snapshot')
|
|
139
|
+
}
|
|
140
|
+
return frame
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const history = async (payload, signal) => {
|
|
144
|
+
const request = {
|
|
145
|
+
address: { kind: 'session', sessionId: payload.sessionId },
|
|
146
|
+
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
147
|
+
}
|
|
148
|
+
const snapshot = await sessionSnapshot(request, signal)
|
|
149
|
+
let records = snapshot.records
|
|
150
|
+
let hasMore = snapshot.hasMore === true
|
|
151
|
+
if (payload.beforeSeq !== undefined) {
|
|
152
|
+
const page = requireRecord(await invoke('session', 'page', requestArgs({
|
|
153
|
+
address: request.address,
|
|
154
|
+
throughSeq: snapshot.cursor,
|
|
155
|
+
beforeSeq: payload.beforeSeq,
|
|
156
|
+
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
157
|
+
}), signal), 'session/page')
|
|
158
|
+
records = page.records
|
|
159
|
+
hasMore = page.hasMore === true
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
events: expandHistoryRecords(records).map(event => ({ event })),
|
|
163
|
+
hasMore,
|
|
164
|
+
projections: requireRecord(snapshot.projections, 'session/follow projections'),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const modelCatalog = async (signal) => requireRecord(
|
|
169
|
+
await invoke('session', 'modelCatalog', {}, signal),
|
|
170
|
+
'session/modelCatalog',
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const commands = {
|
|
174
|
+
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
175
|
+
execute: (sessionId, line, images, signal) => invoke(
|
|
176
|
+
'commands',
|
|
177
|
+
'execute',
|
|
178
|
+
{ agentId: sessionId, line, images },
|
|
179
|
+
signal,
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const goalRefValue = (value, endpoint) => {
|
|
184
|
+
const goal = requireRecord(value, endpoint)
|
|
185
|
+
if (typeof goal.id !== 'string' || !Number.isSafeInteger(goal.revision)) {
|
|
186
|
+
throw new Error(`${endpoint} returned an invalid goal`)
|
|
187
|
+
}
|
|
188
|
+
return { ref: { id: goal.id, revision: goal.revision } }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const describeHost = async (signal) => {
|
|
192
|
+
const [sessions, catalog, canOpenPath] = await Promise.all([
|
|
193
|
+
invoke('session', 'list', sessionListArgs({}), signal),
|
|
194
|
+
modelCatalog(signal),
|
|
195
|
+
invoke('session', 'canOpenWorkspacePath', {}, signal).catch(() => false),
|
|
196
|
+
])
|
|
197
|
+
return {
|
|
198
|
+
version: 'remote-gateway',
|
|
199
|
+
cwd: os.homedir(),
|
|
200
|
+
attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
|
|
201
|
+
canOpenPath: canOpenPath === true,
|
|
202
|
+
defaultProvider: catalog.default?.provider,
|
|
203
|
+
defaultModel: catalog.default?.model,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
sessions: {
|
|
209
|
+
list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
|
|
210
|
+
search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
|
|
211
|
+
create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
|
|
212
|
+
prompt: (payload, signal) => invoke('session', 'prompt', requestArgs({
|
|
213
|
+
requestId: crypto.randomUUID(),
|
|
214
|
+
...payload,
|
|
215
|
+
}), signal),
|
|
216
|
+
attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
|
|
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),
|
|
221
|
+
selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
|
|
222
|
+
history,
|
|
223
|
+
async models(payload, signal) {
|
|
224
|
+
const [catalog, snapshot] = await Promise.all([
|
|
225
|
+
modelCatalog(signal),
|
|
226
|
+
sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
|
|
227
|
+
])
|
|
228
|
+
const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
|
|
229
|
+
return {
|
|
230
|
+
current,
|
|
231
|
+
routable: typeof current?.provider === 'string'
|
|
232
|
+
&& requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
|
|
233
|
+
groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
|
|
234
|
+
failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
workspace: {
|
|
239
|
+
async list(_payload = {}, signal) {
|
|
240
|
+
const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
|
|
241
|
+
if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
|
|
242
|
+
return requireRecord(frame.value, 'workspace/follow baseline')
|
|
243
|
+
},
|
|
244
|
+
create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
|
|
245
|
+
archiveSession: (payload, signal) => invoke('workspace', 'archiveSession', requestArgs(payload), signal),
|
|
246
|
+
},
|
|
247
|
+
settings: {
|
|
248
|
+
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
249
|
+
update: (payload, signal) => invoke('settings', 'update', {
|
|
250
|
+
ns: payload.ns,
|
|
251
|
+
patch: payload.patch,
|
|
252
|
+
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
253
|
+
}, signal),
|
|
254
|
+
},
|
|
255
|
+
skills: {
|
|
256
|
+
list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
|
|
257
|
+
},
|
|
258
|
+
agentPresets: {
|
|
259
|
+
list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
|
|
260
|
+
},
|
|
261
|
+
llm: {
|
|
262
|
+
async models(_payload = {}, signal) {
|
|
263
|
+
const catalog = await modelCatalog(signal)
|
|
264
|
+
return { groups: catalog.groups, failures: catalog.failures }
|
|
265
|
+
},
|
|
266
|
+
async providers(_payload = {}, signal) {
|
|
267
|
+
const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
|
|
268
|
+
return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
goals: {
|
|
272
|
+
async edit(payload) {
|
|
273
|
+
return goalRefValue(await invoke('goals', 'edit', {
|
|
274
|
+
agentId: payload.sessionId,
|
|
275
|
+
ref: payload.ref,
|
|
276
|
+
request: {
|
|
277
|
+
...(payload.objective === undefined ? {} : { objective: payload.objective }),
|
|
278
|
+
...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
|
|
279
|
+
},
|
|
280
|
+
}), 'goals/edit')
|
|
281
|
+
},
|
|
282
|
+
async pause(payload) {
|
|
283
|
+
return goalRefValue(
|
|
284
|
+
await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
|
|
285
|
+
'goals/pause',
|
|
286
|
+
)
|
|
287
|
+
},
|
|
288
|
+
async resume(payload) {
|
|
289
|
+
return goalRefValue(
|
|
290
|
+
await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
|
|
291
|
+
'goals/resume',
|
|
292
|
+
)
|
|
293
|
+
},
|
|
294
|
+
clear: async payload => {
|
|
295
|
+
await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
|
|
296
|
+
return { cleared: true }
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
commands,
|
|
300
|
+
host: {
|
|
301
|
+
describe: (_payload = {}, signal) => describeHost(signal),
|
|
302
|
+
},
|
|
303
|
+
describeHost,
|
|
304
|
+
openControlStream(signal) {
|
|
305
|
+
return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
|
|
306
|
+
},
|
|
307
|
+
openWorkspaceStream(signal) {
|
|
308
|
+
return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
}
|