dsh-plugin-mobile-gateway 0.7.5 → 0.7.7

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
@@ -174,6 +174,8 @@ func connectAuthenticated(publicURL: URL, token: String) -> URLSessionWebSocketT
174
174
  `subscribed` 之后,服务端会紧接着发送该 Session 尚未处理的
175
175
  `question-requested` / `approval-requested`,并标记 `replay: true`。客户端必须按
176
176
  `rpcId` 去重。这保证移动端在审批产生后才打开已有 Session 时仍能显示待处理卡片。
177
+ 启用 `split-channels` 时,`subscribe` 仍由 conversation 连接发送,但上述重放帧会
178
+ 桥接到 control 连接;交互卡片不得发送到 conversation 连接。
177
179
 
178
180
  ---
179
181
 
@@ -186,9 +188,16 @@ Human-in-the-loop 分为两条独立通道:
186
188
 
187
189
  二者都是 Host waterfall 的临时请求,不属于持久化的 `session/event`,且都必须以插件为该次请求生成的 `rpcId` 通过专用响应帧回答,不能作为普通 `message` 发送。该内部实现不改变移动端帧格式。
188
190
 
191
+ 同一请求遵循“任意端先完成、所有端同步收起”的单胜方规则:Mobile 任一设备先完成时,Gateway 会结束 Host 的 WebUI 远端请求并让所有 WebUI 实例收起;任一 WebUI 先完成时,Host 会取消其余 WebUI 实例,Gateway 同时向所有 Mobile 设备广播终态。迟到响应统一视为 `not-pending`。
192
+
189
193
  ### 3.1 提问与回答
190
194
 
191
- Agent 调用 DSH 的 `ask_user_question` 工具时,插件直接接入 Host 的 `user-questions/request` waterfall,并把临时请求投影给移动端。若没有可处理该 Session 的移动连接,插件调用 `next()`,由 WebUI 或后续 Host answerer 处理。
195
+ Agent 调用 DSH 的 `ask_user_question` 工具时,插件接入 Host 的
196
+ `user-questions/request` waterfall,把临时请求投影给移动端,同时继续启动 WebUI
197
+ 或后续 Host answerer。任一端先给出有效答案即完成该请求;Gateway 会向所有移动端
198
+ 广播 `question-resolved`。Mobile 先完成时,Gateway 同时取消 WebUI 的对应远端请求,
199
+ 使其收起半屏弹窗;WebUI 先完成时,移动端收到终态后收起对应卡片。若 Mobile 已断开,
200
+ 则由仍在等待的 Host answerer 继续处理。
192
201
 
193
202
  #### `question-requested` — 服务端推送问题
194
203
 
@@ -292,7 +301,12 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件直接接入 Host 的
292
301
 
293
302
  ### 3.2 操作审批
294
303
 
295
- 当 DSH 的工具管线要求人工授权时,插件会接入一次 Host `approval/request` waterfall,并向移动端投影为 `approval-requested`。这正是 Web UI 中“等待审批”卡片对应的能力:`reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时工具调用轨迹关联。
304
+ 当 DSH 的工具管线要求人工授权时,插件会接入一次 Host `approval/request`
305
+ waterfall,并向移动端投影为 `approval-requested`,同时保留 WebUI answerer;WebUI
306
+ 与 Mobile 中任一端先提交的有效决定生效。Gateway 随后向所有移动端广播
307
+ `approval-resolved`;若 Mobile 先完成,还会取消 WebUI 的对应远端请求以收起审批弹窗。
308
+ `reason` 是面向用户的审批说明,`toolName` 标识请求操作的工具,`callId` 可用于与实时
309
+ 工具调用轨迹关联。
296
310
 
297
311
  #### `approval-requested` — 服务端推送待审批操作
298
312
 
@@ -956,6 +970,8 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
956
970
  | type | 参数 | 说明 |
957
971
  |---|---|---|
958
972
  | `agent-presets` | — | preset 名册(含 `isDefault` 标记) |
973
+ | `session-agent-preset` | `sessionId`, `requestId?` | 读取当前会话的 `agentPreset` 和 `locked` |
974
+ | `select-agent-preset` | `sessionId`, `agentPreset`, `requestId?` | 在首次对话前修改当前会话模式,保持 session ID |
959
975
  | `defaults` | — | 读取默认 agent 预设 + 默认权限 |
960
976
  | `set-default` | `target`(agent-preset\|permission), `value` | 修改默认预设/默认权限(全局) |
961
977
 
@@ -967,6 +983,47 @@ WebUI 中的“任务”与“进行中的目标”分别对应 DSH 的 `todos`
967
983
  → { "kind": "set-default", "target": "agent-preset", "value": "minimal", "applied": true }
968
984
  ```
969
985
 
986
+ ### 当前会话模式选择
987
+
988
+ `hello.capabilities` 包含 `session-agent-preset` 时,可以进入已创建的空白会话后选模式。
989
+ 上述两个新增请求走 Control 通道,成功和错误响应均携带 `sessionId` 与原始可选 `requestId`。
990
+
991
+ ```json
992
+ { "type": "session-agent-preset", "sessionId": "s1", "requestId": "get-mode-1" }
993
+ → { "kind": "session-agent-preset", "sessionId": "s1", "agentPreset": "standard", "locked": false, "requestId": "get-mode-1" }
994
+
995
+ { "type": "select-agent-preset", "sessionId": "s1", "agentPreset": "minimal", "requestId": "set-mode-1" }
996
+ → { "kind": "select-agent-preset", "sessionId": "s1", "agentPreset": "minimal", "requestId": "set-mode-1" }
997
+ ```
998
+
999
+ 候选项使用 `agent-presets.presets`:显示 `name`(缺失时使用 `id`),提交 `id`;带 `broken` 的项不可选。
1000
+ 当前值以 `session-agent-preset.agentPreset` 为准,不能用全局 `isDefault` 或 Session header 代替。
1001
+ Host 在同一 Session 内重组 Agent 并持久化选择,不通过修改全局默认模式实现。
1002
+
1003
+ 首次消息被 gateway 接受后即锁定;已记录的用户输入或 `turn/start` 也会锁定。
1004
+ gateway 对同一 Session 的模式切换与消息提交串行处理,Host 同时保留自身的对话边界检查。
1005
+ 未知/损坏的模式由 Host 拒绝,之前的模式保持;停止或完成对话不会恢复可修改状态。
1006
+ 如果状态投影缺失,返回 `agent-preset/unavailable`,客户端不得把未知状态当作可修改。
1007
+
1008
+ ```json
1009
+ { "kind": "error", "requestType": "select-agent-preset", "sessionId": "s1", "requestId": "set-mode-2", "code": "agent-preset/locked", "message": "session has already started; its agent preset is fixed" }
1010
+ ```
1011
+
1012
+ Control/旧单连接接收全局模式状态增量,不受所订阅会话过滤;只合并实际携带的字段:
1013
+
1014
+ ```json
1015
+ { "kind": "session-agent-preset-updated", "sessionId": "s1", "agentPreset": "minimal", "seq": 12, "time": 1000 }
1016
+ { "kind": "session-agent-preset-updated", "sessionId": "s1", "locked": true, "seq": 13, "time": 1100 }
1017
+ ```
1018
+
1019
+ 模式变化后,App 应丢弃该会话的命令/技能缓存,重新查询;对话通道的 `agent-preset/selected` 事件也保留 `event.agentPreset`。
1020
+ 增量没有连接基线,进入会话及重连后必须重查 `session-agent-preset`;收到锁定通知或错误后锁定控件。
1021
+ 发送首条消息和切换模式期间暂时禁用操作,等待切换成功再发送消息;不要用旧查询响应覆盖后到的锁定通知。
1022
+
1023
+ `session-create` 和不带 `sessionId` 的首条 `message` 也支持可选 `agentPreset`。
1024
+ 已带 `sessionId` 的 `message` 不接受该字段;应提前调用 `select-agent-preset`。
1025
+ 详细 App 状态与验收步骤见 [会话模式接入说明](docs/session-agent-preset-app-integration.md)。
1026
+
970
1027
  ---
971
1028
 
972
1029
  ## 11. 分支(fork)
package/README.md CHANGED
@@ -28,6 +28,8 @@ DeepSeek Harness 的设备鉴权移动网关,支持会话与实时事件、排
28
28
  - Linux 服务器公网:`wss://<公网 IP>/ws/mobile`
29
29
  - 协议文档:[PROTOCOL.md](PROTOCOL.md)
30
30
 
31
+ 当前源码新增会话模式选择:App 创建空白 Session 后,可查询并修改该 Session 的 Agent preset;首次对话后锁定。通过 `session-agent-preset` 能力发现接入,详见 [App 接入说明](docs/session-agent-preset-app-integration.md)。App 界面需按此说明接入,本次源码尚未发布。
32
+
31
33
  ## 多网关第一阶段(当前源码)
32
34
 
33
35
  插件提供稳定 `gatewayId`、可配置 `gatewayName`、配对候选地址列表,以及可持久化的“关闭 / 临时开启 / 常驻开启”运行模式。一个 App 可以分别配对不同机器上的网关;客户端多网关管理仍需按 [App 对接说明](docs/multi-gateway-app-integration.md) 实现。本次源码尚未发布新的 npm 版本。
@@ -0,0 +1,122 @@
1
+ # DSH 0.1.6-alpha.1 协议兼容性审计
2
+
3
+ 核对日期:2026-09-16。
4
+
5
+ ## 结论
6
+
7
+ 当前 gateway `0.7.5` **不能直接声明完整兼容 DSH `0.1.6-alpha.1`**。确定的主要阻断是权限选择协议拆分:新版会话投影不再包含候选列表,而 gateway 的权限菜单仍依赖该列表,导致读取菜单和通过菜单切换权限均失败。
8
+
9
+ 核心会话、消息提交、历史分页、Assistant 独立流、普通问答和人工审批没有发现本次升级引入的接口阻断。另有默认模式的条件性语义差异、SSH 文件访问缺口、版本误报及新增事件元数据丢失。
10
+
11
+ 本次仅增加审计文档;没有修改生产代码、升级本机 DSH 或迁移用户会话。
12
+
13
+ ## 基线与验证范围
14
+
15
+ - gateway:提交 `a92a039332bcfa4a1fadc55888e6b7a7e80dfd3d`,版本 `0.7.5`;开始审计时工作区干净。
16
+ - 上游:对比官方 `dsh-v0.1.5-rc.2` 与 `dsh-v0.1.6-alpha.1` 两个 tag 的完整源码。
17
+ - 发布包:分别下载两版 Session Controller、Workspace Controller、Settings Controller、Commands、Goal、Agent Presets、LLM 共七个包,比较生成的 `typert.host.js`;另外检查新版 Permission Presets 发布包。
18
+ - 七组包的旧版 55 个 Remote 端点没有删除;新版增加 `workspace/unarchiveSession`。现有入参 schema 唯一差异为 `session/updateQueue` 的图片块增加可选 `offloaded: true`,没有要求旧调用增加必填参数。此数量不代表 DSH 全部端点;新 Permission Presets 包另提供 `permissionPresets/catalog`。
19
+ - 用现有 Host Adapter 测试的真实调用,附加新版发布描述符的参数名与 schema 校验:18 次调用、14 个不同端点通过。
20
+ - `npm test` 全部通过,包括 125 项 gateway dispatch 检查、认证配对、LAN、多网关 11 项检查,以及 Host Adapter、Session Follower、Unicode wire、setup 测试。首次运行被沙箱禁止监听端口;允许本地监听后完整运行成功。
21
+ - 使用当前生产 `handleQuery()` 配合新版权限投影样例,复现下述权限菜单问题。
22
+
23
+ 验证边界:没有执行新版真实 Host 与手机全流程联调。发布描述符输入验证及旧测试通过,不代表业务行为完整兼容;权限问题恰好说明旧测试夹具可能掩盖新版差异。
24
+
25
+ 官方依据:[发布说明](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.1)、[版本比较](https://github.com/deepseek-ai/deepseek-harness/compare/dsh-v0.1.5-rc.2...dsh-v0.1.6-alpha.1)。
26
+
27
+ ## 1. 必须适配:权限目录从会话投影拆出
28
+
29
+ 旧版 `projections.values.permissions`:
30
+
31
+ ```json
32
+ {"options":[{"value":"workspace-write","name":"Workspace Write"}],"currentValue":"workspace-write"}
33
+ ```
34
+
35
+ 新版会话投影只保留:
36
+
37
+ ```json
38
+ {"currentValue":"workspace-write"}
39
+ ```
40
+
41
+ 候选项改从新的进程级目录读取:
42
+
43
+ ```js
44
+ typertGateway.invoke({
45
+ namespace: 'permissionPresets',
46
+ method: 'catalog',
47
+ args: {},
48
+ })
49
+ // 返回 { options: [{ value, name, description? }] }
50
+ ```
51
+
52
+ 源码在 `packages/interaction/permission-presets/src/index.ts` 注册的投影视图明确只有 `currentValue`;这不是单纯的类型重命名。新版 `auto` 由运行中的 Auto review 插件动态贡献,不属于默认权限设置中的静态候选表。`custom` 是派生的当前状态,不是可切换候选项。
53
+
54
+ gateway 受影响位置:
55
+
56
+ - `lib/index.mjs:1211` 的 `loadCommandOptions()` 强制要求 `permissions.options`。
57
+ - `lib/index.mjs:1245` 的 `selectCommandOption()` 先查询上述菜单,因此写入前就失败。
58
+ - `lib/index.mjs:1629` 的旧 `permission-options` 接口直接透传投影,新版返回值不再包含 `sessionPermissions.options`;它读取的 settings namespace 也无法补全动态 Auto 候选。
59
+
60
+ 生产函数复现结果:
61
+
62
+ | 请求 | 当前结果 |
63
+ |---|---|
64
+ | `command-options`,`command=permission` | `command-options-unavailable` |
65
+ | `command-select`,`command=permission` | 同样失败,尚未执行写入;还沿用 `requestType=command-options` |
66
+ | `permission-options` | 成功,但 `sessionPermissions` 只剩 `currentValue` |
67
+ | 直接 `permission`,明确传入有效名称 | 可到达原有 `/permission` 执行入口;没有被菜单前置检查阻断 |
68
+
69
+ 建议:Host Adapter 增加权限目录查询,将 catalog 的候选项与会话投影的 `currentValue` 合成现有移动菜单和兼容响应。保留 `/permission` 写入路径;动态目录需在使用前刷新,若缓存则处理 `permission-presets/catalog-changed`。会话 Auto 选项不能写成全局默认权限。
70
+
71
+ 这可以在 gateway 内适配,现有 `command-options` / `command-select` 移动消息形状可继续使用。若 App 自行解析历史中的权限投影,则也需要核对其是否依赖旧 `options` 字段。
72
+
73
+ 依据:[权限类型变更](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/interaction/permission-presets/src/types.ts)、[权限目录与投影实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/interaction/permission-presets/src/index.ts)。
74
+
75
+ ## 2. 条件性不一致与元数据缺口
76
+
77
+ | 项目 | 新版变化及当前影响 | 建议 |
78
+ |---|---|---|
79
+ | 默认 Agent 模式 | 增加 `modeSelectionEnabled`。为 false 时,Host 对未指定 preset 的新会话使用配置默认值,忽略保存的用户默认值。gateway 的 `defaults` 仍返回 settings 中保存的 `default`;`set-default` 返回 `applied: true` 也不代表后续新会话采用该值 | 查询有效默认值时结合 `agentPresets/list` 的 `isDefault` 和 `modeSelectionEnabled`;区分已保存与实际生效。`agent-presets` 原始响应已经透传新字段 |
80
+ | SSH 工作区 | 新版文件/进程可通过 SSH provider 操作远端。gateway `resolveSessionRoot()`、文件列表、下载及目录操作仍使用本机 `node:fs` | 启用 SSH provider 的部署不应宣称文件功能兼容;接入 Host 文件能力或明确禁用。远端路径本机不存在会失败,同名本机路径存在时可能访问错误目录。普通本地工作区不受此项影响 |
81
+ | 宿主版本误报 | `lib/dsh-host-adapter.mjs:16` 将 `DSH_VERSION` 写死为 `0.1.5-rc.2`;`host.describe` 和 `hello` 均使用该值 | 改为真实宿主版本或明确的兼容目标字段。这不是运行时版本拒绝逻辑,但会误导诊断/版本判断 |
82
+ | `image/offload` | 新增持久事件,`data.targets` 包含消息 `seq` 和 `imageIndexes`。gateway 接受其真实序号,历史保留原始数据;实时 `buildWireEvent()` 的默认分支只发事件类型,丢弃 targets | 基础聊天不因未知类型直接中断,但实时 offload 语义缺失;需要展示模型实际保留的图片上下文时补齐映射 |
83
+ | Auto review 拒绝原因 | `tool/result.data.error` 增加可选 `reason`,位于模型消息之外。实时映射只保留 `isError` 和模型内容 preview,丢失结构化错误名、代码和原始原因 | 保留必要的 error 元信息。历史仍保留这些数据,实时展示存在缺口 |
84
+
85
+ Auto review 使用 `tools/pre-execute` 的自动决策入口。普通 `approval/request` / `user-questions/request` 的签名及回答形状没有改变,不能据此把所有审批流判定为失效。
86
+
87
+ 依据:[默认模式选择策略](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/preset/agent-presets/src/index.ts)、[SSH 文件系统提供方](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/ssh/fs-ssh/README.zh.md)、[image/offload 定义](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/compaction/compaction-image-offload/src/projection.ts)、[Session 工具错误定义](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/core/session/src/types.ts)、[Auto review 实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/experimental/auto-review/src/index.ts)。
88
+
89
+ ## 3. 保持兼容或仅增加能力的部分
90
+
91
+ | 范围 | 核对结果 |
92
+ |---|---|
93
+ | 会话格式 | `SESSION_FORMAT_VERSION` 仍为 3。本次没有 rc.2 那次跨格式的历史游标迁移;不要把 DSH 软件版本、Session 格式和移动握手值混为一谈 |
94
+ | 会话读取和控制 | `session/list` 仍使用 `{ _request: {} }`;`follow/page/control` 入参保持不变。Assistant 的快照及 `start/chunk/end` 协定保持不变 |
95
+ | 消息、命令、队列 | prompt 原有文字和图片结构仍有效;命令仍使用 `submittedAttachments`;队列图片块新增可选 `offloaded`,现有文字编辑请求不需修改 |
96
+ | Session fork | 参数不变,切点改为所选边界 `seq + 1`,不再继续带入下次 `turn/start` 前的后续输入/设置。这是上游行为修复,gateway 原有 atSeq 转发无需改协议 |
97
+ | 工作区归档 | 原有 archive 和归档集合推送保持;增加 `workspace/unarchiveSession({ request: { sessionId } })`。gateway 尚未提供恢复归档请求,但从 Web 恢复后,可通过已有 workspace stream 同步归档集合 |
98
+ | Skill / 命令目录 | Skill 增加可选 `path`,CommandDescriptor 增加可选 `definitionId`;当前按名字执行不受影响,移动组合菜单没有保留这两个新元信息 |
99
+ | 插件生命周期 | `agent/session-start` 移除,`agent/created` 改为等待完成的异步串行初始化;gateway 不监听这两个事件,不需要修改该 hook |
100
+ | 同步 Session 历史 API | `snapshotEvents/eventAt/ownEvents` 是弃用,非本版本全部删除。gateway 通过 Remote follow/page 读取,不直接依赖这些同步 API |
101
+ | Host 服务及 Web UI | `typertGateway.invoke/stream`、`agentDefaultModel`、`webServer.register/registerUpgrade` 及当前 sidebar/footer、shell/overlay 集成入口未发现阻断变化 |
102
+
103
+ 依据:[Session Remote 类型](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/session-controller/src/types.ts)、[fork 实现](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/session-controller/src/commands.ts)、[Workspace Controller](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/api/workspace-controller/src/index.ts)、[Agent 生命周期](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/core/agent/src/runtime-types.ts)。
104
+
105
+ ## 4. 模型协议和部署配置变化
106
+
107
+ DeepSeek 默认由 Chat Completions 改为 Messages,并增加 Files API 图片复用。这发生在 Host 到模型服务之间;gateway 使用 Host 的统一 Session/LLM 数据模型,不需要把移动 WebSocket 改成 Anthropic Messages。
108
+
109
+ 显式配置旧官方根地址的部署,需要移除覆盖或使用 `https://api.deepseek.com/anthropic`;自定义 API 地址会保留,因此要核对其与所选协议是否匹配。否则可能出现手机正常连接、模型请求失败的情况。本次没有读取用户凭据或验证实际模型服务配置。
110
+
111
+ PTC 包/服务统一改为 `ptc-runtime`、工作流执行器改为 `workflow-ptc`、E2B 移除、Ralph 默认关闭,以及 Sandbox/Shell 异步接口变化,均需使用相关自定义插件或配置的部署另行适配。当前 gateway 没有这些直接依赖,bundle patch 也没有引用这些旧名称。终端、MCP resources、Browser/Computer Use 属于新增能力,不能据此认定当前移动端已经支持。
112
+
113
+ 依据:[DeepSeek 协议默认配置](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.1.6-alpha.1/packages/llm/llm-deepseek/src/config.ts)、[官方发布说明](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.1)。
114
+
115
+ ## 建议顺序
116
+
117
+ 1. 优先修复权限目录适配,覆盖新版只有 `currentValue` 的投影、动态 Auto、派生 custom、菜单查询与选择及兼容接口。
118
+ 2. 修正宿主版本信息和默认 Agent 模式的有效值语义。
119
+ 3. 本地工作区完成真实 Host 与 App 的消息、生成中重连、历史分页、fork、权限切换、问答/审批、文件下载及归档同步联调。
120
+ 4. 单独决定是否接入恢复归档、SSH 文件访问、image/offload 展示和 Auto review 错误详情。
121
+
122
+ 临时证据目录:`/private/tmp/dsh-alpha1-audit/`,含双版本源码、npm 包、`check-contract.mjs`、`schema-deltas.json`、`reproduce-permissions.mjs`、`permission-reproduction.json` 和完整测试日志。临时探针没有改动仓库生产实现或原有测试。
@@ -0,0 +1,93 @@
1
+ # App 会话运行模式接入说明
2
+
3
+ ## 实现范围
4
+
5
+ gateway 已增加同一 Session 内的 Agent 模式查询、切换、锁定与通知。App 界面尚未修改;本次源码没有发布 npm,也没有升级本机 DSH。
6
+
7
+ 用户流程:创建空白会话 → 进入会话页 → 选择已有模式,可重复修改 → 发送第一条消息 → 模式固定,后续完成、停止、重连均不可再次修改。
8
+
9
+ 这里的模式是 Agent preset,例如 `standard`、`minimal`,与模型选择、权限模式及 gateway 的常驻/临时运行模式相互独立。
10
+
11
+ ## 接入顺序
12
+
13
+ 1. 检查 `hello.capabilities` 是否包含 `session-agent-preset`。没有则不提供当前会话切换入口。
14
+ 2. 用现有 `session-create` 创建会话,收到 `session-created` 后以其 `sessionId` 进入会话页。
15
+ 3. 在 Control 通道查询 `agent-presets` 和 `session-agent-preset`;查询未完成期间禁止选择。
16
+ 4. 显示当前模式的名称;只有 `locked: false` 才允许打开选择器。名册使用 `presets`,显示 `name/description`,提交 `id`,有 `broken` 的项禁用并显示原因。
17
+ 5. 选择后发送 `select-agent-preset`,携带唯一 `requestId`。保存期间禁止再次切换和发送消息;以成功响应的 `agentPreset` 更新当前值。失败保留原值并展示错误。
18
+ 6. 成功切换后重新读取该 Session 的 `commands` 和 `skills`,因为模式决定工具、命令及技能集合。
19
+ 7. 点击发送第一条消息立即在 App 暂时禁用模式选择,收到 `sent` 后保持锁定。若提交报错,重新查询状态再决定是否解锁;连接断开或请求超时也必须先重新查询。
20
+ 8. 每次进入会话或重连时重查当前模式状态,不能依赖上次连接的本地可编辑标记。
21
+
22
+ `agent-presets` 的 `modeSelectionEnabled: false` 表示 Host 隐藏模式选择 UI 的设置,App 可遵循同一设置隐藏入口;旧 Host 没有该字段时默认显示。它不改变 `locked` 的含义。全局 `isDefault` 只用于未指定模式时的默认提示,不能覆盖已有会话当前值。
23
+
24
+ ## 请求与响应
25
+
26
+ ```json
27
+ {"type":"session-create","requestId":"create-1","workspaceId":"w1","agentPreset":"standard"}
28
+ {"kind":"session-created","requestId":"create-1","sessionId":"s1","agentPreset":"standard"}
29
+
30
+ {"type":"agent-presets"}
31
+ {"kind":"agent-presets","presets":[{"id":"standard","name":"标准模式","isDefault":true},{"id":"minimal","name":"精简模式","isDefault":false}],"modeSelectionEnabled":true}
32
+
33
+ {"type":"session-agent-preset","sessionId":"s1","requestId":"state-1"}
34
+ {"kind":"session-agent-preset","sessionId":"s1","requestId":"state-1","agentPreset":"standard","locked":false}
35
+
36
+ {"type":"select-agent-preset","sessionId":"s1","agentPreset":"minimal","requestId":"select-1"}
37
+ {"kind":"select-agent-preset","sessionId":"s1","requestId":"select-1","agentPreset":"minimal"}
38
+ ```
39
+
40
+ 创建时的 `agentPreset` 可省略,沿用 Host 默认值。选择成功响应不带 `locked: false`,避免把并发到达的开始对话通知重新解锁。
41
+
42
+ 如果沿用“首条消息自动创建会话”的流程,只能在不带 `sessionId` 的 `message` 上附带 `agentPreset`;已有 Session 的模式修改必须使用独立选择接口。
43
+
44
+ ## 多端更新及缓存
45
+
46
+ Control 通道(或旧版单连接)会收到:
47
+
48
+ ```json
49
+ {"kind":"session-agent-preset-updated","sessionId":"s1","agentPreset":"minimal","seq":12,"time":1000}
50
+ {"kind":"session-agent-preset-updated","sessionId":"s1","locked":true,"seq":13,"time":1100}
51
+ ```
52
+
53
+ - 第一种表示模式已提交,包括从 WebUI 切换产生的事件;仅更新模式,清除命令/技能缓存。
54
+ - 第二种表示已开始对话,仅更新锁定状态。未携带的字段保持原值。
55
+ - 更新按 `gatewayId + sessionId` 定位;这些控制增量不受对话订阅过滤,不要错误应用到当前显示的其他会话。
56
+ - `seq` 是该通知对应的持久事件序号,不是独立的控制通知连续序列。
57
+ - 同一页面只保留最新 `requestId` 的查询/选择响应;离开页面、切换 gateway 或 session 后忽略旧响应。查询发出后若收到锁定通知,不可被该查询的迟到 `locked: false` 覆盖。
58
+ - 通知不重放连接基线。重连查询负责恢复当前值和锁定状态,历史 `projections.values.agentPreset` 也可更新模式。
59
+ - Session header 的 preset 是创建事实;用户在空白阶段切换后,当前值来自投影,不能只读 header。
60
+
61
+ ## 错误处理
62
+
63
+ | code | App 行为 |
64
+ |---|---|
65
+ | `agent-preset/locked` | 锁定模式入口,提示对话开始后不可修改 |
66
+ | `agent-preset/not-found` | 保留原模式,刷新已有模式列表 |
67
+ | `agent-preset/invalid` | 保留原模式,展示 Host 返回的损坏或不可用原因 |
68
+ | `agent-preset/unavailable` | 状态未知,保持禁用;不可假定为空白会话 |
69
+ | `session/not-found` | 刷新会话列表并退出无效会话 |
70
+ | `bad-request` | 请求缺少有效的 sessionId 或 agentPreset,应修正客户端参数 |
71
+ | 其他 Host/网络错误 | 保留原模式,重查状态;不自动重复有副作用的写入 |
72
+
73
+ 新增查询和选择接口的成功及 Host 错误响应均保留有效 `sessionId` 和传入的字符串 `requestId`。本地参数错误至少保留 `requestType/requestId`。
74
+
75
+ ## Host 对接与锁定规则
76
+
77
+ - 切换调用官方 `agentPresets/select`,参数为 `{ agentId: sessionId, agentPreset }`;它重组 Agent、写入 `agent-preset/selected`,不更换 ID、不修改全局默认值。
78
+ - 查询读取 `session/follow` 首帧的当前 preset 和 `sessionListMetadata`,不以“历史 events 数组为空”判断可编辑,因为历史会分页,空白会话也有初始化事件。
79
+ - 历史含对话轮次或用户输入即锁定。gateway 还覆盖 prompt 已受理但尚未写入轮次事件的间隙。
80
+ - 同一 gateway 内,模式选择与消息提交按 Session 串行,Control/Conversation 两个 socket 的竞争不会让第一条消息越过尚未完成的模式切换。
81
+ - Host 自身仍会检查会话轮次边界。WebUI 等绕过 gateway 的并发操作由 Host 自身负责;本次没有修改 Host 的并发实现。
82
+
83
+ ## 验收
84
+
85
+ - 新建 → 查询标准模式 → 改成精简模式 → 改回标准模式,全程 session ID 不变。
86
+ - 发送第一条消息后,UI 立即锁定;绕过 UI 直接调用选择接口也返回 `agent-preset/locked`。
87
+ - 轮次完成、停止、App 重连、gateway 重启后仍锁定。
88
+ - 未知/损坏模式切换失败,原模式与会话保留;之后仍可选择有效模式。
89
+ - 保存模式期间发送消息不会抢先执行;两个设备并发操作能按响应和通知收敛。
90
+ - WebUI 切换模式后,App 当前模式、命令和技能缓存同步更新;WebUI 开始对话后 App 锁定。
91
+ - 切换到另一个 Session 时,旧 Session 的迟到响应不能修改新页面。
92
+
93
+ 自动测试覆盖 Adapter 真实参数、消息/选择排序、历史锁定、输入校验、错误透传和真实本地 WebSocket 的模式查询、切换通知、锁定、双通道分流。未执行新版真实 DSH 与手机联调。
@@ -133,6 +133,31 @@ export function createDshHostAdapter(typertGateway) {
133
133
  'session/modelCatalog',
134
134
  )
135
135
 
136
+ // Control and conversation sockets share this adapter. Admit a prompt only
137
+ // after an earlier preset switch finishes, and re-check later switches.
138
+ const sessionOperations = new Map()
139
+ const admittedPrompts = new Set()
140
+ const serializeSession = (sessionId, operation) => {
141
+ const task = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation)
142
+ const guard = task.catch(() => {})
143
+ sessionOperations.set(sessionId, guard)
144
+ return task.finally(() => {
145
+ if (sessionOperations.get(sessionId) === guard) sessionOperations.delete(sessionId)
146
+ })
147
+ }
148
+ const sessionPreset = async (sessionId, signal) => {
149
+ const snapshot = await sessionSnapshot({ address: { kind: 'session', sessionId }, maxMessages: 1 }, signal)
150
+ const values = snapshot.projections.values
151
+ const metadata = values?.sessionListMetadata
152
+ if (typeof values?.agentPreset !== 'string' || !metadata || typeof metadata.blank !== 'boolean'
153
+ || !(metadata.lastPromptAt === null || Number.isFinite(metadata.lastPromptAt))) {
154
+ throw Object.assign(new Error('session preset state is unavailable'), { code: 'agent-preset/unavailable' })
155
+ }
156
+ const durableLock = !metadata.blank || metadata.lastPromptAt !== null
157
+ if (durableLock) admittedPrompts.delete(sessionId)
158
+ return { sessionId, agentPreset: values.agentPreset, locked: durableLock || admittedPrompts.has(sessionId) }
159
+ }
160
+
136
161
  const commands = {
137
162
  list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
138
163
  // DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
@@ -177,10 +202,18 @@ export function createDshHostAdapter(typertGateway) {
177
202
  list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
178
203
  search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
179
204
  create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
180
- prompt: (payload, signal) => invoke('session', 'prompt', requestArgs({
181
- requestId: crypto.randomUUID(),
182
- ...payload,
183
- }), signal),
205
+ prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
206
+ // Accepted input may not have reached turn/start yet. Keep that gap
207
+ // locked; the persistent event or a subsequent read retires the marker.
208
+ const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
209
+ admittedPrompts.add(payload.sessionId)
210
+ try {
211
+ return await invoke('session', 'prompt', requestArgs({ requestId: crypto.randomUUID(), ...payload }), signal)
212
+ } catch (error) {
213
+ if (!alreadyAdmitted) admittedPrompts.delete(payload.sessionId)
214
+ throw error
215
+ }
216
+ }),
184
217
  attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
185
218
  fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
186
219
  cancel: (payload, signal) => invoke('session', 'cancel', requestArgs(payload), signal),
@@ -225,6 +258,20 @@ export function createDshHostAdapter(typertGateway) {
225
258
  },
226
259
  agentPresets: {
227
260
  list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
261
+ session: (payload, signal) => serializeSession(payload.sessionId, () => sessionPreset(payload.sessionId, signal)),
262
+ select: (payload, signal) => serializeSession(payload.sessionId, async () => {
263
+ const state = await sessionPreset(payload.sessionId, signal)
264
+ if (state.locked) {
265
+ throw Object.assign(new Error('session has already started; its agent preset is fixed'), { code: 'agent-preset/locked' })
266
+ }
267
+ // Host validates the live catalog, mounts the composition, checks its
268
+ // own turn boundary and persists agent-preset/selected on this ID.
269
+ const selected = await invoke('agentPresets', 'select', {
270
+ agentId: payload.sessionId, agentPreset: payload.agentPreset,
271
+ }, signal)
272
+ if (typeof selected !== 'string' || !selected) throw new Error('agentPresets/select returned an invalid preset')
273
+ return { sessionId: payload.sessionId, agentPreset: selected }
274
+ }),
228
275
  },
229
276
  llm: {
230
277
  async models(_payload = {}, signal) {
@@ -269,6 +316,9 @@ export function createDshHostAdapter(typertGateway) {
269
316
  describe: (_payload = {}, signal) => describeHost(signal),
270
317
  },
271
318
  describeHost,
319
+ observeSessionEvent(sessionId, event) {
320
+ if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
321
+ },
272
322
  openSessionStream(sessionId, signal) {
273
323
  return typertGateway.stream({
274
324
  namespace: 'session', method: 'follow',
package/lib/index.mjs CHANGED
@@ -314,6 +314,8 @@ function buildWireEvent(session, event) {
314
314
  ...(d.source ? { source: d.source } : {}),
315
315
  },
316
316
  })
317
+ case 'agent-preset/selected':
318
+ return Object.assign(base, { event: { type: event.type, agentPreset: d.agentPreset } })
317
319
  case 'tool/call':
318
320
  return Object.assign(base, {
319
321
  event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
@@ -496,6 +498,12 @@ async function admitCommand(host, msg) {
496
498
  // Handle one mobile -> agent message through the official host API. Returns
497
499
  // the wire frame to send back, or null when nothing should be sent.
498
500
  async function admitMessage(api, msg) {
501
+ const preset = parseAgentPreset(msg, 'message')
502
+ if (preset.error) return preset.error
503
+ if (preset.value !== undefined && typeof msg.sessionId === 'string' && msg.sessionId.trim()) {
504
+ return { kind: 'error', code: 'bad-request', requestType: 'message',
505
+ message: 'use select-agent-preset before sending a message to an existing session' }
506
+ }
499
507
  const text = typeof msg.text === 'string' ? msg.text.trim() : ''
500
508
  const parsedImages = parseWireImages(msg.images, 'message')
501
509
  if (parsedImages.error) return parsedImages.error
@@ -511,6 +519,7 @@ async function admitMessage(api, msg) {
511
519
  // New session: optionally place it in a workspace (at most one of
512
520
  // workspaceId / cwd, per the host create contract; workspaceId wins).
513
521
  const createPayload = {}
522
+ if (preset.value !== undefined) createPayload.agentPreset = preset.value
514
523
  if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim() !== '') {
515
524
  createPayload.workspaceId = msg.workspaceId.trim()
516
525
  }
@@ -1307,13 +1316,36 @@ async function selectCommandOption(api, host, command, sessionId, optionId) {
1307
1316
  return { kind: 'error', code: 'bad-request', message: `command is not selectable: ${command}`, requestType: 'command-select', sessionId }
1308
1317
  }
1309
1318
 
1319
+ function parseAgentPreset(msg, requestType, required = false) {
1320
+ if (msg.agentPreset === undefined && !required) return {}
1321
+ if (typeof msg.agentPreset !== 'string' || !msg.agentPreset.trim()) {
1322
+ return { error: { kind: 'error', code: 'bad-request', requestType,
1323
+ message: 'agentPreset must be a non-empty preset ID' } }
1324
+ }
1325
+ return { value: msg.agentPreset.trim() }
1326
+ }
1327
+
1310
1328
  // Dispatch one mobile query frame; returns the wire frame to send back.
1311
1329
  async function handleQuery(api, host, agentDefaultModel, msg) {
1330
+ if (msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset') {
1331
+ const session = requireSessionId(msg)
1332
+ const preset = parseAgentPreset(msg, msg.type, msg.type === 'select-agent-preset')
1333
+ const requestId = typeof msg.requestId === 'string' ? { requestId: msg.requestId } : {}
1334
+ if (session.error || preset.error) return { ...(session.error || preset.error), ...requestId }
1335
+ const method = msg.type === 'session-agent-preset' ? 'session' : 'select'
1336
+ const frame = await proxyQuery(api, msg.type, api.agentPresets[method].bind(api.agentPresets), {
1337
+ sessionId: session.value, ...(preset.value === undefined ? {} : { agentPreset: preset.value }),
1338
+ })
1339
+ return { ...frame, sessionId: session.value, ...requestId }
1340
+ }
1312
1341
  if (msg.type === 'session-create') {
1313
1342
  if (typeof msg.requestId !== 'string' || !msg.requestId.trim()) {
1314
1343
  return { kind: 'error', code: 'bad-request', message: 'session-create requires a requestId', requestType: 'session-create' }
1315
1344
  }
1316
1345
  const payload = {}
1346
+ const preset = parseAgentPreset(msg, msg.type)
1347
+ if (preset.error) return { ...preset.error, requestId: msg.requestId }
1348
+ if (preset.value !== undefined) payload.agentPreset = preset.value
1317
1349
  if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim()) {
1318
1350
  payload.workspaceId = msg.workspaceId.trim()
1319
1351
  } else if (typeof msg.cwd === 'string' && msg.cwd.trim()) {
@@ -2213,20 +2245,43 @@ const plugin = {
2213
2245
  }
2214
2246
 
2215
2247
  const replayPendingInteractions = (ws, trigger) => {
2216
- if (ws.mobileChannel === 'conversation') return { questionCount: 0, approvalCount: 0 }
2248
+ // In split-channel mode `subscribe` belongs to the conversation socket,
2249
+ // while interaction frames belong to the control socket. Bridge a
2250
+ // conversation subscription to every live control/legacy socket so
2251
+ // reopening a Session restores its still-pending cards. Reducers dedupe
2252
+ // by rpcId, so replaying to another trusted device is harmless and keeps
2253
+ // all active clients in sync.
2254
+ // The companion control socket already receives a full replay when it
2255
+ // connects, so a bare conversation connect must not duplicate it again.
2256
+ if (ws.mobileChannel === 'conversation' && trigger !== 'subscribe') {
2257
+ return { questionCount: 0, approvalCount: 0 }
2258
+ }
2259
+ const recipients = ws.mobileChannel === 'conversation'
2260
+ ? [...clients].filter((client) => client.readyState === 1 && client.mobileChannel !== 'conversation')
2261
+ : [ws]
2262
+ if (recipients.length === 0) return { questionCount: 0, approvalCount: 0 }
2263
+ const sessionId = ws.filterSessionId
2217
2264
  let questionCount = 0
2218
2265
  let approvalCount = 0
2219
2266
  for (const pending of pendingQuestions.values()) {
2220
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2221
- ws.send(stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true)))
2267
+ if (sessionId && sessionId !== pending.sessionId) continue
2268
+ const wire = stringifyWireFrame(questionFrameFor(pending.rpcId, pending, true))
2269
+ for (const recipient of recipients) {
2270
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2271
+ recipient.send(wire)
2272
+ }
2222
2273
  questionCount += 1
2223
2274
  }
2224
2275
  for (const pending of pendingApprovals.values()) {
2225
- if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
2226
- ws.send(stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true)))
2276
+ if (sessionId && sessionId !== pending.sessionId) continue
2277
+ const wire = stringifyWireFrame(approvalFrameFor(pending.rpcId, pending, true))
2278
+ for (const recipient of recipients) {
2279
+ if (recipient.filterSessionId && recipient.filterSessionId !== pending.sessionId) continue
2280
+ recipient.send(wire)
2281
+ }
2227
2282
  approvalCount += 1
2228
2283
  }
2229
- log(`interaction replay: trigger=${trigger} filtered=${Boolean(ws.filterSessionId)} questions=${questionCount} approvals=${approvalCount}`)
2284
+ log(`interaction replay: trigger=${trigger} channel=${ws.mobileChannel} filtered=${Boolean(sessionId)} recipients=${recipients.length} questions=${questionCount} approvals=${approvalCount}`)
2230
2285
  return { questionCount, approvalCount }
2231
2286
  }
2232
2287
 
@@ -2236,15 +2291,24 @@ const plugin = {
2236
2291
  && (!client.filterSessionId || client.filterSessionId === sessionId)
2237
2292
  ))
2238
2293
 
2239
- const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED') => {
2294
+ const abortDownstreamInteraction = (pending, kind) => {
2295
+ if (pending.downstreamAbort.signal.aborted) return
2296
+ // The Host API gateway fans this abort out to every WebUI delivery of
2297
+ // the same Remote event. Its existing cancel handling removes each
2298
+ // pending BottomSheet; Mobile peers receive the resolved frame below.
2299
+ pending.downstreamAbort.abort(new Error(`${kind} completed outside WebUI`))
2300
+ }
2301
+
2302
+ const completeQuestion = (pending, action, answer, cancellationCode = 'ASK_CANCELLED', cancellationError, source = 'mobile') => {
2240
2303
  if (pendingQuestions.get(pending.rpcId) !== pending) return false
2241
2304
  pendingQuestions.delete(pending.rpcId)
2242
2305
  pending.cleanup()
2306
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'question')
2243
2307
  if (action === 'answer') pending.resolve(answer)
2244
2308
  else {
2245
- const error = new Error('question cancelled by mobile user')
2246
- error.name = 'UserQuestionError'
2247
- error.code = cancellationCode
2309
+ const error = cancellationError ?? new Error('question cancelled by mobile user')
2310
+ if (!error.name || error.name === 'Error') error.name = 'UserQuestionError'
2311
+ if (!error.code) error.code = cancellationCode
2248
2312
  pending.reject(error)
2249
2313
  }
2250
2314
  broadcastInteractionFrame({
@@ -2283,10 +2347,11 @@ const plugin = {
2283
2347
  return { value: { answers: normalized } }
2284
2348
  }
2285
2349
 
2286
- const completeApproval = (pending, outcome) => {
2350
+ const completeApproval = (pending, outcome, source = 'mobile') => {
2287
2351
  if (pendingApprovals.get(pending.rpcId) !== pending) return false
2288
2352
  pendingApprovals.delete(pending.rpcId)
2289
2353
  pending.cleanup()
2354
+ if (source !== 'downstream') abortDownstreamInteraction(pending, 'approval')
2290
2355
  pending.resolve(outcome)
2291
2356
  broadcastInteractionFrame({
2292
2357
  kind: 'approval-resolved',
@@ -2298,18 +2363,80 @@ const plugin = {
2298
2363
  return true
2299
2364
  }
2300
2365
 
2366
+ const startDownstreamQuestion = (pending) => {
2367
+ let downstream
2368
+ try {
2369
+ downstream = pending.next()
2370
+ } catch (error) {
2371
+ pending.downstream = { kind: 'rejected', error }
2372
+ return
2373
+ }
2374
+ pending.downstream = { kind: 'pending' }
2375
+ Promise.resolve(downstream).then((answer) => {
2376
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2377
+ if (answer && Array.isArray(answer.answers)) {
2378
+ completeQuestion(pending, 'answer', answer, undefined, undefined, 'downstream')
2379
+ log(`question resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2380
+ } else {
2381
+ // A downstream answerer may report that no WebUI currently owns the
2382
+ // request. Keep Mobile active; the value is used only if every Mobile
2383
+ // client later disconnects.
2384
+ pending.downstream = { kind: 'unavailable', value: answer }
2385
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2386
+ }
2387
+ }, (error) => {
2388
+ if (pendingQuestions.get(pending.rpcId) !== pending) return
2389
+ if (error?.code === 'ASK_CANCELLED' || error?.code === 'ASK_ABORTED') {
2390
+ completeQuestion(pending, 'cancel', undefined, error.code, error, 'downstream')
2391
+ log(`question cancelled by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId}`)
2392
+ } else {
2393
+ pending.downstream = { kind: 'rejected', error }
2394
+ if (!hasInteractionClient(pending.sessionId)) fallbackQuestion(pending)
2395
+ }
2396
+ })
2397
+ }
2398
+
2399
+ const startDownstreamApproval = (pending) => {
2400
+ let downstream
2401
+ try {
2402
+ downstream = pending.next()
2403
+ } catch (error) {
2404
+ pending.downstream = { kind: 'rejected', error }
2405
+ return
2406
+ }
2407
+ pending.downstream = { kind: 'pending' }
2408
+ Promise.resolve(downstream).then((outcome) => {
2409
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2410
+ if (outcome === 'allowed-once' || outcome === 'rejected') {
2411
+ completeApproval(pending, outcome, 'downstream')
2412
+ log(`approval resolved by downstream answerer: rpcId=${pending.rpcId} session=${pending.sessionId} outcome=${outcome}`)
2413
+ } else {
2414
+ pending.downstream = { kind: 'unavailable', value: outcome }
2415
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2416
+ }
2417
+ }, (error) => {
2418
+ if (pendingApprovals.get(pending.rpcId) !== pending) return
2419
+ pending.downstream = { kind: 'rejected', error }
2420
+ if (!hasInteractionClient(pending.sessionId)) fallbackApproval(pending)
2421
+ })
2422
+ }
2423
+
2301
2424
  const fallbackQuestion = (pending) => {
2302
2425
  if (pendingQuestions.get(pending.rpcId) !== pending) return
2426
+ if (pending.downstream?.kind === 'pending') return
2303
2427
  pendingQuestions.delete(pending.rpcId)
2304
2428
  pending.cleanup()
2305
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2429
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2430
+ else pending.resolve(pending.downstream?.value)
2306
2431
  }
2307
2432
 
2308
2433
  const fallbackApproval = (pending) => {
2309
2434
  if (pendingApprovals.get(pending.rpcId) !== pending) return
2435
+ if (pending.downstream?.kind === 'pending') return
2310
2436
  pendingApprovals.delete(pending.rpcId)
2311
2437
  pending.cleanup()
2312
- Promise.resolve().then(pending.next).then(pending.resolve, pending.reject)
2438
+ if (pending.downstream?.kind === 'rejected') pending.reject(pending.downstream.error)
2439
+ else pending.resolve(pending.downstream?.value)
2313
2440
  }
2314
2441
 
2315
2442
  const releaseUnclaimedInteractions = () => {
@@ -2665,7 +2792,8 @@ const plugin = {
2665
2792
  msg.type === 'workspace-create' || msg.type === 'session-create' || msg.type === 'models' || msg.type === 'commands' || msg.type === 'command-execute' ||
2666
2793
  msg.type === 'command-options' || msg.type === 'command-select' || msg.type === 'select-model' ||
2667
2794
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
2668
- msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
2795
+ msg.type === 'agent-presets' || msg.type === 'session-agent-preset' || msg.type === 'select-agent-preset' ||
2796
+ msg.type === 'defaults' || msg.type === 'set-default' ||
2669
2797
  msg.type === 'session-stats' || msg.type === 'default-model' ||
2670
2798
  msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'session-cancel' || msg.type === 'queue-update' ||
2671
2799
  msg.type === 'session-archive' || msg.type === 'session-rename' || msg.type === 'providers' ||
@@ -2712,6 +2840,7 @@ const plugin = {
2712
2840
  'projection-baseline',
2713
2841
  'images',
2714
2842
  'session-create',
2843
+ 'session-agent-preset',
2715
2844
  'commands',
2716
2845
  'tasks',
2717
2846
  'goals',
@@ -2789,6 +2918,7 @@ const plugin = {
2789
2918
  }
2790
2919
 
2791
2920
  const disposeEvents = ctx.on('session/event', (session, event) => {
2921
+ api.observeSessionEvent(String(session.id), event)
2792
2922
  if (clients.size === 0) return
2793
2923
  const wire = buildWireEvent(session, event)
2794
2924
  if (!wire) return
@@ -2802,6 +2932,12 @@ const plugin = {
2802
2932
  ...(event.data.source ? { source: event.data.source } : {}),
2803
2933
  })
2804
2934
  }
2935
+ if (event.type === 'agent-preset/selected' || event.type === 'turn/start') {
2936
+ broadcastSessionMetadataFrame({
2937
+ kind: 'session-agent-preset-updated', sessionId: String(session.id), seq: event.seq, time: event.time,
2938
+ ...(event.type === 'turn/start' ? { locked: true } : { agentPreset: event.data.agentPreset }),
2939
+ })
2940
+ }
2805
2941
  const payload = stringifyWireFrame(wire)
2806
2942
  for (const client of clients) {
2807
2943
  if (client.mobileChannel === 'control' || client.sessionFollowerActive) continue
@@ -2816,22 +2952,29 @@ const plugin = {
2816
2952
  if (!sessionId || !Array.isArray(request.questions) || !hasInteractionClient(sessionId)) return next()
2817
2953
  const rpcId = crypto.randomUUID()
2818
2954
  return new Promise((resolve, reject) => {
2955
+ const requestSignal = request.signal
2956
+ const downstreamAbort = new AbortController()
2957
+ request.signal = requestSignal
2958
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
2959
+ : downstreamAbort.signal
2819
2960
  const onAbort = () => {
2820
2961
  const pending = pendingQuestions.get(rpcId)
2821
2962
  if (pending) completeQuestion(pending, 'cancel', undefined, 'ASK_ABORTED')
2822
2963
  }
2823
- request.signal?.addEventListener('abort', onAbort, { once: true })
2964
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2824
2965
  const pending = {
2825
2966
  rpcId,
2826
2967
  sessionId,
2827
2968
  questions: request.questions,
2828
2969
  next,
2970
+ downstreamAbort,
2829
2971
  resolve,
2830
2972
  reject,
2831
- cleanup: () => request.signal?.removeEventListener('abort', onAbort),
2973
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
2832
2974
  }
2833
2975
  pendingQuestions.set(rpcId, pending)
2834
2976
  broadcastInteractionFrame(questionFrameFor(rpcId, pending))
2977
+ startDownstreamQuestion(pending)
2835
2978
  log(`question requested: rpcId=${rpcId} session=${sessionId} questions=${request.questions.length}`)
2836
2979
  })
2837
2980
  }, { prepend: true })
@@ -2842,11 +2985,16 @@ const plugin = {
2842
2985
  const rpcId = crypto.randomUUID()
2843
2986
  const approvalId = crypto.randomUUID()
2844
2987
  return new Promise((resolve, reject) => {
2988
+ const requestSignal = request.signal
2989
+ const downstreamAbort = new AbortController()
2990
+ request.signal = requestSignal
2991
+ ? AbortSignal.any([requestSignal, downstreamAbort.signal])
2992
+ : downstreamAbort.signal
2845
2993
  const onAbort = () => {
2846
2994
  const pending = pendingApprovals.get(rpcId)
2847
2995
  if (pending) completeApproval(pending, 'cancelled')
2848
2996
  }
2849
- request.signal?.addEventListener('abort', onAbort, { once: true })
2997
+ requestSignal?.addEventListener('abort', onAbort, { once: true })
2850
2998
  const pending = {
2851
2999
  rpcId,
2852
3000
  sessionId,
@@ -2855,12 +3003,14 @@ const plugin = {
2855
3003
  ...(request.callId !== undefined ? { callId: request.callId } : {}),
2856
3004
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
2857
3005
  next,
3006
+ downstreamAbort,
2858
3007
  resolve,
2859
3008
  reject,
2860
- cleanup: () => request.signal?.removeEventListener('abort', onAbort),
3009
+ cleanup: () => requestSignal?.removeEventListener('abort', onAbort),
2861
3010
  }
2862
3011
  pendingApprovals.set(rpcId, pending)
2863
3012
  broadcastInteractionFrame(approvalFrameFor(rpcId, pending))
3013
+ startDownstreamApproval(pending)
2864
3014
  log(`approval requested: rpcId=${rpcId} approvalId=${approvalId} session=${sessionId} tool=${request.toolName}`)
2865
3015
  })
2866
3016
  }, { prepend: true })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.7.5",
4
- "description": "Mobile gateway for dsh 0.1.5-rc.1/2 with smaller opening history snapshots, accurate query error reporting, Unicode-safe wire frames, and preserved user message IDs",
3
+ "version": "0.7.7",
4
+ "description": "Mobile gateway for dsh 0.1.5-rc.1/2 with per-session Agent preset selection before the first conversation, automatic preset locking after conversation starts, and live preset updates",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
30
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",