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 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.0)。
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
- 二者都是 API Gateway 的临时请求,不属于持久化的 `session/event`,且都必须以其原始 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。
165
+ 二者都是 Host waterfall 的临时请求,不属于持久化的 `session/event`,且都必须以插件为该次请求生成的 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。该内部实现不改变移动端帧格式。
166
166
 
167
167
  ### 3.1 提问与回答
168
168
 
169
- Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway 的 `events.mux()` 收到临时的待回答请求,并推送给移动端。
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`:API Gateway 为这一整批问题生成的稳定 ID。回答或取消时必须原样返回,客户端不得自行生成。
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
- 提交规则由 API Gateway 严格校验:
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`。WebUI、iOS 或其他客户端中的第一个合法响应获胜;所有移动连接都会收到最终状态并应关闭对应选择界面。移动端断线重连后,API Gateway 会重放仍待回答的问题;DSH 进程重启则会取消这些仅存在于运行时的问题。
267
+ `outcome` 为 `answered` 或 `cancelled`。存在匹配 Session 的移动连接时,移动网关优先认领请求,多台移动设备中的第一个合法响应获胜;若没有匹配连接或连接全部断开,则通过 `next()` 回退给 WebUI/后续 Host answerer。所有移动连接都会收到由移动端完成的最终状态并应关闭对应选择界面。重连时网关会重放仍由它持有的问题;DSH 进程重启则会取消这些仅存在于运行时的问题。
268
268
 
269
269
  ---
270
270
 
271
271
  ### 3.2 操作审批
272
272
 
273
- 当 DSH 的工具管线要求人工授权时,插件会从 API Gateway 收到一次 `approval/requested`。这正是 Web UI 中“等待审批”卡片对应的事件:`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时工具调用轨迹关联。
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`:审批审计 ID;同样必须原样返回,并用于将最终状态关联到本地审批卡片。
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
- 如果 Web UI 或另一台移动设备已经先作出决定,则回执为 `accepted:false`,并附带 `reason:"not-pending"`。收到错误帧或未被接受的回执时,客户端应保留当前状态,等待最终状态或重新打开事件流。
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`。所有移动连接都会收到最终状态并关闭对应审批卡片。移动端断线重连后,API Gateway 会重放仍待决定的审批;已决审批不会重放。
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.0:移动端现可与 WebUI 对齐展示任务列表和当前 Goal;支持 Goal 改名、暂停、继续和删除,并通过实时 projection 与 revision 校验处理多设备并发更新。
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 host `webServer` service
4
- # and the host `session/event` feed, and publishes no service of its own).
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', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
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 official host API (apiProxy.sessions.prompt)
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(typertGateway, sessionId) {
411
- const listed = await typertGateway.invoke({
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(typertGateway, sessionId, line, images, requestType) {
417
+ async function executeHostCommand(host, sessionId, line, images, requestType) {
422
418
  try {
423
- const execution = await typertGateway.invoke({
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(typertGateway, msg) {
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(typertGateway, sessionId.value)
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(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
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({ rpcId: crypto.randomUUID(), payload: createPayload })
501
- if (!created.result.ok) {
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
- rpcId: crypto.randomUUID(),
510
- payload: {
511
- sessionId,
512
- mode,
513
- // Match the official WebUI ordering: images first, optional text last.
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
- if (resp.result.ok) {
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: 'error',
530
- code: resp.result.error.code,
531
- message: resp.result.error.message,
532
- ...(sessionId ? { sessionId } : {}),
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: 'internal', message, ...(sessionId ? { sessionId } : {}) }
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 req = { rpcId: crypto.randomUUID(), payload }
547
- const resp = signal ? await method(req, signal) : await method(req)
548
- if (resp.result.ok) {
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: 'internal', message, requestType: type }
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, typertGateway, command, sessionId, optionId) {
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 typertGateway.invoke({
1210
- namespace: 'commands',
1211
- method: 'execute',
1212
- args: { agentId: sessionId, line: '/permission ' + optionId, images: [] },
1213
- signal: new AbortController().signal,
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, typertGateway, agentDefaultModel, msg) {
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(typertGateway, msg)
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(typertGateway, sessionId.value),
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, typertGateway, command, sessionId.value, optionId)
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
- const execution = await typertGateway.invoke({
1522
- namespace: 'commands',
1523
- method: 'execute',
1524
- // commands/execute 的当前 Typert descriptor 要求 images 字段始终存在;
1525
- // 权限斜杠命令没有附件,因此显式传空数组。
1526
- args: { agentId: sessionId.value, line: '/permission ' + name, images: [] },
1527
- signal: new AbortController().signal,
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 host API gateway. Cordis must not
1837
+ // admits mobile messages through the Host Remote Gateway. Cordis must not
1867
1838
  // activate this row before either service exists.
1868
- inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
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.apiProxy
1875
- const typertGateway = ctx.typertGateway
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 interactionMuxAbort = new AbortController()
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
- if (cancel) {
2046
- const pending = pendingQuestions.get(rpcId)
2047
- if (pending && pending.sessionId !== sessionId) {
2048
- return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
2049
- }
2050
- } else if (!Array.isArray(msg.answers)) {
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
- const result = cancel
2055
- ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
2056
- : {
2057
- ok: true,
2058
- value: {
2059
- sessionId,
2060
- answer: {
2061
- answers: msg.answers.map((answer) => ({
2062
- id: answer && answer.id,
2063
- selected: answer && answer.selected,
2064
- ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
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 && (pending.sessionId !== sessionId || pending.approvalId !== approvalId)) {
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
- try {
2106
- const receipt = await api.respond({
2107
- type: 'client-response',
2108
- rpcId,
2109
- result: { ok: true, value: { sessionId, approvalId, outcome } },
2110
- })
2111
- log(`approval response: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${outcome} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
2112
- return {
2113
- kind: 'approval-response',
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, typertGateway, agentDefaultModel, msg).then((frame) => {
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 interactionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
2460
- ? (async () => {
2461
- try {
2462
- for await (const envelope of api.events.mux(
2463
- { rpcId: crypto.randomUUID(), payload: {} },
2464
- interactionMuxAbort.signal,
2465
- )) {
2466
- const payload = envelope && envelope.payload
2467
- if (!payload || typeof payload.type !== 'string') continue
2468
- log(`interaction observed: type=${payload.type} hasRpc=${envelope.rpcId !== undefined} hasSession=${payload.sessionId !== undefined} hasApprovalId=${payload.approvalId !== undefined} hasToolName=${payload.toolName !== undefined}`)
2469
- if (payload.type === 'question/requested') {
2470
- const rpcId = String(envelope.rpcId)
2471
- const pending = {
2472
- rpcId,
2473
- sessionId: String(payload.sessionId),
2474
- questions: payload.questions,
2475
- }
2476
- pendingQuestions.set(rpcId, pending)
2477
- broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2478
- log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
2479
- } else if (payload.type === 'approval/requested') {
2480
- const rpcId = String(envelope.rpcId)
2481
- const pending = {
2482
- rpcId,
2483
- sessionId: String(payload.sessionId),
2484
- approvalId: String(payload.approvalId),
2485
- toolName: payload.toolName,
2486
- ...(payload.callId !== undefined ? { callId: payload.callId } : {}),
2487
- ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
2488
- }
2489
- pendingApprovals.set(rpcId, pending)
2490
- broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
2491
- log(`approval requested: rpcId=${rpcId} approvalId=${pending.approvalId} session=${pending.sessionId} tool=${pending.toolName}`)
2492
- } else if (payload.type === 'question/resolved') {
2493
- const questionRpcId = String(payload.questionRpcId)
2494
- pendingQuestions.delete(questionRpcId)
2495
- broadcastInteractionFrame({
2496
- kind: 'question-resolved',
2497
- rpcId: questionRpcId,
2498
- sessionId: String(payload.sessionId),
2499
- outcome: payload.outcome,
2500
- })
2501
- log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
2502
- } else if (payload.type === 'approval/resolved') {
2503
- const approvalId = String(payload.approvalId)
2504
- const sessionId = String(payload.sessionId)
2505
- const entry = [...pendingApprovals.entries()].find(([, pending]) => pending.approvalId === approvalId && pending.sessionId === sessionId)
2506
- if (!entry) {
2507
- log(`approval resolved without pending request: approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2508
- continue
2509
- }
2510
- const [rpcId, pending] = entry
2511
- pendingApprovals.delete(rpcId)
2512
- broadcastInteractionFrame({
2513
- kind: 'approval-resolved',
2514
- rpcId,
2515
- sessionId,
2516
- approvalId,
2517
- outcome: payload.outcome,
2518
- })
2519
- log(`approval resolved: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} outcome=${payload.outcome}`)
2520
- } else if (payload.type === 'session/projection' && (payload.key === 'todos' || payload.key === 'goal')) {
2521
- const sessionId = String(payload.sessionId)
2522
- const kind = payload.key === 'todos' ? 'tasks-updated' : 'goal-updated'
2523
- const valueKey = payload.key === 'todos' ? 'todos' : 'goal'
2524
- broadcastInteractionFrame({
2525
- kind,
2526
- sessionId,
2527
- asOfSeq: payload.seq,
2528
- [valueKey]: payload.value,
2529
- })
2530
- log(`projection forwarded: key=${payload.key} session=${sessionId} seq=${payload.seq}`)
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
- : null
2542
- if (interactionMuxTask) log('api gateway interaction mux attached')
2543
- else log('api gateway interaction mux unavailable; human-in-the-loop disabled')
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
- interactionMuxAbort.abort()
2549
- pendingQuestions.clear()
2550
- pendingApprovals.clear()
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.0",
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",