dsh-plugin-mobile-gateway 0.6.8 → 0.6.9

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.6.8)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问与操作审批、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.9)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -343,10 +343,148 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
343
343
  ```
344
344
  - `sessionId`:可选。省略时**自动创建新会话**(可用 `workspaceId` 或 `cwd` 指定归属工作区,至多一个,workspaceId 优先)
345
345
  - `mode`:`"queue"`(排队,默认)/ `"steer"`(打断当前回合)
346
- - `text` `/` 开头会被当作**斜杠命令**(如 `/permission ask`),宿主直接执行、**绝不发给模型**
346
+ - `message` 始终是用户 Prompt,Gateway 不会猜测或拦截其中的 `/...`。Host 命令必须使用下文的 `command-execute`;技能(如 `/android-cli 连接设备`)仍作为 `message` 发送,Host 会在 pre-step 阶段注入技能内容。
347
347
  - `text` 与 `images` 至少提供一项;因此支持纯图片消息
348
348
  - `clientTimeZone`:可选 IANA 时区,例如 `Asia/Shanghai`,宿主会校验后记录到这条用户消息
349
349
 
350
+ ### 输入菜单目录(命令 + 技能)
351
+
352
+ 目录按会话查询:Agent preset 会影响 Host 命令,会话工作目录会影响可用技能。客户端输入 `/` 后请求:
353
+
354
+ ```json
355
+ { "type": "commands", "sessionId": "session-abc", "locale": "zh-CN" }
356
+ ```
357
+
358
+ ```json
359
+ → {
360
+ "kind": "commands",
361
+ "sessionId": "session-abc",
362
+ "locale": "zh-CN",
363
+ "groups": [
364
+ {
365
+ "id": "commands",
366
+ "title": "命令",
367
+ "items": [
368
+ {
369
+ "id": "command:compact",
370
+ "name": "compact",
371
+ "description": "Compact older conversation history",
372
+ "source": "host",
373
+ "ui": {
374
+ "kind": "immediate",
375
+ "submitRequest": "command-execute",
376
+ "submitText": "/compact"
377
+ }
378
+ },
379
+ {
380
+ "id": "command:permission",
381
+ "name": "permission",
382
+ "description": "Switch the permission preset",
383
+ "source": "host",
384
+ "ui": {
385
+ "kind": "select",
386
+ "insertText": "/permission",
387
+ "optionsRequest": "command-options",
388
+ "selectionRequest": "command-select"
389
+ }
390
+ }
391
+ ]
392
+ },
393
+ {
394
+ "id": "skills",
395
+ "title": "技能",
396
+ "items": [
397
+ {
398
+ "id": "skill:android-cli",
399
+ "name": "android-cli",
400
+ "description": "Provides instructions for installing and using the Android CLI",
401
+ "source": "skill",
402
+ "action": "insert",
403
+ "modelInvocable": true,
404
+ "ui": {
405
+ "kind": "input",
406
+ "insertText": "/android-cli ",
407
+ "images": true,
408
+ "submitRequest": "message"
409
+ }
410
+ }
411
+ ]
412
+ }
413
+ ]
414
+ }
415
+ ```
416
+
417
+ - `groups` 是客户端的权威渲染结构;分组标题、顺序、条目和交互参数全部由服务端下发。
418
+ - `locale` 可传 `zh-CN` 或英文 locale;服务端返回实际使用的 locale,并为已知命令下发 `ui.displayHint`。客户端优先显示 `displayHint`,缺失时回退到 Host 原始 `hint`。
419
+ - 客户端只解释 `ui`,不按条目名写分支:`immediate` 将 `submitText` 通过 `submitRequest` 发送;`input` 插入 `insertText`、高亮首个 Token 并使用可选的 `displayHint/hint/images`;`select` 插入 `insertText` 并打开通用二级菜单。
420
+ - `source: "host"` / `action: "execute"`:真实 DSH 斜杠命令,必须将 `/<name>` 或 `/<name> <args>` 通过 `command-execute.line` 提交,不得放入 `message.text`。
421
+ - `source: "skill"`:条目来自 DSH `skill.list({sessionId})`。选中时仅按 `ui.insertText` 写入草稿,发送后 Host 会在 pre-step 阶段加载技能内容,不需要专用执行接口。`modelInvocable: false` 的用户专用技能也会被列出,其显示描述由服务端加上“仅用户”标记。
422
+ - `model` 虽然是与官方 Web UI 一致的客户端命令,但选项加载与提交同样走下述通用接口,客户端不需要识别它的名字或模型协议。
423
+ - Host 命令和技能各自保留原始顺序,`model` 客户端命令追加在命令组末尾。若未来 Host 自己注册 `model`,gateway 不会重复追加。
424
+ - `hello.capabilities` 包含 `commands` 时表示服务端支持此目录接口。
425
+
426
+ #### Host 命令执行
427
+
428
+ `ui.submitRequest` 为 `command-execute` 时,客户端将完整命令行发送到专用接口:
429
+
430
+ ```json
431
+ { "type": "command-execute", "sessionId": "session-abc",
432
+ "line": "/compact", "images": [] }
433
+ → {
434
+ "kind": "command-executed",
435
+ "sessionId": "session-abc",
436
+ "line": "/compact",
437
+ "commandId": "command-123",
438
+ "result": { "kind": "success", "text": "Compacted 24 history items (~7230 tokens)." }
439
+ }
440
+ ```
441
+
442
+ 带参数命令仍是 `command-execute`:
443
+
444
+ ```json
445
+ { "type": "command-execute", "sessionId": "session-abc",
446
+ "line": "/plan 帮我完成 Android 端适配", "images": [] }
447
+ ```
448
+
449
+ - `line` 必须以 `/` 开头,参数作为同一字符串跟在命令后面。
450
+ - 只有目录中 `ui.images: true` 的命令可携带图片;图片结构与 `message.images` 相同。Gateway 会再次校验。
451
+ - `result.kind: "error"` 表示命令已进入 Host 但处理失败;客户端应保留当前草稿和图片供用户修改。
452
+ - 命令不会生成 `user/message`,也不会进入模型 Prompt。Host 会持久化 `command/run` / `command/done`;`compact` 还会产生 `compaction/start` / `compaction/summary` / `compaction/end`。Gateway 会把这些事件实时转发,客户端据此渲染“正在压缩…”和最终结果。
453
+
454
+ #### 通用二级菜单
455
+
456
+ 当 `ui.kind` 为 `select` 时,客户端使用 `ui.optionsRequest` 指定的请求类型加载标准化选项:
457
+
458
+ ```json
459
+ { "type": "command-options", "sessionId": "session-abc", "command": "permission" }
460
+ → {
461
+ "kind": "command-options",
462
+ "sessionId": "session-abc",
463
+ "command": "permission",
464
+ "options": [
465
+ { "id": "ask", "label": "Ask", "description": "Ask before risky operations", "selected": true },
466
+ { "id": "workspace-write", "label": "Workspace Write", "selected": false }
467
+ ]
468
+ }
469
+ ```
470
+
471
+ `id` 是服务端拥有的 opaque 值;客户端只负责原样回传。模型选项和权限选项使用完全相同的 `{id,label,detail?,description?,selected}` 结构。
472
+
473
+ 选择后使用 `ui.selectionRequest` 指定的请求类型提交:
474
+
475
+ ```json
476
+ { "type": "command-select", "sessionId": "session-abc",
477
+ "command": "permission", "optionId": "workspace-write" }
478
+ → {
479
+ "kind": "command-selected",
480
+ "sessionId": "session-abc",
481
+ "command": "permission",
482
+ "selected": { "id": "workspace-write", "label": "Workspace Write", "selected": true }
483
+ }
484
+ ```
485
+
486
+ 客户端用 `selected.label/detail` 更新输入框状态栏。`model`、`permission` 的具体查询、校验和写入全部由服务端处理;现有 `models/select-model` 与 `permission-options/permission` 仅作为兼容接口保留。
487
+
350
488
  ### 发送图片
351
489
 
352
490
  iOS 将本地图片原始文件数据编码成**标准 Base64**,不要包含 `data:image/...;base64,` 前缀:
@@ -720,6 +858,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
720
858
  | v0.6.6 | Human-in-the-loop 操作审批:转发 API Gateway approval 请求、一次性允许/拒绝、重连重放与多端最终状态收敛 |
721
859
  | v0.6.7 | 订阅已有 Session 时重放待处理 Human-in-the-loop 请求,并增加 Approval 端到端诊断日志与安装版本标记 |
722
860
  | v0.6.8 | 会话工作目录受限的文件列表与分块下载:支持图片、文档、IPA、APK 等普通文件,含连接归属、路径越界防护、取消、超时和 SHA-256 完整性校验 |
861
+ | v0.6.9 | 服务端驱动的命令与技能目录:支持本地化 Hint、通用二级选项、专用命令执行,以及 command/compaction 生命周期事件;Host 命令不再作为用户 Prompt 发送 |
723
862
 
724
863
  ---
725
864
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # dsh-plugin-mobile-gateway
6
6
 
7
- 让 iPhone 通过经过设备鉴权的 WebSocket 连接 DeepSeek Harness。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
7
+ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
8
8
 
9
9
  - WebSocket:`/ws/mobile`
10
10
  - 局域网:`ws://<局域网 IP>:3081/ws/mobile`
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## 配套 iOS 客户端
15
15
 
16
- [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop、模型与权限设置。
16
+ [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop,以及由网关配置驱动的命令、技能、模型与权限菜单。
17
17
 
18
18
  <table>
19
19
  <tr>
package/lib/index.mjs CHANGED
@@ -47,6 +47,10 @@
47
47
  // { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
48
48
  // { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
49
49
  // { "type": "providers" } -> configurable provider list (live/dormant)
50
+ // { "type": "commands", "sessionId" } -> slash-command catalog for this session
51
+ // { "type": "command-execute", "sessionId", "line", "images"? } -> execute a Host command
52
+ // { "type": "command-options", "sessionId", "command" } -> normalized submenu options
53
+ // { "type": "command-select", "sessionId", "command", "optionId" } -> apply submenu option
50
54
  // { "type": "select-model", "sessionId", "provider", "model", "reasoningEffort"? }
51
55
  // { "type": "permission-options", "sessionId"? } -> permission presets (+ session knobs)
52
56
  // { "type": "permission", "sessionId", "name" } -> switch preset via /permission command
@@ -60,12 +64,20 @@
60
64
  // { "type": "question-cancel", "rpcId", "sessionId" }
61
65
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
62
66
  // "outcome": "allowed-once"|"rejected" }
63
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"],
67
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "file-downloads"],
64
68
  // "authenticated", "device"?, "port", "clients" }
65
69
  // { "kind": "pong", "at" }
66
70
  // { "kind": "subscribed", "sessionId" }
67
71
  // { "kind": "sent", "sessionId", "mode", "command"? }
68
72
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
73
+ // { "kind": "commands", "sessionId", "groups": [
74
+ // { "id", "title", "items": [{ "id", "name", "description", "ui" }] }
75
+ // ] }
76
+ // { "kind": "command-options", "sessionId", "command", "options": [
77
+ // { "id", "label", "detail"?, "description"?, "selected" }
78
+ // ] }
79
+ // { "kind": "command-executed", "sessionId", "line", "commandId", "result" }
80
+ // { "kind": "command-selected", "sessionId", "command", "selected" }
69
81
  // { "kind": "attachment", "sessionId", "attachment", "data" }
70
82
  // { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
71
83
  // { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
@@ -120,6 +132,42 @@ const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
120
132
  const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
121
133
  const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
122
134
  const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
135
+ const COMMAND_UI_OVERRIDES = Object.freeze({
136
+ permission: Object.freeze({
137
+ kind: 'select',
138
+ optionsRequest: 'command-options',
139
+ selectionRequest: 'command-select',
140
+ }),
141
+ model: Object.freeze({
142
+ kind: 'select',
143
+ optionsRequest: 'command-options',
144
+ selectionRequest: 'command-select',
145
+ }),
146
+ })
147
+ const COMMAND_CATALOG_COPY = Object.freeze({
148
+ zh: Object.freeze({
149
+ locale: 'zh-CN',
150
+ commandsTitle: '命令',
151
+ skillsTitle: '技能',
152
+ userOnly: '仅用户',
153
+ modelDescription: '选择本会话使用的模型',
154
+ hints: Object.freeze({
155
+ plan: '描述你的任务以生成计划',
156
+ goal: '输入目标,智能体将持续执行',
157
+ }),
158
+ }),
159
+ en: Object.freeze({
160
+ locale: 'en',
161
+ commandsTitle: 'Commands',
162
+ skillsTitle: 'Skills',
163
+ userOnly: 'user-only',
164
+ modelDescription: 'Select the model for this conversation',
165
+ hints: Object.freeze({
166
+ plan: 'describe your task to generate plan',
167
+ goal: 'describe the objective for a long-running task',
168
+ }),
169
+ }),
170
+ })
123
171
 
124
172
  // Cordis validates this schema at plugin load and fills these defaults. Keep
125
173
  // the defaults conservative: installing the bundle must never create an
@@ -248,6 +296,55 @@ function buildWireEvent(session, event) {
248
296
  },
249
297
  })
250
298
  }
299
+ case 'command/run':
300
+ return Object.assign(base, {
301
+ event: {
302
+ type: 'command/run',
303
+ commandId: d.commandId,
304
+ name: d.name,
305
+ ...(typeof d.args === 'string' ? { args: d.args } : {}),
306
+ ...(d.source ? { source: d.source } : {}),
307
+ },
308
+ })
309
+ case 'command/done':
310
+ return Object.assign(base, {
311
+ event: {
312
+ type: 'command/done',
313
+ commandId: d.commandId,
314
+ outcome: d.kind,
315
+ ...(typeof d.text === 'string' ? { text: d.text } : {}),
316
+ ...(typeof d.sourceEventSeq === 'number' ? { sourceEventSeq: d.sourceEventSeq } : {}),
317
+ },
318
+ })
319
+ case 'compaction/start':
320
+ return Object.assign(base, {
321
+ event: {
322
+ type: 'compaction/start',
323
+ compactionId: d.compactionId,
324
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
325
+ turn: d.turn ?? null,
326
+ },
327
+ })
328
+ case 'compaction/summary':
329
+ return Object.assign(base, {
330
+ event: {
331
+ type: 'compaction/summary',
332
+ compactionId: d.compactionId,
333
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
334
+ shadowedItemCount: Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.length : null,
335
+ shadowedTokenCount: typeof d.shadowedTokenCount === 'number' ? d.shadowedTokenCount : null,
336
+ },
337
+ })
338
+ case 'compaction/end':
339
+ return Object.assign(base, {
340
+ event: {
341
+ type: 'compaction/end',
342
+ compactionId: d.compactionId,
343
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
344
+ turn: d.turn ?? null,
345
+ ...(typeof d.error === 'string' ? { error: d.error } : {}),
346
+ },
347
+ })
251
348
  case 'turn/start':
252
349
  case 'turn/end':
253
350
  case 'step/start':
@@ -260,31 +357,28 @@ function buildWireEvent(session, event) {
260
357
  }
261
358
  }
262
359
 
263
- // Handle one mobile -> agent message through the official host API. Returns
264
- // the wire frame to send back, or null when nothing should be sent.
265
- async function admitMessage(api, msg) {
266
- const text = typeof msg.text === 'string' ? msg.text.trim() : ''
267
- const images = msg.images === undefined ? [] : msg.images
360
+ function parseWireImages(rawImages, requestType) {
361
+ const images = rawImages === undefined ? [] : rawImages
268
362
  if (!Array.isArray(images)) {
269
- return { kind: 'error', code: 'bad-request', message: 'images must be an array' }
363
+ return { error: { kind: 'error', code: 'bad-request', message: 'images must be an array', requestType } }
270
364
  }
271
365
  if (images.length > 20) {
272
- return { kind: 'error', code: 'bad-request', message: 'a message can contain at most 20 images' }
366
+ return { error: { kind: 'error', code: 'bad-request', message: 'a request can contain at most 20 images', requestType } }
273
367
  }
274
368
  const imageParts = []
275
369
  for (let index = 0; index < images.length; index++) {
276
370
  const image = images[index]
277
371
  if (!image || typeof image !== 'object') {
278
- return { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object` }
372
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object`, requestType } }
279
373
  }
280
374
  if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
281
- return { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported` }
375
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported`, requestType } }
282
376
  }
283
377
  if (typeof image.data !== 'string' || image.data.length === 0) {
284
- return { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string` }
378
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string`, requestType } }
285
379
  }
286
380
  if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
287
- return { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters` }
381
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters`, requestType } }
288
382
  }
289
383
  imageParts.push({
290
384
  type: 'image',
@@ -293,6 +387,88 @@ async function admitMessage(api, msg) {
293
387
  ...(image.name ? { name: image.name } : {}),
294
388
  })
295
389
  }
390
+ return { value: imageParts }
391
+ }
392
+
393
+ function commandNameOf(line) {
394
+ const trimmed = line.trim()
395
+ if (!trimmed.startsWith('/')) return null
396
+ const end = trimmed.search(/\s/)
397
+ return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
398
+ }
399
+
400
+ async function listHostCommands(typertGateway, sessionId) {
401
+ const listed = await typertGateway.invoke({
402
+ namespace: 'commands',
403
+ method: 'list',
404
+ args: { agentId: sessionId },
405
+ signal: new AbortController().signal,
406
+ })
407
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
408
+ return listed
409
+ }
410
+
411
+ async function executeHostCommand(typertGateway, sessionId, line, images, requestType) {
412
+ try {
413
+ const execution = await typertGateway.invoke({
414
+ namespace: 'commands',
415
+ method: 'execute',
416
+ args: { agentId: sessionId, line, images },
417
+ signal: new AbortController().signal,
418
+ })
419
+ if (execution === undefined || execution === null) {
420
+ return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
421
+ }
422
+ log(`command executed: session=${sessionId} line=${JSON.stringify(line)} commandId=${execution.commandId} outcome=${execution.result.kind}`)
423
+ return {
424
+ kind: 'command-executed',
425
+ sessionId,
426
+ line,
427
+ commandId: execution.commandId,
428
+ result: execution.result,
429
+ }
430
+ } catch (error) {
431
+ const code = error && error.code ? error.code : 'internal'
432
+ const message = error && error.message ? error.message : String(error)
433
+ log(`command rejected: session=${sessionId} ${code}: ${message}`)
434
+ return { kind: 'error', code, message, requestType, sessionId }
435
+ }
436
+ }
437
+
438
+ async function admitCommand(typertGateway, msg) {
439
+ const sessionId = requireSessionId(msg)
440
+ if (sessionId.error) return sessionId.error
441
+ const line = typeof msg.line === 'string' ? msg.line.trim() : ''
442
+ const name = commandNameOf(line)
443
+ if (!name) {
444
+ return { kind: 'error', code: 'bad-request', message: 'command-execute requires a slash-prefixed line', requestType: 'command-execute', sessionId: sessionId.value }
445
+ }
446
+ const parsedImages = parseWireImages(msg.images, 'command-execute')
447
+ if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
448
+ try {
449
+ const listed = await listHostCommands(typertGateway, sessionId.value)
450
+ const descriptor = listed.find((command) => command && command.name === name)
451
+ if (!descriptor) {
452
+ return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
453
+ }
454
+ if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
455
+ return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
456
+ }
457
+ return executeHostCommand(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
458
+ } catch (error) {
459
+ const code = error && error.code ? error.code : 'internal'
460
+ const message = error && error.message ? error.message : String(error)
461
+ return { kind: 'error', code, message, requestType: 'command-execute', sessionId: sessionId.value }
462
+ }
463
+ }
464
+
465
+ // Handle one mobile -> agent message through the official host API. Returns
466
+ // the wire frame to send back, or null when nothing should be sent.
467
+ async function admitMessage(api, msg) {
468
+ const text = typeof msg.text === 'string' ? msg.text.trim() : ''
469
+ const parsedImages = parseWireImages(msg.images, 'message')
470
+ if (parsedImages.error) return parsedImages.error
471
+ const imageParts = parsedImages.value
296
472
  if (!text && imageParts.length === 0) {
297
473
  return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
298
474
  }
@@ -856,6 +1032,171 @@ function capHistoryEvents(events, maxBytes, trim) {
856
1032
  return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
857
1033
  }
858
1034
 
1035
+ function resolveCommandCatalogCopy(locale) {
1036
+ return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
1037
+ ? COMMAND_CATALOG_COPY.en
1038
+ : COMMAND_CATALOG_COPY.zh
1039
+ }
1040
+
1041
+ function commandUiDescriptor(command, copy) {
1042
+ const override = COMMAND_UI_OVERRIDES[command.name]
1043
+ if (override) return { ...override, insertText: `/${command.name}` }
1044
+ if (command.input && typeof command.input.hint === 'string') {
1045
+ const displayHint = copy.hints[command.name]
1046
+ return {
1047
+ kind: 'input',
1048
+ insertText: `/${command.name} `,
1049
+ hint: command.input.hint,
1050
+ ...(displayHint ? { displayHint } : {}),
1051
+ images: command.input.images === true,
1052
+ submitRequest: 'command-execute',
1053
+ }
1054
+ }
1055
+ return { kind: 'immediate', submitRequest: 'command-execute', submitText: `/${command.name}` }
1056
+ }
1057
+
1058
+ function skillUiDescriptor(skill) {
1059
+ return {
1060
+ kind: 'input',
1061
+ insertText: `/${skill.name} `,
1062
+ images: true,
1063
+ submitRequest: 'message',
1064
+ }
1065
+ }
1066
+
1067
+ function modelCommandOptionId(provider, model) {
1068
+ return Buffer.from(JSON.stringify([provider, model]), 'utf8').toString('base64url')
1069
+ }
1070
+
1071
+ function decodeModelCommandOptionId(optionId) {
1072
+ try {
1073
+ const value = JSON.parse(Buffer.from(optionId, 'base64url').toString('utf8'))
1074
+ if (!Array.isArray(value) || value.length !== 2 || value.some((part) => typeof part !== 'string' || part === '')) return null
1075
+ return { provider: value[0], model: value[1] }
1076
+ } catch {
1077
+ return null
1078
+ }
1079
+ }
1080
+
1081
+ async function loadCommandOptions(api, command, sessionId) {
1082
+ if (command === 'model') {
1083
+ const frame = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1084
+ if (frame.kind !== 'models') return frame
1085
+ const options = []
1086
+ for (const group of frame.groups || []) {
1087
+ for (const model of group.models || []) {
1088
+ options.push({
1089
+ id: modelCommandOptionId(String(group.id), String(model.id)),
1090
+ label: String(model.name || model.id),
1091
+ detail: String(group.name || group.id),
1092
+ ...(model.description ? { description: String(model.description) } : {}),
1093
+ selected: frame.current && frame.current.provider === group.id && frame.current.model === model.id,
1094
+ })
1095
+ }
1096
+ }
1097
+ return { kind: 'command-options', sessionId, command, options }
1098
+ }
1099
+
1100
+ if (command === 'permission') {
1101
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1102
+ if (history.kind !== 'history') return history
1103
+ const permissions = history.projections && history.projections.values && history.projections.values.permissions
1104
+ if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
1105
+ return {
1106
+ kind: 'error',
1107
+ code: 'command-options-unavailable',
1108
+ message: 'permission options are unavailable for this session',
1109
+ requestType: 'command-options',
1110
+ sessionId,
1111
+ }
1112
+ }
1113
+ return {
1114
+ kind: 'command-options',
1115
+ sessionId,
1116
+ command,
1117
+ options: permissions.options.map((option) => ({
1118
+ id: String(option.value),
1119
+ label: String(option.name || option.value),
1120
+ ...(option.description ? { description: String(option.description) } : {}),
1121
+ selected: option.value === permissions.currentValue,
1122
+ })),
1123
+ }
1124
+ }
1125
+
1126
+ return {
1127
+ kind: 'error',
1128
+ code: 'bad-request',
1129
+ message: `command does not provide selectable options: ${command}`,
1130
+ requestType: 'command-options',
1131
+ sessionId,
1132
+ }
1133
+ }
1134
+
1135
+ async function selectCommandOption(api, typertGateway, command, sessionId, optionId) {
1136
+ const catalog = await loadCommandOptions(api, command, sessionId)
1137
+ if (catalog.kind !== 'command-options') return catalog
1138
+ const option = catalog.options.find((candidate) => candidate.id === optionId)
1139
+ if (!option) {
1140
+ return {
1141
+ kind: 'error',
1142
+ code: 'bad-request',
1143
+ message: `unknown option for ${command}: ${optionId}`,
1144
+ requestType: 'command-select',
1145
+ sessionId,
1146
+ }
1147
+ }
1148
+
1149
+ if (command === 'permission') {
1150
+ try {
1151
+ const execution = await typertGateway.invoke({
1152
+ namespace: 'commands',
1153
+ method: 'execute',
1154
+ args: { agentId: sessionId, line: '/permission ' + optionId, images: [] },
1155
+ signal: new AbortController().signal,
1156
+ })
1157
+ if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
1158
+ if (execution.result.kind === 'error') {
1159
+ return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
1160
+ }
1161
+ return { kind: 'command-selected', sessionId, command, selected: { ...option, selected: true } }
1162
+ } catch (error) {
1163
+ const code = error && error.code ? error.code : 'internal'
1164
+ const message = error && error.message ? error.message : String(error)
1165
+ return { kind: 'error', code, message, requestType: 'command-select', sessionId }
1166
+ }
1167
+ }
1168
+
1169
+ if (command === 'model') {
1170
+ const selection = decodeModelCommandOptionId(optionId)
1171
+ if (!selection) {
1172
+ return { kind: 'error', code: 'bad-request', message: 'invalid model option id', requestType: 'command-select', sessionId }
1173
+ }
1174
+ const models = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1175
+ if (models.kind !== 'models') return models
1176
+ const group = (models.groups || []).find((candidate) => candidate.id === selection.provider)
1177
+ const model = group && (group.models || []).find((candidate) => candidate.id === selection.model)
1178
+ if (!model) {
1179
+ return { kind: 'error', code: 'bad-request', message: 'model option is no longer available', requestType: 'command-select', sessionId }
1180
+ }
1181
+ const payload = { sessionId, ...selection }
1182
+ const reasoningEffort = models.current && models.current.provider === selection.provider && models.current.model === selection.model
1183
+ ? models.current.reasoningEffort
1184
+ : model.reasoning && model.reasoning.defaultEffort
1185
+ if (reasoningEffort) payload.reasoningEffort = reasoningEffort
1186
+ const selected = await proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1187
+ if (selected.kind !== 'select-model') return selected
1188
+ return {
1189
+ kind: 'command-selected',
1190
+ sessionId,
1191
+ command,
1192
+ selected: { ...option, selected: true },
1193
+ value: selected.selected,
1194
+ }
1195
+ }
1196
+
1197
+ return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
1198
+ }
1199
+
859
1200
  // Dispatch one mobile query frame; returns the wire frame to send back.
860
1201
  async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
861
1202
  if (msg.type === 'workspaces') {
@@ -986,6 +1327,99 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
986
1327
  if (msg.type === 'providers') {
987
1328
  return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
988
1329
  }
1330
+ if (msg.type === 'command-execute') {
1331
+ return admitCommand(typertGateway, msg)
1332
+ }
1333
+ if (msg.type === 'commands') {
1334
+ const sessionId = requireSessionId(msg)
1335
+ if (sessionId.error) return sessionId.error
1336
+ const copy = resolveCommandCatalogCopy(msg.locale)
1337
+ try {
1338
+ // This is the same session-scoped catalog used by the official Web UI.
1339
+ // Copy only the stable descriptor fields so no live Typert object crosses
1340
+ // the mobile wire. The Web UI also contributes /model client-side; expose
1341
+ // the equivalent action here so mobile clients can render the same menu.
1342
+ const [listed, skillFrame] = await Promise.all([
1343
+ listHostCommands(typertGateway, sessionId.value),
1344
+ api.skills && typeof api.skills.list === 'function'
1345
+ ? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
1346
+ : Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
1347
+ ])
1348
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
1349
+ const commands = listed
1350
+ .filter((command) => command && typeof command.name === 'string' && typeof command.description === 'string')
1351
+ .map((command) => ({
1352
+ id: `command:${command.name}`,
1353
+ name: command.name,
1354
+ description: command.description,
1355
+ source: 'host',
1356
+ action: 'execute',
1357
+ ui: commandUiDescriptor(command, copy),
1358
+ ...(command.input && typeof command.input.hint === 'string'
1359
+ ? { input: { hint: command.input.hint, ...(command.input.images === true ? { images: true } : {}) } }
1360
+ : {}),
1361
+ }))
1362
+ if (!commands.some((command) => command.name === 'model')) {
1363
+ commands.push({
1364
+ id: 'command:model',
1365
+ name: 'model',
1366
+ description: copy.modelDescription,
1367
+ source: 'client',
1368
+ action: 'select-model',
1369
+ ui: commandUiDescriptor({ name: 'model' }, copy),
1370
+ })
1371
+ }
1372
+ const skills = skillFrame.kind === 'skills' && Array.isArray(skillFrame.skills)
1373
+ ? skillFrame.skills
1374
+ .filter((skill) => skill && typeof skill.name === 'string' && typeof skill.description === 'string')
1375
+ .map((skill) => ({
1376
+ id: `skill:${skill.name}`,
1377
+ name: skill.name,
1378
+ description: skill.modelInvocable === true ? skill.description : `${copy.userOnly} · ${skill.description}`,
1379
+ source: 'skill',
1380
+ action: 'insert',
1381
+ modelInvocable: skill.modelInvocable === true,
1382
+ ...(typeof skill.whenToUse === 'string' && skill.whenToUse !== '' ? { whenToUse: skill.whenToUse } : {}),
1383
+ ui: skillUiDescriptor(skill),
1384
+ }))
1385
+ : []
1386
+ const groups = [
1387
+ { id: 'commands', title: copy.commandsTitle, items: commands },
1388
+ ...(skills.length > 0 ? [{ id: 'skills', title: copy.skillsTitle, items: skills }] : []),
1389
+ ]
1390
+ const warnings = skillFrame.kind === 'error'
1391
+ ? [{ source: 'skills', code: skillFrame.code, message: skillFrame.message }]
1392
+ : []
1393
+ log(`command catalog queried: session=${sessionId.value} commands=${commands.length} skills=${skills.length}`)
1394
+ return {
1395
+ kind: 'commands',
1396
+ sessionId: sessionId.value,
1397
+ locale: copy.locale,
1398
+ groups,
1399
+ ...(warnings.length > 0 ? { warnings } : {}),
1400
+ }
1401
+ } catch (error) {
1402
+ const code = error && error.code ? error.code : 'internal'
1403
+ const message = error && error.message ? error.message : String(error)
1404
+ log(`command catalog failed: session=${sessionId.value} ${code}: ${message}`)
1405
+ return { kind: 'error', code, message, requestType: 'commands', sessionId: sessionId.value }
1406
+ }
1407
+ }
1408
+ if (msg.type === 'command-options') {
1409
+ const sessionId = requireSessionId(msg)
1410
+ if (sessionId.error) return sessionId.error
1411
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1412
+ if (!command) return { kind: 'error', code: 'bad-request', message: 'command-options requires a command', requestType: 'command-options', sessionId: sessionId.value }
1413
+ return loadCommandOptions(api, command, sessionId.value)
1414
+ }
1415
+ if (msg.type === 'command-select') {
1416
+ const sessionId = requireSessionId(msg)
1417
+ if (sessionId.error) return sessionId.error
1418
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1419
+ const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
1420
+ if (!command || !optionId) return { kind: 'error', code: 'bad-request', message: 'command-select requires command and optionId', requestType: 'command-select', sessionId: sessionId.value }
1421
+ return selectCommandOption(api, typertGateway, command, sessionId.value, optionId)
1422
+ }
989
1423
  if (msg.type === 'select-model') {
990
1424
  const sessionId = requireSessionId(msg)
991
1425
  if (sessionId.error) return sessionId.error
@@ -1824,7 +2258,8 @@ const plugin = {
1824
2258
  })
1825
2259
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
1826
2260
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
1827
- msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
2261
+ msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2262
+ msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
1828
2263
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
1829
2264
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
1830
2265
  msg.type === 'session-stats' || msg.type === 'default-model' ||
@@ -1857,7 +2292,7 @@ const plugin = {
1857
2292
  ws.send(JSON.stringify({
1858
2293
  kind: 'hello',
1859
2294
  protocol: 3,
1860
- capabilities: ['images', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2295
+ capabilities: ['images', 'commands', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
1861
2296
  port: transport.port || webServer.port,
1862
2297
  clients: clients.size,
1863
2298
  authenticated: !!device,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.6.8",
4
- "description": "1.support permission requirement;\n2. support file transfer",
3
+ "version": "0.6.9",
4
+ "description": "Authenticated mobile gateway for DeepSeek Harness with server-driven commands, skills, approvals, and file transfers",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",