dsh-plugin-mobile-gateway 0.7.0 → 0.7.1
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 +11 -10
- package/README.md +9 -1
- package/cordis.patch.yml +2 -2
- package/docs/blog-mobile-gateway.md +1 -1
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/lib/dsh-host-adapter.mjs +304 -0
- package/lib/index.mjs +280 -232
- package/package.json +2 -2
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.
|
|
3
|
+
移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.7.1)。
|
|
4
4
|
|
|
5
5
|
- **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
|
|
6
6
|
- **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
|
|
@@ -162,11 +162,11 @@ Human-in-the-loop 分为两条独立通道:
|
|
|
162
162
|
- **提问**:Agent 的 `ask_user_question` 工具向用户收集答案。
|
|
163
163
|
- **审批**:高风险工具操作(例如沙箱升权)请求一次性允许或拒绝。
|
|
164
164
|
|
|
165
|
-
二者都是
|
|
165
|
+
二者都是 Host waterfall 的临时请求,不属于持久化的 `session/event`,且都必须以插件为该次请求生成的 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。该内部实现不改变移动端帧格式。
|
|
166
166
|
|
|
167
167
|
### 3.1 提问与回答
|
|
168
168
|
|
|
169
|
-
Agent 调用 DSH 的 `ask_user_question`
|
|
169
|
+
Agent 调用 DSH 的 `ask_user_question` 工具时,插件直接接入 Host 的 `user-questions/request` waterfall,并把临时请求投影给移动端。若没有可处理该 Session 的移动连接,插件调用 `next()`,由 WebUI 或后续 Host answerer 处理。
|
|
170
170
|
|
|
171
171
|
#### `question-requested` — 服务端推送问题
|
|
172
172
|
|
|
@@ -191,7 +191,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
191
191
|
}
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
- `rpcId
|
|
194
|
+
- `rpcId`:移动网关为这一整批问题生成的不透明稳定 ID。回答或取消时必须原样返回,客户端不得自行生成或解析。
|
|
195
195
|
- `questions`:一次工具调用中的完整问题批次;可能包含多题。
|
|
196
196
|
- `id`:问题 ID,必须在对应答案中原样返回。
|
|
197
197
|
- `header` / `detail`:可选展示信息。
|
|
@@ -226,7 +226,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
226
226
|
}
|
|
227
227
|
```
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
提交规则由移动网关在进入 Host waterfall 前严格校验:
|
|
230
230
|
|
|
231
231
|
- 必须一次提交这一批中的全部问题,`answers` 数量、顺序和 `id` 必须与 `questions` 一致。
|
|
232
232
|
- `selected` 中的值必须与原始 `options[].label` 完全一致,且不能重复。
|
|
@@ -264,13 +264,13 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
264
264
|
"outcome":"answered" }
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
`outcome` 为 `answered` 或 `cancelled
|
|
267
|
+
`outcome` 为 `answered` 或 `cancelled`。存在匹配 Session 的移动连接时,移动网关优先认领请求,多台移动设备中的第一个合法响应获胜;若没有匹配连接或连接全部断开,则通过 `next()` 回退给 WebUI/后续 Host answerer。所有移动连接都会收到由移动端完成的最终状态并应关闭对应选择界面。重连时网关会重放仍由它持有的问题;DSH 进程重启则会取消这些仅存在于运行时的问题。
|
|
268
268
|
|
|
269
269
|
---
|
|
270
270
|
|
|
271
271
|
### 3.2 操作审批
|
|
272
272
|
|
|
273
|
-
当 DSH
|
|
273
|
+
当 DSH 的工具管线要求人工授权时,插件会接入一次 Host `approval/request` waterfall,并向移动端投影为 `approval-requested`。这正是 Web UI 中“等待审批”卡片对应的能力:`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时工具调用轨迹关联。
|
|
274
274
|
|
|
275
275
|
#### `approval-requested` — 服务端推送待审批操作
|
|
276
276
|
|
|
@@ -288,7 +288,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
288
288
|
```
|
|
289
289
|
|
|
290
290
|
- `rpcId`:本次可回答请求的稳定 RPC ID;提交决定时必须原样返回。
|
|
291
|
-
- `approvalId
|
|
291
|
+
- `approvalId`:移动网关生成的本次审批关联 ID;同样必须原样返回,并用于将最终状态关联到本地审批卡片。
|
|
292
292
|
- `toolName`:请求审批的工具名。
|
|
293
293
|
- `callId` / `reason`:可选。前者可关联工具调用,后者应直接显示为待审批原因。
|
|
294
294
|
- `replay: true`:表示当前仍未决定的审批在移动端连接或切换 Session 后重放。客户端应按 `rpcId` 去重。
|
|
@@ -321,7 +321,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
321
321
|
"approvalId":"approval-1", "outcome":"allowed-once", "accepted":true }
|
|
322
322
|
```
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
如果另一台移动设备已经先作出决定,或请求已经回退给 WebUI/后续 answerer,则回执为 `accepted:false`,并附带 `reason:"not-pending"`。收到错误帧或未被接受的回执时,客户端应保留当前状态,等待最终状态或重新打开事件流。
|
|
325
325
|
|
|
326
326
|
#### `approval-resolved` — 服务端广播最终状态
|
|
327
327
|
|
|
@@ -330,7 +330,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
|
|
|
330
330
|
"approvalId":"approval-1", "outcome":"allowed-once" }
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
-
`outcome` 为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable
|
|
333
|
+
`outcome` 为 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。所有移动连接都会收到由移动网关完成的最终状态并关闭对应审批卡片。移动端断线重连后,网关会重放仍由它持有的审批;已决或已回退的审批不会重放。
|
|
334
334
|
|
|
335
335
|
---
|
|
336
336
|
|
|
@@ -949,6 +949,7 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
|
|
|
949
949
|
| v0.6.8 | 会话工作目录受限的文件列表与分块下载:支持图片、文档、IPA、APK 等普通文件,含连接归属、路径越界防护、取消、超时和 SHA-256 完整性校验 |
|
|
950
950
|
| v0.6.9 | 服务端驱动的命令与技能目录:支持本地化 Hint、通用二级选项、专用命令执行,以及 command/compaction 生命周期事件;Host 命令不再作为用户 Prompt 发送 |
|
|
951
951
|
| v0.7.0 | 任务与 Goal 对齐:任务/Goal 基线查询、`todos`/`goal` 实时投影、Goal 改名、暂停、继续与删除 |
|
|
952
|
+
| v0.7.1 | DSH v0.1.2-rc.1 兼容:内部迁移至 Remote Gateway 与 Host waterfall,移除 APIProxy 依赖;`dsh-mobile-v1` 保持不变 |
|
|
952
953
|
|
|
953
954
|
---
|
|
954
955
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
|
|
8
8
|
|
|
9
|
-
> v0.7.
|
|
9
|
+
> v0.7.1:适配 DSH v0.1.2-rc.1 Remote Gateway,移除 APIProxy 依赖;移动端协议和现有功能保持兼容。
|
|
10
|
+
|
|
11
|
+
## 协议与 DSH 兼容层
|
|
12
|
+
|
|
13
|
+
移动端连接的是本项目维护的 `dsh-mobile-v1`,不是 DSH 的内部 Remote 协议。插件内部通过独立 Host Adapter 对接 DSH v0.1.2-rc.1 的 Remote Gateway;Session、Workspace、Settings、Commands、Goals 等 namespace 和严格参数只存在于该适配层。
|
|
14
|
+
|
|
15
|
+
因此本次从旧 APIProxy 迁移到 Remote Gateway 不要求现有移动端修改:WebSocket 子协议、`hello.protocol = 3`、配对鉴权和已有请求/响应字段保持不变。以后 DSH 调整 Remote API 时,优先只更新插件适配层及契约测试;只有移动协议本身发生不可兼容变化时,才会新增并并行支持新的移动端协议版本。
|
|
16
|
+
|
|
17
|
+
详细实施边界见 [Remote Gateway 重构实施计划](docs/remote-gateway-refactor-plan.md)。
|
|
10
18
|
|
|
11
19
|
- WebSocket:`/ws/mobile`
|
|
12
20
|
- 局域网:`ws://<局域网 IP>:3081/ws/mobile`
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# dsh-plugin-mobile-gateway bundle patch.
|
|
2
2
|
# Applied after dsh-base and dsh-web-app: inserts the persistent /ws/mobile
|
|
3
|
-
# gateway row into the host plane (it consumes the
|
|
4
|
-
# and the
|
|
3
|
+
# gateway row into the host plane (it consumes `webServer`, the Host Remote
|
|
4
|
+
# Gateway and the `session/event` feed, and publishes no service of its own).
|
|
5
5
|
- insert:
|
|
6
6
|
- id: mobile-gateway
|
|
7
7
|
name: 'dsh-plugin-mobile-gateway'
|
|
@@ -379,7 +379,7 @@ DSH 用 Cordis 的依赖注入容器编排"服务行"。插件通过 `package.js
|
|
|
379
379
|
const plugin = {
|
|
380
380
|
name: 'mobile-gateway',
|
|
381
381
|
Config,
|
|
382
|
-
inject: ['webServer', '
|
|
382
|
+
inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
|
|
383
383
|
apply(ctx, config) { /* ... */ },
|
|
384
384
|
}
|
|
385
385
|
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Remote Gateway 重构实施计划
|
|
2
|
+
|
|
3
|
+
## 目标
|
|
4
|
+
|
|
5
|
+
在保持移动端 `dsh-mobile-v1`、配对流程和现有消息字段不变的前提下,移除对 DSH `APIProxy` 的依赖,改为只通过 Host 侧 `typertGateway` 访问 DSH v0.1.2-rc.1 Remote API。所有上游协议差异集中在插件内部适配层,后续 DSH 发生破坏性更新时不要求移动端同步升级。
|
|
6
|
+
|
|
7
|
+
## 稳定边界
|
|
8
|
+
|
|
9
|
+
- 对移动端:继续协商 `dsh-mobile-v1`,`hello.protocol` 保持 `3`,已有请求和响应帧保持兼容。
|
|
10
|
+
- 对 DSH:仅 `lib/dsh-host-adapter.mjs` 知道 Remote namespace、method、严格参数名、stream opening frame 和错误形态。
|
|
11
|
+
- 对业务编排:`lib/index.mjs` 只调用语义化 Host Adapter,不拼装 Remote 描述符。
|
|
12
|
+
- 对错误:上游带 `code/message` 的错误映射为既有移动端错误帧;未知异常稳定映射为 `internal`。
|
|
13
|
+
- 对历史:适配层把 RC 版压缩的 `chunkrow/*` 记录还原为 v1 已支持的 `assistant/chunk` 事件。
|
|
14
|
+
|
|
15
|
+
## 实施阶段
|
|
16
|
+
|
|
17
|
+
### 1. Host Adapter 与普通 RPC
|
|
18
|
+
|
|
19
|
+
- 封装 `invoke()` / `stream()`,校验关键返回值。
|
|
20
|
+
- 迁移 Session、Workspace、Settings、Commands、Skills、Agent Presets、LLM、Goals。
|
|
21
|
+
- Session 历史通过 `session.follow` 获取快照游标和 projections;旧页通过 `session.page` 读取。
|
|
22
|
+
- Workspace 列表通过 `workspace.follow` 的 baseline 获取。
|
|
23
|
+
- Host 信息由插件本地能力和 Remote catalog 合成,不再依赖已删除的 `host.describe`。
|
|
24
|
+
|
|
25
|
+
### 2. 实时与 Human-in-the-loop
|
|
26
|
+
|
|
27
|
+
- 普通实时会话事件继续使用 Host 内部 `session/event`,保持移动端事件帧不变。
|
|
28
|
+
- projection 更新从 `session.control` stream 转发,替代旧 `api.events.mux()` 中的投影帧。
|
|
29
|
+
- approval/question 改为 Cordis waterfall listener;生成插件自有不透明 `rpcId`,等待移动端回答。
|
|
30
|
+
- 只有存在匹配会话的已认证移动连接时才认领 waterfall;否则立即 `next()`,保留 WebUI 回退路径。
|
|
31
|
+
- 连接断开、请求 signal 取消或插件卸载时释放等待者并回退/取消,禁止悬挂 Promise。
|
|
32
|
+
|
|
33
|
+
### 3. 兼容性测试与门禁
|
|
34
|
+
|
|
35
|
+
- 使用假的 `typertGateway`,按 RC 的精确 namespace/method/args 测试,不再模拟 APIProxy。
|
|
36
|
+
- 添加 Host Adapter 单元测试,覆盖 direct value、throw、stream baseline、历史 chunk 展开和严格参数形状。
|
|
37
|
+
- 保留现有 WebSocket v1 端到端用例,验证移动端请求/响应字段没有改变。
|
|
38
|
+
- 增加静态门禁:生产代码不得出现 `apiProxy`、旧 `{ rpcId, payload }` Remote 调用或 `api.events.mux()`。
|
|
39
|
+
|
|
40
|
+
### 4. 配置与文档
|
|
41
|
+
|
|
42
|
+
- 删除 `apiProxy` 注入声明。
|
|
43
|
+
- 更新 README、PROTOCOL 和 bundle patch 注释,明确移动协议与 DSH Remote 的分层。
|
|
44
|
+
- 保留 `session-query-sqlite` 的按需搜索配置,但将其标记为可选能力,避免它成为插件启动前提。
|
|
45
|
+
|
|
46
|
+
## 验收标准
|
|
47
|
+
|
|
48
|
+
1. 插件可在 DSH v0.1.2-rc.1 组合中加载,且不请求 `apiProxy`。
|
|
49
|
+
2. 当前移动端无需修改即可完成配对、会话列表、历史、发送消息、命令、模型、设置、任务、Goal、文件和交互审批。
|
|
50
|
+
3. 全量测试通过;Remote 调用参数均由适配层测试锁定。
|
|
51
|
+
4. 上游 namespace 或数据结构未来变化时,改动范围原则上限定于 Host Adapter 及对应契约测试。
|
|
52
|
+
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
|
|
219
|
+
history,
|
|
220
|
+
async models(payload, signal) {
|
|
221
|
+
const [catalog, snapshot] = await Promise.all([
|
|
222
|
+
modelCatalog(signal),
|
|
223
|
+
sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
|
|
224
|
+
])
|
|
225
|
+
const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
|
|
226
|
+
return {
|
|
227
|
+
current,
|
|
228
|
+
routable: typeof current?.provider === 'string'
|
|
229
|
+
&& requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
|
|
230
|
+
groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
|
|
231
|
+
failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
workspace: {
|
|
236
|
+
async list(_payload = {}, signal) {
|
|
237
|
+
const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
|
|
238
|
+
if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
|
|
239
|
+
return requireRecord(frame.value, 'workspace/follow baseline')
|
|
240
|
+
},
|
|
241
|
+
create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
|
|
242
|
+
},
|
|
243
|
+
settings: {
|
|
244
|
+
describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
|
|
245
|
+
update: (payload, signal) => invoke('settings', 'update', {
|
|
246
|
+
ns: payload.ns,
|
|
247
|
+
patch: payload.patch,
|
|
248
|
+
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
249
|
+
}, signal),
|
|
250
|
+
},
|
|
251
|
+
skills: {
|
|
252
|
+
list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
|
|
253
|
+
},
|
|
254
|
+
agentPresets: {
|
|
255
|
+
list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
|
|
256
|
+
},
|
|
257
|
+
llm: {
|
|
258
|
+
async models(_payload = {}, signal) {
|
|
259
|
+
const catalog = await modelCatalog(signal)
|
|
260
|
+
return { groups: catalog.groups, failures: catalog.failures }
|
|
261
|
+
},
|
|
262
|
+
async providers(_payload = {}, signal) {
|
|
263
|
+
const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
|
|
264
|
+
return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
goals: {
|
|
268
|
+
async edit(payload) {
|
|
269
|
+
return goalRefValue(await invoke('goals', 'edit', {
|
|
270
|
+
agentId: payload.sessionId,
|
|
271
|
+
ref: payload.ref,
|
|
272
|
+
request: {
|
|
273
|
+
...(payload.objective === undefined ? {} : { objective: payload.objective }),
|
|
274
|
+
...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
|
|
275
|
+
},
|
|
276
|
+
}), 'goals/edit')
|
|
277
|
+
},
|
|
278
|
+
async pause(payload) {
|
|
279
|
+
return goalRefValue(
|
|
280
|
+
await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
|
|
281
|
+
'goals/pause',
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
async resume(payload) {
|
|
285
|
+
return goalRefValue(
|
|
286
|
+
await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
|
|
287
|
+
'goals/resume',
|
|
288
|
+
)
|
|
289
|
+
},
|
|
290
|
+
clear: async payload => {
|
|
291
|
+
await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
|
|
292
|
+
return { cleared: true }
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
commands,
|
|
296
|
+
host: {
|
|
297
|
+
describe: (_payload = {}, signal) => describeHost(signal),
|
|
298
|
+
},
|
|
299
|
+
describeHost,
|
|
300
|
+
openControlStream(signal) {
|
|
301
|
+
return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
package/lib/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// feed the browser UI consumes) to connected mobile clients as curated
|
|
7
7
|
// JSON text frames.
|
|
8
8
|
// 2. Mobile -> agent: clients send a message through the same socket; the
|
|
9
|
-
// plugin admits it through the
|
|
9
|
+
// plugin admits it through the DSH Remote Gateway Host API.
|
|
10
10
|
// so it enters the target session exactly like a browser-submitted prompt.
|
|
11
11
|
//
|
|
12
12
|
// Wire protocol (JSON text frames):
|
|
@@ -114,6 +114,7 @@ import crypto from 'node:crypto'
|
|
|
114
114
|
import Schema from '@deepseek-ai/schemastery'
|
|
115
115
|
import { WebSocketServer } from 'ws'
|
|
116
116
|
import devicesModule from './devices.js'
|
|
117
|
+
import { createDshHostAdapter } from './dsh-host-adapter.mjs'
|
|
117
118
|
import QRCode from 'qrcode'
|
|
118
119
|
|
|
119
120
|
const { createRegistry } = devicesModule
|
|
@@ -407,25 +408,15 @@ function commandNameOf(line) {
|
|
|
407
408
|
return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
|
|
408
409
|
}
|
|
409
410
|
|
|
410
|
-
async function listHostCommands(
|
|
411
|
-
const listed = await
|
|
412
|
-
namespace: 'commands',
|
|
413
|
-
method: 'list',
|
|
414
|
-
args: { agentId: sessionId },
|
|
415
|
-
signal: new AbortController().signal,
|
|
416
|
-
})
|
|
411
|
+
async function listHostCommands(host, sessionId) {
|
|
412
|
+
const listed = await host.commands.list(sessionId, new AbortController().signal)
|
|
417
413
|
if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
|
|
418
414
|
return listed
|
|
419
415
|
}
|
|
420
416
|
|
|
421
|
-
async function executeHostCommand(
|
|
417
|
+
async function executeHostCommand(host, sessionId, line, images, requestType) {
|
|
422
418
|
try {
|
|
423
|
-
const execution = await
|
|
424
|
-
namespace: 'commands',
|
|
425
|
-
method: 'execute',
|
|
426
|
-
args: { agentId: sessionId, line, images },
|
|
427
|
-
signal: new AbortController().signal,
|
|
428
|
-
})
|
|
419
|
+
const execution = await host.commands.execute(sessionId, line, images, new AbortController().signal)
|
|
429
420
|
if (execution === undefined || execution === null) {
|
|
430
421
|
return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
|
|
431
422
|
}
|
|
@@ -445,7 +436,7 @@ async function executeHostCommand(typertGateway, sessionId, line, images, reques
|
|
|
445
436
|
}
|
|
446
437
|
}
|
|
447
438
|
|
|
448
|
-
async function admitCommand(
|
|
439
|
+
async function admitCommand(host, msg) {
|
|
449
440
|
const sessionId = requireSessionId(msg)
|
|
450
441
|
if (sessionId.error) return sessionId.error
|
|
451
442
|
const line = typeof msg.line === 'string' ? msg.line.trim() : ''
|
|
@@ -456,7 +447,7 @@ async function admitCommand(typertGateway, msg) {
|
|
|
456
447
|
const parsedImages = parseWireImages(msg.images, 'command-execute')
|
|
457
448
|
if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
|
|
458
449
|
try {
|
|
459
|
-
const listed = await listHostCommands(
|
|
450
|
+
const listed = await listHostCommands(host, sessionId.value)
|
|
460
451
|
const descriptor = listed.find((command) => command && command.name === name)
|
|
461
452
|
if (!descriptor) {
|
|
462
453
|
return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
@@ -464,7 +455,7 @@ async function admitCommand(typertGateway, msg) {
|
|
|
464
455
|
if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
|
|
465
456
|
return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
|
|
466
457
|
}
|
|
467
|
-
return executeHostCommand(
|
|
458
|
+
return executeHostCommand(host, sessionId.value, line, parsedImages.value, 'command-execute')
|
|
468
459
|
} catch (error) {
|
|
469
460
|
const code = error && error.code ? error.code : 'internal'
|
|
470
461
|
const message = error && error.message ? error.message : String(error)
|
|
@@ -497,44 +488,30 @@ async function admitMessage(api, msg) {
|
|
|
497
488
|
if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
|
|
498
489
|
else createPayload.cwd = msg.cwd.trim()
|
|
499
490
|
}
|
|
500
|
-
const created = await api.sessions.create(
|
|
501
|
-
|
|
502
|
-
return { kind: 'error', code: created.result.error.code, message: created.result.error.message }
|
|
503
|
-
}
|
|
504
|
-
sessionId = created.result.value.sessionId
|
|
491
|
+
const created = await api.sessions.create(createPayload)
|
|
492
|
+
sessionId = created.sessionId
|
|
505
493
|
log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
|
|
506
494
|
}
|
|
507
495
|
|
|
508
496
|
const resp = await api.sessions.prompt({
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
|
|
515
|
-
...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
|
|
516
|
-
},
|
|
497
|
+
sessionId,
|
|
498
|
+
mode,
|
|
499
|
+
// Match the official WebUI ordering: images first, optional text last.
|
|
500
|
+
content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
|
|
501
|
+
...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
|
|
517
502
|
})
|
|
518
|
-
|
|
519
|
-
log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
|
|
520
|
-
return {
|
|
521
|
-
kind: 'sent',
|
|
522
|
-
sessionId,
|
|
523
|
-
mode,
|
|
524
|
-
...(resp.result.value.command ? { command: resp.result.value.command } : {}),
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
log(`mobile message rejected: ${resp.result.error.code}: ${resp.result.error.message}`)
|
|
503
|
+
log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
|
|
528
504
|
return {
|
|
529
|
-
kind: '
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
...(
|
|
505
|
+
kind: 'sent',
|
|
506
|
+
sessionId,
|
|
507
|
+
mode,
|
|
508
|
+
...(resp.command ? { command: resp.command } : {}),
|
|
533
509
|
}
|
|
534
510
|
} catch (error) {
|
|
511
|
+
const code = error && error.code ? error.code : 'internal'
|
|
535
512
|
const message = error && error.message ? error.message : String(error)
|
|
536
513
|
log(`mobile message failed: ${message}`)
|
|
537
|
-
return { kind: 'error', code
|
|
514
|
+
return { kind: 'error', code, message, ...(sessionId ? { sessionId } : {}) }
|
|
538
515
|
}
|
|
539
516
|
}
|
|
540
517
|
|
|
@@ -543,23 +520,17 @@ async function admitMessage(api, msg) {
|
|
|
543
520
|
// the uniform `{ kind: 'error', code, message, requestType }` frame.
|
|
544
521
|
async function proxyQuery(api, type, method, payload, signal) {
|
|
545
522
|
try {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
log(`query ok: ${type}`)
|
|
550
|
-
return { kind: type, ...resp.result.value }
|
|
551
|
-
}
|
|
552
|
-
log(`query rejected: ${type} -> ${resp.result.error.code}: ${resp.result.error.message}`)
|
|
553
|
-
return {
|
|
554
|
-
kind: 'error',
|
|
555
|
-
code: resp.result.error.code,
|
|
556
|
-
message: resp.result.error.message,
|
|
557
|
-
requestType: type,
|
|
523
|
+
const value = signal ? await method(payload, signal) : await method(payload)
|
|
524
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
525
|
+
throw new Error(`${type} returned an invalid response`)
|
|
558
526
|
}
|
|
527
|
+
log(`query ok: ${type}`)
|
|
528
|
+
return { kind: type, ...value }
|
|
559
529
|
} catch (error) {
|
|
530
|
+
const code = error && error.code ? error.code : 'internal'
|
|
560
531
|
const message = error && error.message ? error.message : String(error)
|
|
561
|
-
log(`query failed: ${type} -> ${message}`)
|
|
562
|
-
return { kind: 'error', code
|
|
532
|
+
log(`query failed: ${type} -> ${code}: ${message}`)
|
|
533
|
+
return { kind: 'error', code, message, requestType: type }
|
|
563
534
|
}
|
|
564
535
|
}
|
|
565
536
|
|
|
@@ -1190,7 +1161,7 @@ async function loadCommandOptions(api, command, sessionId) {
|
|
|
1190
1161
|
}
|
|
1191
1162
|
}
|
|
1192
1163
|
|
|
1193
|
-
async function selectCommandOption(api,
|
|
1164
|
+
async function selectCommandOption(api, host, command, sessionId, optionId) {
|
|
1194
1165
|
const catalog = await loadCommandOptions(api, command, sessionId)
|
|
1195
1166
|
if (catalog.kind !== 'command-options') return catalog
|
|
1196
1167
|
const option = catalog.options.find((candidate) => candidate.id === optionId)
|
|
@@ -1206,12 +1177,12 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
|
|
|
1206
1177
|
|
|
1207
1178
|
if (command === 'permission') {
|
|
1208
1179
|
try {
|
|
1209
|
-
const execution = await
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1180
|
+
const execution = await host.commands.execute(
|
|
1181
|
+
sessionId,
|
|
1182
|
+
'/permission ' + optionId,
|
|
1183
|
+
[],
|
|
1184
|
+
new AbortController().signal,
|
|
1185
|
+
)
|
|
1215
1186
|
if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
|
|
1216
1187
|
if (execution.result.kind === 'error') {
|
|
1217
1188
|
return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
|
|
@@ -1256,7 +1227,7 @@ async function selectCommandOption(api, typertGateway, command, sessionId, optio
|
|
|
1256
1227
|
}
|
|
1257
1228
|
|
|
1258
1229
|
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
1259
|
-
async function handleQuery(api,
|
|
1230
|
+
async function handleQuery(api, host, agentDefaultModel, msg) {
|
|
1260
1231
|
if (msg.type === 'workspaces') {
|
|
1261
1232
|
return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
|
|
1262
1233
|
}
|
|
@@ -1386,7 +1357,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
1386
1357
|
return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
|
|
1387
1358
|
}
|
|
1388
1359
|
if (msg.type === 'command-execute') {
|
|
1389
|
-
return admitCommand(
|
|
1360
|
+
return admitCommand(host, msg)
|
|
1390
1361
|
}
|
|
1391
1362
|
if (msg.type === 'commands') {
|
|
1392
1363
|
const sessionId = requireSessionId(msg)
|
|
@@ -1398,7 +1369,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
1398
1369
|
// the mobile wire. The Web UI also contributes /model client-side; expose
|
|
1399
1370
|
// the equivalent action here so mobile clients can render the same menu.
|
|
1400
1371
|
const [listed, skillFrame] = await Promise.all([
|
|
1401
|
-
listHostCommands(
|
|
1372
|
+
listHostCommands(host, sessionId.value),
|
|
1402
1373
|
api.skills && typeof api.skills.list === 'function'
|
|
1403
1374
|
? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
|
|
1404
1375
|
: Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
|
|
@@ -1476,7 +1447,7 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
1476
1447
|
const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
|
|
1477
1448
|
const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
|
|
1478
1449
|
if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
|
|
1479
|
-
return selectCommandOption(api,
|
|
1450
|
+
return selectCommandOption(api, host, command, sessionId.value, optionId)
|
|
1480
1451
|
}
|
|
1481
1452
|
if (msg.type === 'select-model') {
|
|
1482
1453
|
const sessionId = requireSessionId(msg)
|
|
@@ -1518,14 +1489,14 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
1518
1489
|
// command registry handler WITHOUT sending anything to the model, and the
|
|
1519
1490
|
// api-remotes agent lookup resumes cold sessions just like prompt does.
|
|
1520
1491
|
try {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1492
|
+
// commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
|
|
1493
|
+
// 权限斜杠命令没有附件,因此显式传空数组。
|
|
1494
|
+
const execution = await host.commands.execute(
|
|
1495
|
+
sessionId.value,
|
|
1496
|
+
'/permission ' + name,
|
|
1497
|
+
[],
|
|
1498
|
+
new AbortController().signal,
|
|
1499
|
+
)
|
|
1529
1500
|
if (execution === undefined || execution === null) {
|
|
1530
1501
|
return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
|
|
1531
1502
|
}
|
|
@@ -1863,16 +1834,16 @@ const plugin = {
|
|
|
1863
1834
|
name: 'mobile-gateway',
|
|
1864
1835
|
Config,
|
|
1865
1836
|
// Hard dependencies: the gateway registers on the host web server and
|
|
1866
|
-
// admits mobile messages through the
|
|
1837
|
+
// admits mobile messages through the Host Remote Gateway. Cordis must not
|
|
1867
1838
|
// activate this row before either service exists.
|
|
1868
|
-
inject: ['webServer', '
|
|
1839
|
+
inject: ['webServer', 'typertGateway', 'agentDefaultModel'],
|
|
1869
1840
|
// exported for local tests; Cordis ignores unknown plugin fields
|
|
1870
1841
|
admitMessage,
|
|
1871
1842
|
handleQuery,
|
|
1872
1843
|
apply(ctx, config) {
|
|
1873
1844
|
const webServer = ctx.webServer
|
|
1874
|
-
const api = ctx.
|
|
1875
|
-
const
|
|
1845
|
+
const api = createDshHostAdapter(ctx.typertGateway)
|
|
1846
|
+
const host = api
|
|
1876
1847
|
const agentDefaultModel = ctx.agentDefaultModel
|
|
1877
1848
|
const options = {
|
|
1878
1849
|
path: DEFAULT_WS_PATH,
|
|
@@ -1929,7 +1900,7 @@ const plugin = {
|
|
|
1929
1900
|
log(`file download expiry failed: ${error && error.message ? error.message : String(error)}`)
|
|
1930
1901
|
})
|
|
1931
1902
|
}, Math.min(options.fileDownloadIdleMs, 30_000))
|
|
1932
|
-
const
|
|
1903
|
+
const controlStreamAbort = new AbortController()
|
|
1933
1904
|
let counter = 0
|
|
1934
1905
|
let gatewayEnabled = options.gatewayEnabled === true
|
|
1935
1906
|
let waitExpiresAt = null
|
|
@@ -2035,6 +2006,96 @@ const plugin = {
|
|
|
2035
2006
|
return { questionCount, approvalCount }
|
|
2036
2007
|
}
|
|
2037
2008
|
|
|
2009
|
+
const hasInteractionClient = (sessionId) => [...clients].some((client) => (
|
|
2010
|
+
client.readyState === 1
|
|
2011
|
+
&& (!client.filterSessionId || client.filterSessionId === sessionId)
|
|
2012
|
+
))
|
|
2013
|
+
|
|
2014
|
+
const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
|
|
2015
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return false
|
|
2016
|
+
pendingQuestions.delete(pending.rpcId)
|
|
2017
|
+
pending.cleanup()
|
|
2018
|
+
if (action === 'answer') pending.resolve(answer)
|
|
2019
|
+
else {
|
|
2020
|
+
const error = new Error('question cancelled by mobile user')
|
|
2021
|
+
error.name = 'UserQuestionError'
|
|
2022
|
+
error.code = cancellationCode
|
|
2023
|
+
pending.reject(error)
|
|
2024
|
+
}
|
|
2025
|
+
broadcastInteractionFrame({
|
|
2026
|
+
kind: 'question-resolved',
|
|
2027
|
+
rpcId: pending.rpcId,
|
|
2028
|
+
sessionId: pending.sessionId,
|
|
2029
|
+
outcome: action === 'answer' ? 'answered' : 'cancelled',
|
|
2030
|
+
})
|
|
2031
|
+
return true
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const normalizeQuestionAnswers = (pending, answers) => {
|
|
2035
|
+
if (answers.length !== pending.questions.length) return { error: 'answers must cover every question exactly once' }
|
|
2036
|
+
const normalized = []
|
|
2037
|
+
for (let index = 0; index < pending.questions.length; index += 1) {
|
|
2038
|
+
const question = pending.questions[index]
|
|
2039
|
+
const answer = answers[index]
|
|
2040
|
+
if (!answer || typeof answer !== 'object' || answer.id !== question.id || !Array.isArray(answer.selected)) {
|
|
2041
|
+
return { error: 'answers must preserve question order, ids, and selected arrays' }
|
|
2042
|
+
}
|
|
2043
|
+
if (answer.selected.some((label) => typeof label !== 'string')) return { error: 'selected values must be strings' }
|
|
2044
|
+
if (new Set(answer.selected).size !== answer.selected.length) return { error: 'selected values must not repeat' }
|
|
2045
|
+
const offered = new Set((question.options || []).map((option) => option.label))
|
|
2046
|
+
if (answer.selected.some((label) => !offered.has(label))) return { error: 'selected values must match offered option labels' }
|
|
2047
|
+
if (question.multiSelect !== true && answer.selected.length > 1) return { error: 'single-select questions accept at most one selection' }
|
|
2048
|
+
let custom
|
|
2049
|
+
if (answer.custom !== undefined) {
|
|
2050
|
+
if (typeof answer.custom !== 'string' || !answer.custom.trim()) return { error: 'custom answers must be non-empty strings' }
|
|
2051
|
+
custom = answer.custom.trim()
|
|
2052
|
+
}
|
|
2053
|
+
if (question.multiSelect !== true && custom !== undefined && answer.selected.length > 0) {
|
|
2054
|
+
return { error: 'single-select custom answers cannot accompany a selection' }
|
|
2055
|
+
}
|
|
2056
|
+
normalized.push({ id: answer.id, selected: [...answer.selected], ...(custom === undefined ? {} : { custom }) })
|
|
2057
|
+
}
|
|
2058
|
+
return { value: { answers: normalized } }
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const completeApproval = (pending, outcome) => {
|
|
2062
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return false
|
|
2063
|
+
pendingApprovals.delete(pending.rpcId)
|
|
2064
|
+
pending.cleanup()
|
|
2065
|
+
pending.resolve(outcome)
|
|
2066
|
+
broadcastInteractionFrame({
|
|
2067
|
+
kind: 'approval-resolved',
|
|
2068
|
+
rpcId: pending.rpcId,
|
|
2069
|
+
sessionId: pending.sessionId,
|
|
2070
|
+
approvalId: pending.approvalId,
|
|
2071
|
+
outcome,
|
|
2072
|
+
})
|
|
2073
|
+
return true
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const fallbackQuestion = (pending) => {
|
|
2077
|
+
if (pendingQuestions.get(pending.rpcId) !== pending) return
|
|
2078
|
+
pendingQuestions.delete(pending.rpcId)
|
|
2079
|
+
pending.cleanup()
|
|
2080
|
+
Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const fallbackApproval = (pending) => {
|
|
2084
|
+
if (pendingApprovals.get(pending.rpcId) !== pending) return
|
|
2085
|
+
pendingApprovals.delete(pending.rpcId)
|
|
2086
|
+
pending.cleanup()
|
|
2087
|
+
Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const releaseUnclaimedInteractions = () => {
|
|
2091
|
+
for (const pending of [...pendingQuestions.values()]) {
|
|
2092
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
|
|
2093
|
+
}
|
|
2094
|
+
for (const pending of [...pendingApprovals.values()]) {
|
|
2095
|
+
if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2038
2099
|
const respondToQuestion = async (msg, cancel = false) => {
|
|
2039
2100
|
const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
|
|
2040
2101
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
|
|
@@ -2042,46 +2103,30 @@ const plugin = {
|
|
|
2042
2103
|
return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
|
|
2043
2104
|
}
|
|
2044
2105
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2106
|
+
const pending = pendingQuestions.get(rpcId)
|
|
2107
|
+
if (!pending) {
|
|
2108
|
+
return { kind: 'question-response', rpcId, sessionId, action: cancel ? 'cancel' : 'answer', accepted: false, reason: 'not-pending' }
|
|
2109
|
+
}
|
|
2110
|
+
if (pending.sessionId !== sessionId) {
|
|
2111
|
+
return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
|
|
2112
|
+
}
|
|
2113
|
+
if (!cancel && !Array.isArray(msg.answers)) {
|
|
2051
2114
|
return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
|
|
2052
2115
|
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
},
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
|
-
try {
|
|
2071
|
-
const receipt = await api.respond({ type: 'client-response', rpcId, result })
|
|
2072
|
-
log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
|
|
2073
|
-
return {
|
|
2074
|
-
kind: 'question-response',
|
|
2075
|
-
rpcId,
|
|
2076
|
-
sessionId,
|
|
2077
|
-
action: cancel ? 'cancel' : 'answer',
|
|
2078
|
-
accepted: receipt.accepted,
|
|
2079
|
-
...(!receipt.accepted ? { reason: receipt.reason } : {}),
|
|
2080
|
-
}
|
|
2081
|
-
} catch (error) {
|
|
2082
|
-
const message = error && error.message ? error.message : String(error)
|
|
2083
|
-
log(`question response failed: rpcId=${rpcId} ${message}`)
|
|
2084
|
-
return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
|
|
2116
|
+
const normalized = cancel ? null : normalizeQuestionAnswers(pending, msg.answers)
|
|
2117
|
+
if (normalized?.error) {
|
|
2118
|
+
return { kind: 'error', code: 'bad-response', message: normalized.error, requestType: msg.type, sessionId }
|
|
2119
|
+
}
|
|
2120
|
+
const answer = normalized?.value
|
|
2121
|
+
const accepted = completeQuestion(pending, cancel ? 'cancel' : 'answer', answer)
|
|
2122
|
+
log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${accepted}`)
|
|
2123
|
+
return {
|
|
2124
|
+
kind: 'question-response',
|
|
2125
|
+
rpcId,
|
|
2126
|
+
sessionId,
|
|
2127
|
+
action: cancel ? 'cancel' : 'answer',
|
|
2128
|
+
accepted,
|
|
2129
|
+
...(!accepted ? { reason: 'not-pending' } : {}),
|
|
2085
2130
|
}
|
|
2086
2131
|
}
|
|
2087
2132
|
|
|
@@ -2098,30 +2143,22 @@ const plugin = {
|
|
|
2098
2143
|
}
|
|
2099
2144
|
|
|
2100
2145
|
const pending = pendingApprovals.get(rpcId)
|
|
2101
|
-
if (pending
|
|
2146
|
+
if (!pending) {
|
|
2147
|
+
return { kind: 'approval-response', rpcId, sessionId, approvalId, outcome, accepted: false, reason: 'not-pending' }
|
|
2148
|
+
}
|
|
2149
|
+
if (pending.sessionId !== sessionId || pending.approvalId !== approvalId) {
|
|
2102
2150
|
return { kind: 'error', code: 'bad-request', message: 'approval-response does not match the pending approval', requestType: msg.type, sessionId }
|
|
2103
2151
|
}
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
rpcId,
|
|
2115
|
-
sessionId,
|
|
2116
|
-
approvalId,
|
|
2117
|
-
outcome,
|
|
2118
|
-
accepted: receipt.accepted,
|
|
2119
|
-
...(!receipt.accepted ? { reason: receipt.reason } : {}),
|
|
2120
|
-
}
|
|
2121
|
-
} catch (error) {
|
|
2122
|
-
const message = error && error.message ? error.message : String(error)
|
|
2123
|
-
log(`approval response failed: rpcId=${rpcId} ${message}`)
|
|
2124
|
-
return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
|
|
2152
|
+
const accepted = completeApproval(pending, outcome)
|
|
2153
|
+
log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${accepted}`)
|
|
2154
|
+
return {
|
|
2155
|
+
kind: 'approval-response',
|
|
2156
|
+
rpcId,
|
|
2157
|
+
sessionId,
|
|
2158
|
+
approvalId,
|
|
2159
|
+
outcome,
|
|
2160
|
+
accepted,
|
|
2161
|
+
...(!accepted ? { reason: 'not-pending' } : {}),
|
|
2125
2162
|
}
|
|
2126
2163
|
}
|
|
2127
2164
|
|
|
@@ -2357,7 +2394,7 @@ const plugin = {
|
|
|
2357
2394
|
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
|
|
2358
2395
|
msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
|
|
2359
2396
|
msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
|
|
2360
|
-
handleQuery(api,
|
|
2397
|
+
handleQuery(api, host, agentDefaultModel, msg).then((frame) => {
|
|
2361
2398
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
2362
2399
|
})
|
|
2363
2400
|
} else {
|
|
@@ -2368,6 +2405,7 @@ const plugin = {
|
|
|
2368
2405
|
ws.on('close', () => {
|
|
2369
2406
|
clients.delete(ws)
|
|
2370
2407
|
if (ws.deviceId) registry.disconnected(ws.deviceId)
|
|
2408
|
+
releaseUnclaimedInteractions()
|
|
2371
2409
|
fileTransfers.closeClient(ws).catch((error) => {
|
|
2372
2410
|
log(`file download cleanup failed: ${error && error.message ? error.message : String(error)}`)
|
|
2373
2411
|
})
|
|
@@ -2456,104 +2494,114 @@ const plugin = {
|
|
|
2456
2494
|
})
|
|
2457
2495
|
log('session/event listener attached')
|
|
2458
2496
|
|
|
2459
|
-
const
|
|
2460
|
-
?
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
})
|
|
2530
|
-
log(`projection forwarded: key=${payload.key} session=${sessionId} seq=${payload.seq}`)
|
|
2531
|
-
} else {
|
|
2532
|
-
log(`interaction ignored: type=${payload.type}`)
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
} catch (error) {
|
|
2536
|
-
if (!interactionMuxAbort.signal.aborted) {
|
|
2537
|
-
log(`interaction mux failed: ${error && error.message ? error.message : String(error)}`)
|
|
2538
|
-
}
|
|
2497
|
+
const disposeQuestions = ctx.on('user-questions/request', (request, next) => {
|
|
2498
|
+
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2499
|
+
if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
|
|
2500
|
+
const rpcId = crypto.randomUUID()
|
|
2501
|
+
return new Promise((resolve, reject) => {
|
|
2502
|
+
const onAbort = () => {
|
|
2503
|
+
const pending = pendingQuestions.get(rpcId)
|
|
2504
|
+
if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
|
|
2505
|
+
}
|
|
2506
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
2507
|
+
const pending = {
|
|
2508
|
+
rpcId,
|
|
2509
|
+
sessionId,
|
|
2510
|
+
questions: request.questions,
|
|
2511
|
+
next,
|
|
2512
|
+
resolve,
|
|
2513
|
+
reject,
|
|
2514
|
+
cleanup: () => request.signal?.removeEventListener('abort', onAbort),
|
|
2515
|
+
}
|
|
2516
|
+
pendingQuestions.set(rpcId, pending)
|
|
2517
|
+
broadcastInteractionFrame(questionFrameFor(rpcId, pending))
|
|
2518
|
+
log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
|
|
2519
|
+
})
|
|
2520
|
+
}, { prepend: true })
|
|
2521
|
+
|
|
2522
|
+
const disposeApprovals = ctx.on('approval/request', (request, next) => {
|
|
2523
|
+
const sessionId = request?.agent?.id === undefined ? null : String(request.agent.id)
|
|
2524
|
+
if (!sessionId || typeof request.toolName !== 'string' || !hasInteractionClient(sessionId)) return next()
|
|
2525
|
+
const rpcId = crypto.randomUUID()
|
|
2526
|
+
const approvalId = crypto.randomUUID()
|
|
2527
|
+
return new Promise((resolve, reject) => {
|
|
2528
|
+
const onAbort = () => {
|
|
2529
|
+
const pending = pendingApprovals.get(rpcId)
|
|
2530
|
+
if (pending) completeApproval(pending, 'cancelled')
|
|
2531
|
+
}
|
|
2532
|
+
request.signal?.addEventListener('abort', onAbort, { once: true })
|
|
2533
|
+
const pending = {
|
|
2534
|
+
rpcId,
|
|
2535
|
+
sessionId,
|
|
2536
|
+
approvalId,
|
|
2537
|
+
toolName: request.toolName,
|
|
2538
|
+
...(request.callId !== undefined ? { callId: request.callId } : {}),
|
|
2539
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
2540
|
+
next,
|
|
2541
|
+
resolve,
|
|
2542
|
+
reject,
|
|
2543
|
+
cleanup: () => request.signal?.removeEventListener('abort', onAbort),
|
|
2544
|
+
}
|
|
2545
|
+
pendingApprovals.set(rpcId, pending)
|
|
2546
|
+
broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
|
|
2547
|
+
log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
|
|
2548
|
+
})
|
|
2549
|
+
}, { prepend: true })
|
|
2550
|
+
|
|
2551
|
+
const projectionTask = (async () => {
|
|
2552
|
+
while (!controlStreamAbort.signal.aborted) {
|
|
2553
|
+
try {
|
|
2554
|
+
const stream = await api.openControlStream(controlStreamAbort.signal)
|
|
2555
|
+
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}`)
|
|
2539
2567
|
}
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2568
|
+
if (!controlStreamAbort.signal.aborted) log('session control stream ended; retrying')
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
if (!controlStreamAbort.signal.aborted) {
|
|
2571
|
+
log(`session control stream failed; retrying: ${error && error.message ? error.message : String(error)}`)
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
if (controlStreamAbort.signal.aborted) break
|
|
2575
|
+
await new Promise((resolve) => {
|
|
2576
|
+
const onAbort = () => {
|
|
2577
|
+
clearTimeout(timer)
|
|
2578
|
+
resolve()
|
|
2579
|
+
}
|
|
2580
|
+
const timer = setTimeout(() => {
|
|
2581
|
+
controlStreamAbort.signal.removeEventListener('abort', onAbort)
|
|
2582
|
+
resolve()
|
|
2583
|
+
}, 1_000)
|
|
2584
|
+
controlStreamAbort.signal.addEventListener('abort', onAbort, { once: true })
|
|
2585
|
+
})
|
|
2586
|
+
}
|
|
2587
|
+
})()
|
|
2588
|
+
void projectionTask
|
|
2589
|
+
log('Host waterfall interaction listeners and session control stream attached')
|
|
2544
2590
|
|
|
2545
2591
|
ctx.effect(() => () => {
|
|
2546
2592
|
if (waitTimer) clearTimeout(waitTimer)
|
|
2547
2593
|
clearInterval(fileTransferExpiryTimer)
|
|
2548
|
-
|
|
2549
|
-
pendingQuestions.
|
|
2550
|
-
pendingApprovals.
|
|
2594
|
+
controlStreamAbort.abort()
|
|
2595
|
+
for (const pending of [...pendingQuestions.values()]) fallbackQuestion(pending)
|
|
2596
|
+
for (const pending of [...pendingApprovals.values()]) fallbackApproval(pending)
|
|
2551
2597
|
fileTransfers.dispose().catch((error) => {
|
|
2552
2598
|
log(`file download disposal failed: ${error && error.message ? error.message : String(error)}`)
|
|
2553
2599
|
})
|
|
2554
2600
|
disposeUpgrade()
|
|
2555
2601
|
disposeMgmt()
|
|
2556
2602
|
disposeEvents()
|
|
2603
|
+
disposeQuestions()
|
|
2604
|
+
disposeApprovals()
|
|
2557
2605
|
if (lanServer) lanServer.close()
|
|
2558
2606
|
for (const client of clients) client.terminate()
|
|
2559
2607
|
clients.clear()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Secure mobile gateway for DeepSeek Harness with realtime sessions, tasks, goals, commands, approvals, and file transfers",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
29
|
+
"test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|