dsh-plugin-mobile-gateway 0.6.8 → 0.7.0

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.7.0)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -77,7 +77,7 @@ const pairingText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base6
77
77
  ```json
78
78
  { "kind": "paired", "token": "<长期设备 token>",
79
79
  "device": { "id": "...", "name": "iPhone", "createdAt": 1787111700000 } }
80
- { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"], "authenticated": true,
80
+ { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"], "authenticated": true,
81
81
  "device": { "id": "...", "name": "iPhone" }, "port": 3080, "clients": 1 }
82
82
  ```
83
83
 
@@ -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,` 前缀:
@@ -408,6 +546,8 @@ let image = [
408
546
  | `search` | `query` | 会话全文搜索 |
409
547
  | `session-stats` | `sessionId` | 执行统计投影(输入框统计条数据源) |
410
548
  | `context-usage` | `sessionId` | token 用量 + 上下文占用投影 |
549
+ | `tasks` | `sessionId` | 当前任务列表(`todos` projection) |
550
+ | `goal` | `sessionId` | 当前目标及其 CAS 版本(`goal` projection) |
411
551
 
412
552
  ### `history` 详细
413
553
  ```json
@@ -521,7 +661,93 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
521
661
 
522
662
  ---
523
663
 
524
- ## 6. 工作区与目录
664
+ ## 6. 任务列表与目标
665
+
666
+ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos` 和 `goal` session projection。移动端进入会话后应请求 `tasks` 与 `goal` 取得基线;随后以 `tasks-updated` / `goal-updated` 实时更新 UI。
667
+
668
+ ### 任务列表
669
+
670
+ ```json
671
+ { "type": "tasks", "sessionId": "session-abc" }
672
+ → {
673
+ "kind": "tasks",
674
+ "sessionId": "session-abc",
675
+ "asOfSeq": 42,
676
+ "todos": [
677
+ { "content": "检查 Android SDK", "status": "completed" },
678
+ { "content": "创建项目", "status": "in_progress" },
679
+ { "content": "构建 APK", "status": "pending" }
680
+ ]
681
+ }
682
+ ```
683
+
684
+ - `todos: null` 表示该会话尚未写入过任务列表;客户端可隐藏任务卡片。
685
+ - 任务由 Agent 的 `todo_write` 更新;移动端只读展示,不能直接改写。
686
+
687
+ ### 当前目标
688
+
689
+ ```json
690
+ { "type": "goal", "sessionId": "session-abc" }
691
+ → {
692
+ "kind": "goal",
693
+ "sessionId": "session-abc",
694
+ "asOfSeq": 42,
695
+ "goal": {
696
+ "goal": {
697
+ "id": "goal-opaque-id",
698
+ "revision": 7,
699
+ "objective": "初始化一个 Android app",
700
+ "phase": "active",
701
+ "maxGoalRounds": 12
702
+ },
703
+ "roundsStarted": 3,
704
+ "createdAt": 1787111700000,
705
+ "updatedAt": 1787111800000
706
+ }
707
+ }
708
+ ```
709
+
710
+ `goal: null` 表示没有当前目标。所有目标写操作必须携带刚读取到的 `{id, revision}`。这是 DSH 的 compare-and-set 保护:当 WebUI 或另一台设备已经修改目标时,宿主拒绝陈旧 revision,移动端应重新请求 `goal` 后再提示用户重试。
711
+
712
+ | type | 参数 | 说明 |
713
+ |---|---|---|
714
+ | `goal-edit` | `sessionId`, `ref`, `objective?`, `maxGoalRounds?` | 修改目标名称(`objective`)或轮数上限,至少提供一项 |
715
+ | `goal-pause` | `sessionId`, `ref` | 暂停当前目标 |
716
+ | `goal-resume` | `sessionId`, `ref` | 继续已暂停/阻塞的目标 |
717
+ | `goal-clear` | `sessionId`, `ref` | 删除当前目标 |
718
+
719
+ 更改目标名称:
720
+
721
+ ```json
722
+ { "type": "goal-edit", "sessionId": "session-abc",
723
+ "ref": { "id": "goal-opaque-id", "revision": 7 },
724
+ "objective": "完成 Android app 初始化" }
725
+ → { "kind": "goal-edit", "sessionId": "session-abc",
726
+ "ref": { "id": "goal-opaque-id", "revision": 8 } }
727
+ ```
728
+
729
+ 暂停、继续和删除只替换 `type`:
730
+
731
+ ```json
732
+ { "type": "goal-pause", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 8 } }
733
+ → { "kind": "goal-pause", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 9 } }
734
+
735
+ { "type": "goal-clear", "sessionId": "session-abc", "ref": { "id": "goal-opaque-id", "revision": 9 } }
736
+ → { "kind": "goal-clear", "sessionId": "session-abc", "cleared": true }
737
+ ```
738
+
739
+ ### 实时更新
740
+
741
+ ```json
742
+ { "kind": "tasks-updated", "sessionId": "session-abc", "asOfSeq": 43, "todos": [ ... ] }
743
+ { "kind": "goal-updated", "sessionId": "session-abc", "asOfSeq": 44, "goal": { ... } }
744
+ ```
745
+
746
+ 服务端只转发 `todos` 与 `goal` projection;客户端按 `asOfSeq` 做高序号覆盖,避免较早推送回写较新的查询结果。
747
+
748
+ ---
749
+
750
+ ## 7. 工作区与目录
525
751
 
526
752
  | type | 参数 | 说明 |
527
753
  |---|---|---|
@@ -551,7 +777,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
551
777
 
552
778
  ---
553
779
 
554
- ## 7. 模型与思考等级
780
+ ## 8. 模型与思考等级
555
781
 
556
782
  | type | 参数 | 说明 |
557
783
  |---|---|---|
@@ -583,7 +809,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
583
809
 
584
810
  ---
585
811
 
586
- ## 8. 权限控制
812
+ ## 9. 权限控制
587
813
 
588
814
  | type | 参数 | 说明 |
589
815
  |---|---|---|
@@ -598,7 +824,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
598
824
 
599
825
  ---
600
826
 
601
- ## 9. 新会话默认配置
827
+ ## 10. 新会话默认配置
602
828
 
603
829
  | type | 参数 | 说明 |
604
830
  |---|---|---|
@@ -616,7 +842,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
616
842
 
617
843
  ---
618
844
 
619
- ## 10. 分支(fork)
845
+ ## 11. 分支(fork)
620
846
 
621
847
  ```json
622
848
  { "type": "fork", "sessionId": "session-abc", "atSeq": 42 }
@@ -627,7 +853,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
627
853
 
628
854
  ---
629
855
 
630
- ## 11. 宿主信息
856
+ ## 12. 宿主信息
631
857
 
632
858
  | type | 返回 |
633
859
  |---|---|
@@ -641,13 +867,14 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
641
867
 
642
868
  ---
643
869
 
644
- ## 12. 服务端主动推送
870
+ ## 13. 服务端主动推送
645
871
 
646
872
  | kind | 触发时机 |
647
873
  |---|---|
648
874
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
649
- | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
875
+ | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images","commands","tasks","goals","file-downloads"], "authenticated":true, "port":3080, "clients":1 }` |
650
876
  | `event` | 任意会话的 agent 输出(见下) |
877
+ | `tasks-updated` / `goal-updated` | 当前会话的任务列表或目标 projection 发生变化 |
651
878
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
652
879
  | `approval-requested` / `approval-resolved` | Human-in-the-loop 操作审批请求与最终状态 |
653
880
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
@@ -667,7 +894,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
667
894
 
668
895
  ---
669
896
 
670
- ## 13. 端到端示例(Postman)
897
+ ## 14. 端到端示例(Postman)
671
898
 
672
899
  1. Connect → 收到 `hello`
673
900
  2. `{"type":"sessions"}` → 挑 `sessionId`(或直接下一步自动建)
@@ -680,7 +907,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
680
907
 
681
908
  ---
682
909
 
683
- ## 14. 安全注意
910
+ ## 15. 安全注意
684
911
 
685
912
  - `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
686
913
  - 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
@@ -695,7 +922,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
695
922
 
696
923
  ---
697
924
 
698
- ## 15. 版本历史(插件)
925
+ ## 16. 版本历史(插件)
699
926
 
700
927
  | 版本 | 新增 |
701
928
  |---|---|
@@ -720,6 +947,8 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
720
947
  | v0.6.6 | Human-in-the-loop 操作审批:转发 API Gateway approval 请求、一次性允许/拒绝、重连重放与多端最终状态收敛 |
721
948
  | v0.6.7 | 订阅已有 Session 时重放待处理 Human-in-the-loop 请求,并增加 Approval 端到端诊断日志与安装版本标记 |
722
949
  | v0.6.8 | 会话工作目录受限的文件列表与分块下载:支持图片、文档、IPA、APK 等普通文件,含连接归属、路径越界防护、取消、超时和 SHA-256 完整性校验 |
950
+ | v0.6.9 | 服务端驱动的命令与技能目录:支持本地化 Hint、通用二级选项、专用命令执行,以及 command/compaction 生命周期事件;Host 命令不再作为用户 Prompt 发送 |
951
+ | v0.7.0 | 任务与 Goal 对齐:任务/Goal 基线查询、`todos`/`goal` 实时投影、Goal 改名、暂停、继续与删除 |
723
952
 
724
953
  ---
725
954
 
package/README.md CHANGED
@@ -4,7 +4,9 @@
4
4
 
5
5
  # dsh-plugin-mobile-gateway
6
6
 
7
- 让 iPhone 通过经过设备鉴权的 WebSocket 连接 DeepSeek Harness。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
7
+ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、任务列表和当前 Goal 同步及管理、服务端驱动的命令和技能菜单、Human-in-the-loop、图片及文件传输。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
8
+
9
+ > v0.7.0:移动端现可与 WebUI 对齐展示任务列表和当前 Goal;支持 Goal 改名、暂停、继续和删除,并通过实时 projection 与 revision 校验处理多设备并发更新。
8
10
 
9
11
  - WebSocket:`/ws/mobile`
10
12
  - 局域网:`ws://<局域网 IP>:3081/ws/mobile`
@@ -13,7 +15,7 @@
13
15
 
14
16
  ## 配套 iOS 客户端
15
17
 
16
- [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop、模型与权限设置。
18
+ [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop,以及由网关配置驱动的命令、技能、模型与权限菜单。
17
19
 
18
20
  <table>
19
21
  <tr>
package/lib/index.mjs CHANGED
@@ -47,12 +47,20 @@
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
53
57
  // { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
54
58
  // { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
55
59
  // (the input-box stats strip source)
60
+ // { "type": "tasks", "sessionId" } -> current todo-list projection
61
+ // { "type": "goal", "sessionId" } -> current goal projection
62
+ // { "type": "goal-edit", "sessionId", "ref", "objective"?, "maxGoalRounds"? }
63
+ // { "type": "goal-pause"|"goal-resume"|"goal-clear", "sessionId", "ref" }
56
64
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
57
65
  // { "type": "defaults" } -> default agent preset + default permission
58
66
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
@@ -60,12 +68,20 @@
60
68
  // { "type": "question-cancel", "rpcId", "sessionId" }
61
69
  // { "type": "approval-response", "rpcId", "sessionId", "approvalId",
62
70
  // "outcome": "allowed-once"|"rejected" }
63
- // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "file-downloads"],
71
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images", "commands", "tasks", "goals", "file-downloads"],
64
72
  // "authenticated", "device"?, "port", "clients" }
65
73
  // { "kind": "pong", "at" }
66
74
  // { "kind": "subscribed", "sessionId" }
67
75
  // { "kind": "sent", "sessionId", "mode", "command"? }
68
76
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
77
+ // { "kind": "commands", "sessionId", "groups": [
78
+ // { "id", "title", "items": [{ "id", "name", "description", "ui" }] }
79
+ // ] }
80
+ // { "kind": "command-options", "sessionId", "command", "options": [
81
+ // { "id", "label", "detail"?, "description"?, "selected" }
82
+ // ] }
83
+ // { "kind": "command-executed", "sessionId", "line", "commandId", "result" }
84
+ // { "kind": "command-selected", "sessionId", "command", "selected" }
69
85
  // { "kind": "attachment", "sessionId", "attachment", "data" }
70
86
  // { "kind": "file-list", "requestId"?, "sessionId", "path", "entries" }
71
87
  // { "kind": "file-download-opened", "requestId", "transferId", "sessionId",
@@ -74,6 +90,12 @@
74
90
  // { "kind": "file-download-cancelled", "transferId" }
75
91
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
76
92
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
93
+ // { "kind": "tasks", "sessionId", "asOfSeq", "todos" }
94
+ // { "kind": "goal", "sessionId", "asOfSeq", "goal" }
95
+ // { "kind": "goal-edit"|"goal-pause"|"goal-resume", "sessionId", "ref" }
96
+ // { "kind": "goal-clear", "sessionId", "cleared": true }
97
+ // { "kind": "tasks-updated", "sessionId", "asOfSeq", "todos" }
98
+ // { "kind": "goal-updated", "sessionId", "asOfSeq", "goal" }
77
99
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
78
100
  // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
79
101
  // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
@@ -120,6 +142,42 @@ const INTERACTION_PROTOCOL_REVISION = 'question-approval-v2'
120
142
  const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
121
143
  const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
122
144
  const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
145
+ const COMMAND_UI_OVERRIDES = Object.freeze({
146
+ permission: Object.freeze({
147
+ kind: 'select',
148
+ optionsRequest: 'command-options',
149
+ selectionRequest: 'command-select',
150
+ }),
151
+ model: Object.freeze({
152
+ kind: 'select',
153
+ optionsRequest: 'command-options',
154
+ selectionRequest: 'command-select',
155
+ }),
156
+ })
157
+ const COMMAND_CATALOG_COPY = Object.freeze({
158
+ zh: Object.freeze({
159
+ locale: 'zh-CN',
160
+ commandsTitle: '命令',
161
+ skillsTitle: '技能',
162
+ userOnly: '仅用户',
163
+ modelDescription: '选择本会话使用的模型',
164
+ hints: Object.freeze({
165
+ plan: '描述你的任务以生成计划',
166
+ goal: '输入目标,智能体将持续执行',
167
+ }),
168
+ }),
169
+ en: Object.freeze({
170
+ locale: 'en',
171
+ commandsTitle: 'Commands',
172
+ skillsTitle: 'Skills',
173
+ userOnly: 'user-only',
174
+ modelDescription: 'Select the model for this conversation',
175
+ hints: Object.freeze({
176
+ plan: 'describe your task to generate plan',
177
+ goal: 'describe the objective for a long-running task',
178
+ }),
179
+ }),
180
+ })
123
181
 
124
182
  // Cordis validates this schema at plugin load and fills these defaults. Keep
125
183
  // the defaults conservative: installing the bundle must never create an
@@ -248,6 +306,55 @@ function buildWireEvent(session, event) {
248
306
  },
249
307
  })
250
308
  }
309
+ case 'command/run':
310
+ return Object.assign(base, {
311
+ event: {
312
+ type: 'command/run',
313
+ commandId: d.commandId,
314
+ name: d.name,
315
+ ...(typeof d.args === 'string' ? { args: d.args } : {}),
316
+ ...(d.source ? { source: d.source } : {}),
317
+ },
318
+ })
319
+ case 'command/done':
320
+ return Object.assign(base, {
321
+ event: {
322
+ type: 'command/done',
323
+ commandId: d.commandId,
324
+ outcome: d.kind,
325
+ ...(typeof d.text === 'string' ? { text: d.text } : {}),
326
+ ...(typeof d.sourceEventSeq === 'number' ? { sourceEventSeq: d.sourceEventSeq } : {}),
327
+ },
328
+ })
329
+ case 'compaction/start':
330
+ return Object.assign(base, {
331
+ event: {
332
+ type: 'compaction/start',
333
+ compactionId: d.compactionId,
334
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
335
+ turn: d.turn ?? null,
336
+ },
337
+ })
338
+ case 'compaction/summary':
339
+ return Object.assign(base, {
340
+ event: {
341
+ type: 'compaction/summary',
342
+ compactionId: d.compactionId,
343
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
344
+ shadowedItemCount: Array.isArray(d.shadowedSeqs) ? d.shadowedSeqs.length : null,
345
+ shadowedTokenCount: typeof d.shadowedTokenCount === 'number' ? d.shadowedTokenCount : null,
346
+ },
347
+ })
348
+ case 'compaction/end':
349
+ return Object.assign(base, {
350
+ event: {
351
+ type: 'compaction/end',
352
+ compactionId: d.compactionId,
353
+ ...(d.sourceCommandId ? { sourceCommandId: d.sourceCommandId } : {}),
354
+ turn: d.turn ?? null,
355
+ ...(typeof d.error === 'string' ? { error: d.error } : {}),
356
+ },
357
+ })
251
358
  case 'turn/start':
252
359
  case 'turn/end':
253
360
  case 'step/start':
@@ -260,31 +367,28 @@ function buildWireEvent(session, event) {
260
367
  }
261
368
  }
262
369
 
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
370
+ function parseWireImages(rawImages, requestType) {
371
+ const images = rawImages === undefined ? [] : rawImages
268
372
  if (!Array.isArray(images)) {
269
- return { kind: 'error', code: 'bad-request', message: 'images must be an array' }
373
+ return { error: { kind: 'error', code: 'bad-request', message: 'images must be an array', requestType } }
270
374
  }
271
375
  if (images.length > 20) {
272
- return { kind: 'error', code: 'bad-request', message: 'a message can contain at most 20 images' }
376
+ return { error: { kind: 'error', code: 'bad-request', message: 'a request can contain at most 20 images', requestType } }
273
377
  }
274
378
  const imageParts = []
275
379
  for (let index = 0; index < images.length; index++) {
276
380
  const image = images[index]
277
381
  if (!image || typeof image !== 'object') {
278
- return { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object` }
382
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object`, requestType } }
279
383
  }
280
384
  if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
281
- return { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported` }
385
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported`, requestType } }
282
386
  }
283
387
  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` }
388
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string`, requestType } }
285
389
  }
286
390
  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` }
391
+ return { error: { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters`, requestType } }
288
392
  }
289
393
  imageParts.push({
290
394
  type: 'image',
@@ -293,6 +397,88 @@ async function admitMessage(api, msg) {
293
397
  ...(image.name ? { name: image.name } : {}),
294
398
  })
295
399
  }
400
+ return { value: imageParts }
401
+ }
402
+
403
+ function commandNameOf(line) {
404
+ const trimmed = line.trim()
405
+ if (!trimmed.startsWith('/')) return null
406
+ const end = trimmed.search(/\s/)
407
+ return (end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)) || null
408
+ }
409
+
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
+ })
417
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
418
+ return listed
419
+ }
420
+
421
+ async function executeHostCommand(typertGateway, sessionId, line, images, requestType) {
422
+ 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
+ })
429
+ if (execution === undefined || execution === null) {
430
+ return { kind: 'error', code: 'unknown-command', message: `unknown or malformed command: ${line}`, requestType, sessionId }
431
+ }
432
+ log(`command executed: session=${sessionId} line=${JSON.stringify(line)} commandId=${execution.commandId} outcome=${execution.result.kind}`)
433
+ return {
434
+ kind: 'command-executed',
435
+ sessionId,
436
+ line,
437
+ commandId: execution.commandId,
438
+ result: execution.result,
439
+ }
440
+ } catch (error) {
441
+ const code = error && error.code ? error.code : 'internal'
442
+ const message = error && error.message ? error.message : String(error)
443
+ log(`command rejected: session=${sessionId} ${code}: ${message}`)
444
+ return { kind: 'error', code, message, requestType, sessionId }
445
+ }
446
+ }
447
+
448
+ async function admitCommand(typertGateway, msg) {
449
+ const sessionId = requireSessionId(msg)
450
+ if (sessionId.error) return sessionId.error
451
+ const line = typeof msg.line === 'string' ? msg.line.trim() : ''
452
+ const name = commandNameOf(line)
453
+ if (!name) {
454
+ return { kind: 'error', code: 'bad-request', message: 'command-execute requires a slash-prefixed line', requestType: 'command-execute', sessionId: sessionId.value }
455
+ }
456
+ const parsedImages = parseWireImages(msg.images, 'command-execute')
457
+ if (parsedImages.error) return { ...parsedImages.error, sessionId: sessionId.value }
458
+ try {
459
+ const listed = await listHostCommands(typertGateway, sessionId.value)
460
+ const descriptor = listed.find((command) => command && command.name === name)
461
+ if (!descriptor) {
462
+ return { kind: 'error', code: 'unknown-command', message: `command not found: /${name}`, requestType: 'command-execute', sessionId: sessionId.value }
463
+ }
464
+ if (parsedImages.value.length > 0 && descriptor.input?.images !== true) {
465
+ return { kind: 'error', code: 'bad-request', message: `/${name} does not accept image attachments`, requestType: 'command-execute', sessionId: sessionId.value }
466
+ }
467
+ return executeHostCommand(typertGateway, sessionId.value, line, parsedImages.value, 'command-execute')
468
+ } catch (error) {
469
+ const code = error && error.code ? error.code : 'internal'
470
+ const message = error && error.message ? error.message : String(error)
471
+ return { kind: 'error', code, message, requestType: 'command-execute', sessionId: sessionId.value }
472
+ }
473
+ }
474
+
475
+ // Handle one mobile -> agent message through the official host API. Returns
476
+ // the wire frame to send back, or null when nothing should be sent.
477
+ async function admitMessage(api, msg) {
478
+ const text = typeof msg.text === 'string' ? msg.text.trim() : ''
479
+ const parsedImages = parseWireImages(msg.images, 'message')
480
+ if (parsedImages.error) return parsedImages.error
481
+ const imageParts = parsedImages.value
296
482
  if (!text && imageParts.length === 0) {
297
483
  return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
298
484
  }
@@ -782,6 +968,54 @@ function requireSessionId(msg) {
782
968
  return { value: sessionId }
783
969
  }
784
970
 
971
+ // Goal mutations use DSH's compare-and-set reference. Returning a stale
972
+ // revision is intentionally rejected by DSH instead of overwriting a newer
973
+ // WebUI/mobile update.
974
+ function requireGoalRef(msg, sessionId) {
975
+ const ref = msg.ref
976
+ const id = ref && typeof ref.id === 'string' && ref.id.trim() !== '' ? ref.id.trim() : null
977
+ const revision = ref && typeof ref.revision === 'number' && Number.isSafeInteger(ref.revision) && ref.revision > 0
978
+ ? ref.revision
979
+ : null
980
+ if (!id || revision === null) {
981
+ return {
982
+ error: {
983
+ kind: 'error',
984
+ code: 'bad-request',
985
+ message: `${typeof msg.type === 'string' ? msg.type : 'goal mutation'} requires ref.id and a positive integer ref.revision`,
986
+ requestType: typeof msg.type === 'string' ? msg.type : 'goal',
987
+ sessionId,
988
+ },
989
+ }
990
+ }
991
+ return { value: { id, revision } }
992
+ }
993
+
994
+ async function readSessionProjection(api, type, sessionId, key) {
995
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
996
+ if (history.kind !== 'history') return history
997
+ const values = (history.projections && history.projections.values) || {}
998
+ return {
999
+ kind: type,
1000
+ sessionId,
1001
+ asOfSeq: history.projections ? history.projections.asOfSeq : undefined,
1002
+ [key]: Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null,
1003
+ }
1004
+ }
1005
+
1006
+ async function mutateGoal(api, type, method, payload) {
1007
+ if (!api.goals || typeof api.goals[method] !== 'function') {
1008
+ return {
1009
+ kind: 'error',
1010
+ code: 'unsupported',
1011
+ message: 'goal management is unavailable in this DSH host',
1012
+ requestType: type,
1013
+ sessionId: payload.sessionId,
1014
+ }
1015
+ }
1016
+ return proxyQuery(api, type, api.goals[method].bind(api.goals), payload)
1017
+ }
1018
+
785
1019
  // ---------------------------------------------------------------------------
786
1020
  // History frame sizing: raw session logs can be huge (trajectory, tool output,
787
1021
  // context), so a single history response must never blow the client's
@@ -856,6 +1090,171 @@ function capHistoryEvents(events, maxBytes, trim) {
856
1090
  return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
857
1091
  }
858
1092
 
1093
+ function resolveCommandCatalogCopy(locale) {
1094
+ return typeof locale === 'string' && !locale.toLowerCase().startsWith('zh')
1095
+ ? COMMAND_CATALOG_COPY.en
1096
+ : COMMAND_CATALOG_COPY.zh
1097
+ }
1098
+
1099
+ function commandUiDescriptor(command, copy) {
1100
+ const override = COMMAND_UI_OVERRIDES[command.name]
1101
+ if (override) return { ...override, insertText: `/${command.name}` }
1102
+ if (command.input && typeof command.input.hint === 'string') {
1103
+ const displayHint = copy.hints[command.name]
1104
+ return {
1105
+ kind: 'input',
1106
+ insertText: `/${command.name} `,
1107
+ hint: command.input.hint,
1108
+ ...(displayHint ? { displayHint } : {}),
1109
+ images: command.input.images === true,
1110
+ submitRequest: 'command-execute',
1111
+ }
1112
+ }
1113
+ return { kind: 'immediate', submitRequest: 'command-execute', submitText: `/${command.name}` }
1114
+ }
1115
+
1116
+ function skillUiDescriptor(skill) {
1117
+ return {
1118
+ kind: 'input',
1119
+ insertText: `/${skill.name} `,
1120
+ images: true,
1121
+ submitRequest: 'message',
1122
+ }
1123
+ }
1124
+
1125
+ function modelCommandOptionId(provider, model) {
1126
+ return Buffer.from(JSON.stringify([provider, model]), 'utf8').toString('base64url')
1127
+ }
1128
+
1129
+ function decodeModelCommandOptionId(optionId) {
1130
+ try {
1131
+ const value = JSON.parse(Buffer.from(optionId, 'base64url').toString('utf8'))
1132
+ if (!Array.isArray(value) || value.length !== 2 || value.some((part) => typeof part !== 'string' || part === '')) return null
1133
+ return { provider: value[0], model: value[1] }
1134
+ } catch {
1135
+ return null
1136
+ }
1137
+ }
1138
+
1139
+ async function loadCommandOptions(api, command, sessionId) {
1140
+ if (command === 'model') {
1141
+ const frame = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1142
+ if (frame.kind !== 'models') return frame
1143
+ const options = []
1144
+ for (const group of frame.groups || []) {
1145
+ for (const model of group.models || []) {
1146
+ options.push({
1147
+ id: modelCommandOptionId(String(group.id), String(model.id)),
1148
+ label: String(model.name || model.id),
1149
+ detail: String(group.name || group.id),
1150
+ ...(model.description ? { description: String(model.description) } : {}),
1151
+ selected: frame.current && frame.current.provider === group.id && frame.current.model === model.id,
1152
+ })
1153
+ }
1154
+ }
1155
+ return { kind: 'command-options', sessionId, command, options }
1156
+ }
1157
+
1158
+ if (command === 'permission') {
1159
+ const history = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
1160
+ if (history.kind !== 'history') return history
1161
+ const permissions = history.projections && history.projections.values && history.projections.values.permissions
1162
+ if (!permissions || !Array.isArray(permissions.options) || typeof permissions.currentValue !== 'string') {
1163
+ return {
1164
+ kind: 'error',
1165
+ code: 'command-options-unavailable',
1166
+ message: 'permission options are unavailable for this session',
1167
+ requestType: 'command-options',
1168
+ sessionId,
1169
+ }
1170
+ }
1171
+ return {
1172
+ kind: 'command-options',
1173
+ sessionId,
1174
+ command,
1175
+ options: permissions.options.map((option) => ({
1176
+ id: String(option.value),
1177
+ label: String(option.name || option.value),
1178
+ ...(option.description ? { description: String(option.description) } : {}),
1179
+ selected: option.value === permissions.currentValue,
1180
+ })),
1181
+ }
1182
+ }
1183
+
1184
+ return {
1185
+ kind: 'error',
1186
+ code: 'bad-request',
1187
+ message: `command does not provide selectable options: ${command}`,
1188
+ requestType: 'command-options',
1189
+ sessionId,
1190
+ }
1191
+ }
1192
+
1193
+ async function selectCommandOption(api, typertGateway, command, sessionId, optionId) {
1194
+ const catalog = await loadCommandOptions(api, command, sessionId)
1195
+ if (catalog.kind !== 'command-options') return catalog
1196
+ const option = catalog.options.find((candidate) => candidate.id === optionId)
1197
+ if (!option) {
1198
+ return {
1199
+ kind: 'error',
1200
+ code: 'bad-request',
1201
+ message: `unknown option for ${command}: ${optionId}`,
1202
+ requestType: 'command-select',
1203
+ sessionId,
1204
+ }
1205
+ }
1206
+
1207
+ if (command === 'permission') {
1208
+ 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
+ })
1215
+ if (!execution || !execution.result) throw new Error('permission command returned an invalid result')
1216
+ if (execution.result.kind === 'error') {
1217
+ return { kind: 'error', code: 'command-error', message: execution.result.text, requestType: 'command-select', sessionId }
1218
+ }
1219
+ return { kind: 'command-selected', sessionId, command, selected: { ...option, selected: true } }
1220
+ } catch (error) {
1221
+ const code = error && error.code ? error.code : 'internal'
1222
+ const message = error && error.message ? error.message : String(error)
1223
+ return { kind: 'error', code, message, requestType: 'command-select', sessionId }
1224
+ }
1225
+ }
1226
+
1227
+ if (command === 'model') {
1228
+ const selection = decodeModelCommandOptionId(optionId)
1229
+ if (!selection) {
1230
+ return { kind: 'error', code: 'bad-request', message: 'invalid model option id', requestType: 'command-select', sessionId }
1231
+ }
1232
+ const models = await proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
1233
+ if (models.kind !== 'models') return models
1234
+ const group = (models.groups || []).find((candidate) => candidate.id === selection.provider)
1235
+ const model = group && (group.models || []).find((candidate) => candidate.id === selection.model)
1236
+ if (!model) {
1237
+ return { kind: 'error', code: 'bad-request', message: 'model option is no longer available', requestType: 'command-select', sessionId }
1238
+ }
1239
+ const payload = { sessionId, ...selection }
1240
+ const reasoningEffort = models.current && models.current.provider === selection.provider && models.current.model === selection.model
1241
+ ? models.current.reasoningEffort
1242
+ : model.reasoning && model.reasoning.defaultEffort
1243
+ if (reasoningEffort) payload.reasoningEffort = reasoningEffort
1244
+ const selected = await proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
1245
+ if (selected.kind !== 'select-model') return selected
1246
+ return {
1247
+ kind: 'command-selected',
1248
+ sessionId,
1249
+ command,
1250
+ selected: { ...option, selected: true },
1251
+ value: selected.selected,
1252
+ }
1253
+ }
1254
+
1255
+ return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
1256
+ }
1257
+
859
1258
  // Dispatch one mobile query frame; returns the wire frame to send back.
860
1259
  async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
861
1260
  if (msg.type === 'workspaces') {
@@ -986,6 +1385,99 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
986
1385
  if (msg.type === 'providers') {
987
1386
  return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
988
1387
  }
1388
+ if (msg.type === 'command-execute') {
1389
+ return admitCommand(typertGateway, msg)
1390
+ }
1391
+ if (msg.type === 'commands') {
1392
+ const sessionId = requireSessionId(msg)
1393
+ if (sessionId.error) return sessionId.error
1394
+ const copy = resolveCommandCatalogCopy(msg.locale)
1395
+ try {
1396
+ // This is the same session-scoped catalog used by the official Web UI.
1397
+ // Copy only the stable descriptor fields so no live Typert object crosses
1398
+ // the mobile wire. The Web UI also contributes /model client-side; expose
1399
+ // the equivalent action here so mobile clients can render the same menu.
1400
+ const [listed, skillFrame] = await Promise.all([
1401
+ listHostCommands(typertGateway, sessionId.value),
1402
+ api.skills && typeof api.skills.list === 'function'
1403
+ ? proxyQuery(api, 'skills', api.skills.list.bind(api.skills), { sessionId: sessionId.value })
1404
+ : Promise.resolve({ kind: 'error', code: 'unsupported', message: 'skill catalog is unavailable', requestType: 'skills' }),
1405
+ ])
1406
+ if (!Array.isArray(listed)) throw new Error('commands/list returned an invalid catalog')
1407
+ const commands = listed
1408
+ .filter((command) => command && typeof command.name === 'string' && typeof command.description === 'string')
1409
+ .map((command) => ({
1410
+ id: `command:${command.name}`,
1411
+ name: command.name,
1412
+ description: command.description,
1413
+ source: 'host',
1414
+ action: 'execute',
1415
+ ui: commandUiDescriptor(command, copy),
1416
+ ...(command.input && typeof command.input.hint === 'string'
1417
+ ? { input: { hint: command.input.hint, ...(command.input.images === true ? { images: true } : {}) } }
1418
+ : {}),
1419
+ }))
1420
+ if (!commands.some((command) => command.name === 'model')) {
1421
+ commands.push({
1422
+ id: 'command:model',
1423
+ name: 'model',
1424
+ description: copy.modelDescription,
1425
+ source: 'client',
1426
+ action: 'select-model',
1427
+ ui: commandUiDescriptor({ name: 'model' }, copy),
1428
+ })
1429
+ }
1430
+ const skills = skillFrame.kind === 'skills' && Array.isArray(skillFrame.skills)
1431
+ ? skillFrame.skills
1432
+ .filter((skill) => skill && typeof skill.name === 'string' && typeof skill.description === 'string')
1433
+ .map((skill) => ({
1434
+ id: `skill:${skill.name}`,
1435
+ name: skill.name,
1436
+ description: skill.modelInvocable === true ? skill.description : `${copy.userOnly} · ${skill.description}`,
1437
+ source: 'skill',
1438
+ action: 'insert',
1439
+ modelInvocable: skill.modelInvocable === true,
1440
+ ...(typeof skill.whenToUse === 'string' && skill.whenToUse !== '' ? { whenToUse: skill.whenToUse } : {}),
1441
+ ui: skillUiDescriptor(skill),
1442
+ }))
1443
+ : []
1444
+ const groups = [
1445
+ { id: 'commands', title: copy.commandsTitle, items: commands },
1446
+ ...(skills.length > 0 ? [{ id: 'skills', title: copy.skillsTitle, items: skills }] : []),
1447
+ ]
1448
+ const warnings = skillFrame.kind === 'error'
1449
+ ? [{ source: 'skills', code: skillFrame.code, message: skillFrame.message }]
1450
+ : []
1451
+ log(`command catalog queried: session=${sessionId.value} commands=${commands.length} skills=${skills.length}`)
1452
+ return {
1453
+ kind: 'commands',
1454
+ sessionId: sessionId.value,
1455
+ locale: copy.locale,
1456
+ groups,
1457
+ ...(warnings.length > 0 ? { warnings } : {}),
1458
+ }
1459
+ } catch (error) {
1460
+ const code = error && error.code ? error.code : 'internal'
1461
+ const message = error && error.message ? error.message : String(error)
1462
+ log(`command catalog failed: session=${sessionId.value} ${code}: ${message}`)
1463
+ return { kind: 'error', code, message, requestType: 'commands', sessionId: sessionId.value }
1464
+ }
1465
+ }
1466
+ if (msg.type === 'command-options') {
1467
+ const sessionId = requireSessionId(msg)
1468
+ if (sessionId.error) return sessionId.error
1469
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1470
+ if (!command) return { kind: 'error', code: 'bad-request', message: 'command-options requires a command', requestType: 'command-options', sessionId: sessionId.value }
1471
+ return loadCommandOptions(api, command, sessionId.value)
1472
+ }
1473
+ if (msg.type === 'command-select') {
1474
+ const sessionId = requireSessionId(msg)
1475
+ if (sessionId.error) return sessionId.error
1476
+ const command = typeof msg.command === 'string' && msg.command.trim() !== '' ? msg.command.trim() : null
1477
+ const optionId = typeof msg.optionId === 'string' && msg.optionId !== '' ? msg.optionId : null
1478
+ 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)
1480
+ }
989
1481
  if (msg.type === 'select-model') {
990
1482
  const sessionId = requireSessionId(msg)
991
1483
  if (sessionId.error) return sessionId.error
@@ -1081,6 +1573,39 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
1081
1573
  contextPressure: values.contextPressure || null,
1082
1574
  }
1083
1575
  }
1576
+ if (msg.type === 'tasks') {
1577
+ const sessionId = requireSessionId(msg)
1578
+ if (sessionId.error) return sessionId.error
1579
+ return readSessionProjection(api, 'tasks', sessionId.value, 'todos')
1580
+ }
1581
+ if (msg.type === 'goal') {
1582
+ const sessionId = requireSessionId(msg)
1583
+ if (sessionId.error) return sessionId.error
1584
+ return readSessionProjection(api, 'goal', sessionId.value, 'goal')
1585
+ }
1586
+ if (msg.type === 'goal-edit') {
1587
+ const sessionId = requireSessionId(msg)
1588
+ if (sessionId.error) return sessionId.error
1589
+ const ref = requireGoalRef(msg, sessionId.value)
1590
+ if (ref.error) return ref.error
1591
+ const payload = { sessionId: sessionId.value, ref: ref.value }
1592
+ if (typeof msg.objective === 'string' && msg.objective.trim() !== '') payload.objective = msg.objective.trim()
1593
+ if (typeof msg.maxGoalRounds === 'number' && Number.isSafeInteger(msg.maxGoalRounds) && msg.maxGoalRounds > 0) {
1594
+ payload.maxGoalRounds = msg.maxGoalRounds
1595
+ }
1596
+ if (payload.objective === undefined && payload.maxGoalRounds === undefined) {
1597
+ return { kind: 'error', code: 'bad-request', message: 'goal-edit requires a non-empty objective or a positive maxGoalRounds', requestType: 'goal-edit', sessionId: sessionId.value }
1598
+ }
1599
+ return mutateGoal(api, 'goal-edit', 'edit', payload)
1600
+ }
1601
+ if (msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
1602
+ const sessionId = requireSessionId(msg)
1603
+ if (sessionId.error) return sessionId.error
1604
+ const ref = requireGoalRef(msg, sessionId.value)
1605
+ if (ref.error) return ref.error
1606
+ const method = msg.type === 'goal-pause' ? 'pause' : msg.type === 'goal-resume' ? 'resume' : 'clear'
1607
+ return mutateGoal(api, msg.type, method, { sessionId: sessionId.value, ref: ref.value })
1608
+ }
1084
1609
  if (msg.type === 'agent-presets') {
1085
1610
  return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
1086
1611
  }
@@ -1824,11 +2349,14 @@ const plugin = {
1824
2349
  })
1825
2350
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
1826
2351
  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' ||
2352
+ msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2353
+ msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
1828
2354
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
1829
2355
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
1830
2356
  msg.type === 'session-stats' || msg.type === 'default-model' ||
1831
- msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers') {
2357
+ msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers' ||
2358
+ msg.type === 'tasks' || msg.type === 'goal' || msg.type === 'goal-edit' ||
2359
+ msg.type === 'goal-pause' || msg.type === 'goal-resume' || msg.type === 'goal-clear') {
1832
2360
  handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
1833
2361
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1834
2362
  })
@@ -1857,7 +2385,7 @@ const plugin = {
1857
2385
  ws.send(JSON.stringify({
1858
2386
  kind: 'hello',
1859
2387
  protocol: 3,
1860
- capabilities: ['images', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
2388
+ capabilities: ['images', 'commands', 'tasks', 'goals', ...(options.fileDownloadsEnabled ? ['file-downloads'] : [])],
1861
2389
  port: transport.port || webServer.port,
1862
2390
  clients: clients.size,
1863
2391
  authenticated: !!device,
@@ -1989,6 +2517,17 @@ const plugin = {
1989
2517
  outcome: payload.outcome,
1990
2518
  })
1991
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}`)
1992
2531
  } else {
1993
2532
  log(`interaction ignored: type=${payload.type}`)
1994
2533
  }
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.7.0",
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": [
7
7
  "bin",