dsh-plugin-mobile-gateway 0.5.0 → 0.6.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,11 +1,11 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.5.0)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.0)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
7
7
  - **公网端点**:必须由 TLS 反向代理提供 `wss://<域名>/ws/mobile`
8
- - **帧格式**:全部为 JSON 文本帧(UTF-8
8
+ - **帧格式**:全部为 JSON 文本帧(UTF-8);图片字节使用标准 Base64
9
9
  - **连接即推送**:连上后服务端立刻发送一条 `hello`,之后 agent 输出以 `event` 帧实时推送
10
10
 
11
11
  ---
@@ -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": 2, "authenticated": true,
80
+ { "kind": "hello", "protocol": 3, "capabilities": ["images"], "authenticated": true,
81
81
  "device": { "id": "...", "name": "iPhone" }, "port": 3080, "clients": 1 }
82
82
  ```
83
83
 
@@ -265,6 +265,48 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
265
265
  - `sessionId`:可选。省略时**自动创建新会话**(可用 `workspaceId` 或 `cwd` 指定归属工作区,至多一个,workspaceId 优先)
266
266
  - `mode`:`"queue"`(排队,默认)/ `"steer"`(打断当前回合)
267
267
  - `text` 以 `/` 开头会被当作**斜杠命令**(如 `/permission ask`),宿主直接执行、**绝不发给模型**
268
+ - `text` 与 `images` 至少提供一项;因此支持纯图片消息
269
+ - `clientTimeZone`:可选 IANA 时区,例如 `Asia/Shanghai`,宿主会校验后记录到这条用户消息
270
+
271
+ ### 发送图片
272
+
273
+ iOS 将本地图片原始文件数据编码成**标准 Base64**,不要包含 `data:image/...;base64,` 前缀:
274
+
275
+ ```json
276
+ {
277
+ "type": "message",
278
+ "sessionId": "session-abc",
279
+ "text": "请描述这两张图片",
280
+ "clientTimeZone": "Asia/Shanghai",
281
+ "images": [
282
+ {
283
+ "mediaType": "image/jpeg",
284
+ "data": "/9j/4AAQSkZJRgABAQ...",
285
+ "name": "IMG_1024.JPG"
286
+ },
287
+ {
288
+ "mediaType": "image/png",
289
+ "data": "iVBORw0KGgoAAA...",
290
+ "name": "diagram.png"
291
+ }
292
+ ]
293
+ }
294
+ ```
295
+
296
+ 支持的 `mediaType`:`image/png`、`image/jpeg`、`image/webp`、`image/gif`。宿主会验证 Base64、文件签名、格式、尺寸、像素数、单图大小、图片数量和总大小;声明 MIME 与真实字节不一致会拒绝整条消息,且不会产生部分附件。
297
+
298
+ 当前 DSH 默认最多 20 张图片、单图约 3.5 MiB、单条消息图片总计 100 MiB,实际值以最近一次 `history.projections.values.imageLimits` 为准。WebSocket 单帧上限默认 144 MiB,用于容纳 100 MiB 图片经 Base64 后的 JSON 请求;反向代理也必须允许相应大小的 WebSocket 帧。
299
+
300
+ Swift 编码示例:
301
+
302
+ ```swift
303
+ let data = try Data(contentsOf: imageURL)
304
+ let image = [
305
+ "mediaType": "image/jpeg",
306
+ "data": data.base64EncodedString(),
307
+ "name": imageURL.lastPathComponent
308
+ ]
309
+ ```
268
310
 
269
311
  ```json
270
312
  → { "kind": "sent", "sessionId": "session-abc", "mode": "queue" }
@@ -279,6 +321,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
279
321
  |---|---|---|
280
322
  | `sessions` | — | 会话列表(`updatedAt/running/blank/cwd/agentPreset`) |
281
323
  | `history` | `sessionId`, `beforeSeq?`, `maxMessages?`, `maxBytes?`, `view?` | 历史事件页(见下) |
324
+ | `attachment` | `sessionId`, `attachmentId` | 读取历史中属于该会话的图片字节 |
282
325
  | `search` | `query` | 会话全文搜索 |
283
326
  | `session-stats` | `sessionId` | 执行统计投影(输入框统计条数据源) |
284
327
  | `context-usage` | `sessionId` | token 用量 + 上下文占用投影 |
@@ -288,6 +331,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
288
331
  { "type": "history", "sessionId": "session-abc", "maxMessages": 60, "maxBytes": 4194304, "view": "conversation" }
289
332
  ```
290
333
  - 返回**原始 SessionEvent**(`{type, seq, time, data}`,方案A),可选裁剪
334
+ - 图片不会内联进历史页。`user/message.data.content[]` 中的图片块为 `{ "type":"image", "attachment": ImageAttachmentRef }`;iOS 使用其中的 `attachmentId` 请求图片数据
291
335
  - `maxBytes`:单帧字节预算,默认 **4 MiB**;超预算保留最新部分并给出 `nextBeforeSeq` 续页(客户端 16 MiB 上限的安全余量)
292
336
  - `view: "conversation"`:**对话裁剪模式**——丢弃 `assistant/chunk`(token 回放)与 `request/header`(system prompt),`tool/result` 嵌套文本截断到 2000 字符
293
337
  - 分页:`hasMore` 为真时用 `beforeSeq: nextBeforeSeq` 请求更早一页
@@ -298,6 +342,48 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
298
342
  "projections": { "asOfSeq": 127, "values": { "tokenUsage": {...}, "contextPressure": {...}, "permissions": {...}, "sessionStats": {...} } } }
299
343
  ```
300
344
 
345
+ 图片引用结构:
346
+
347
+ ```json
348
+ {
349
+ "type": "image",
350
+ "attachment": {
351
+ "attachmentId": "sha256-opaque-id",
352
+ "mediaType": "image/jpeg",
353
+ "bytes": 184320,
354
+ "width": 1200,
355
+ "height": 900,
356
+ "name": "IMG_1024.JPG"
357
+ }
358
+ }
359
+ ```
360
+
361
+ iOS 发现尚未缓存的 `attachmentId` 后发送:
362
+
363
+ ```json
364
+ { "type":"attachment", "sessionId":"session-abc", "attachmentId":"sha256-opaque-id" }
365
+ ```
366
+
367
+ 服务端在确认该会话历史确实引用了这张图片后返回:
368
+
369
+ ```json
370
+ {
371
+ "kind": "attachment",
372
+ "sessionId": "session-abc",
373
+ "attachment": {
374
+ "attachmentId": "sha256-opaque-id",
375
+ "mediaType": "image/jpeg",
376
+ "bytes": 184320,
377
+ "width": 1200,
378
+ "height": 900,
379
+ "name": "IMG_1024.JPG"
380
+ },
381
+ "data": "/9j/4AAQSkZJRgABAQ..."
382
+ }
383
+ ```
384
+
385
+ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建议以 `attachmentId` 为缓存键。不要把 Base64 长期保存在对话模型对象里。并发同步历史时可限制为 2~4 个附件请求,优先加载当前可见消息。
386
+
301
387
  ### `session-stats` 详细(输入框统计条)
302
388
  ```json
303
389
  { "type": "session-stats", "sessionId": "session-abc" }
@@ -423,7 +509,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
423
509
  | kind | 触发时机 |
424
510
  |---|---|
425
511
  | `paired` | 首次配对成功;仅此一次返回长期设备 token |
426
- | `hello` | 连接成功:`{ "kind":"hello", "protocol":2, "authenticated":true, "port":3080, "clients":1 }` |
512
+ | `hello` | 连接成功:`{ "kind":"hello", "protocol":3, "capabilities":["images"], "authenticated":true, "port":3080, "clients":1 }` |
427
513
  | `event` | 任意会话的 agent 输出(见下) |
428
514
  | `question-requested` / `question-resolved` | Human-in-the-loop 问题请求与最终状态 |
429
515
  | `pong` / `subscribed` / `sent` | 对应请求的回复 |
@@ -434,7 +520,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
434
520
  "event": { "type": "assistant/chunk", "turn": 1, "step": 0, "chunkType": "text-delta", "text": "正在" } }
435
521
  ```
436
522
  `event.type` 覆盖(精炼字段):
437
- - `user/message` → `{text, source}`
523
+ - `user/message` → `{text, source, images?: ImageAttachmentRef[]}`
438
524
  - `assistant/chunk` → `{turn, step, chunkType: text-delta|reasoning-delta|tool-call-delta|usage|finish, text?/tool?/usage?/finish?}`
439
525
  - `assistant/message` → `{turn, step, text, reasoning, toolCalls[]}`
440
526
  - `tool/call` → `{turn, step, callId, name, arguments}`
@@ -487,6 +573,7 @@ Agent 调用 DSH 的 `ask_user_question` 工具时,插件通过 API Gateway
487
573
  | v0.1.17 | models 支持无 sessionId 全局目录;新增 providers |
488
574
  | v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
489
575
  | v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
576
+ | v0.6.0 | DSH 0.1.1 图片:WebSocket Base64 上传、实时图片引用、历史附件按会话安全读取 |
490
577
 
491
578
  ---
492
579
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # dsh-plugin-mobile-gateway
6
6
 
7
- 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供经过设备鉴权的持久化 WebSocket 网关,让 iOS 等移动客户端能够查看工作区和历史会话、接收 Agent 实时输出、处理 Human-in-the-loop 选择、发送任务,以及调整会话模型与权限。
7
+ 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供经过设备鉴权的持久化 WebSocket 网关,让 iOS 等移动客户端能够查看工作区和历史会话、收发文字与图片、接收 Agent 实时输出、处理 Human-in-the-loop 选择,以及调整会话模型与权限。
8
8
 
9
9
  ![DeepSeek Harness 移动设备管理面板](docs/assets/mobile-device-management.png)
10
10
 
@@ -15,20 +15,22 @@
15
15
 
16
16
  ## 直接安装(推荐,无需下载源码)
17
17
 
18
+ 如果使用 nvm 安装 Node.js,并希望不打开 WebUI、直接从服务器终端生成 iOS 配对字符串,请参照[公网 IP 命令行配对指南](docs/public-ip-deployment-guide.md)。
19
+
18
20
  ### 1. 安装插件
19
21
 
20
22
  确保本机已经安装并能正常运行 DeepSeek Harness,然后执行:
21
23
 
22
24
  ```bash
23
- dsh plugin --profile web add dsh-plugin-mobile-gateway
25
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
24
26
  ```
25
27
 
26
- 这条命令会直接从 npm 获取插件、安装依赖,并将插件加入 `web` profile 的 bundle 列表。用户不需要 clone 仓库,也不需要运行 `pnpm install`。
28
+ 这条命令会在执行时从 npm 获取 `latest` 标签指向的最新插件、安装依赖,并将插件加入 `web` profile 的 bundle 列表。用户不需要 clone 仓库,也不需要运行 `pnpm install`。已经安装的插件不会后台自动升级;重新安装最新版本后需要重启 WebUI。
27
29
 
28
30
  如果希望固定版本,可以在包名后指定版本号:
29
31
 
30
32
  ```bash
31
- dsh plugin --profile web add dsh-plugin-mobile-gateway@0.5.0
33
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.6.0
32
34
  ```
33
35
 
34
36
  也可以不经过 npm,直接安装 GitHub 版本:
@@ -141,6 +143,7 @@ wss://gateway.example.com/ws/mobile
141
143
  - 浏览工作区、未分组会话和服务端目录
142
144
  - 创建工作区与新会话
143
145
  - 加载历史消息与轨迹
146
+ - 从历史消息安全加载图片,并把 iOS 本地图片发送给 Agent
144
147
  - 实时接收思考、工具调用和最终回答
145
148
  - 向远端 Agent 发送任务
146
149
  - 查询或切换会话模型、推理等级和访问权限
@@ -0,0 +1,135 @@
1
+ # 公网 IP 命令行配对指南
2
+
3
+ 适用于通过 nvm 安装 Node.js、已经可以直接使用 `dsh` 命令的 Ubuntu/Debian 服务器。服务器端不需要打开 WebUI:终端会直接输出 iOS 所需的 Base64URL 配对字符串。
4
+
5
+ ## 1. 放行公网端口
6
+
7
+ 先在云安全组中放行 TCP `80` 和 `443`。不要开放 `3080`、`3081` 或 `/mgw/*`。
8
+
9
+ 如果服务器启用了 UFW:
10
+
11
+ ```bash
12
+ sudo ufw allow 80/tcp
13
+ sudo ufw allow 443/tcp
14
+ ```
15
+
16
+ ## 2. 安装插件和辅助命令
17
+
18
+ ```bash
19
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
20
+ sudo apt-get update
21
+ sudo apt-get install -y jq tmux
22
+ ```
23
+
24
+ ## 3. 配置公网 WSS
25
+
26
+ 获取服务器公网 IPv4:
27
+
28
+ ```bash
29
+ PUBLIC_IP="$(curl -4 -fsS https://ifconfig.me)"
30
+ printf 'Public IP: %s\n' "$PUBLIC_IP"
31
+ ```
32
+
33
+ 如果显示的不是服务器实际公网 IPv4,请手动执行 `PUBLIC_IP="实际公网IP"`。
34
+
35
+ 因为 Node.js 来自 nvm,运行安装器时需要把当前 `PATH` 传给 `sudo`:
36
+
37
+ ```bash
38
+ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest setup \
39
+ --ip "$PUBLIC_IP" \
40
+ --port 3080
41
+ ```
42
+
43
+ 安装成功后检查:
44
+
45
+ ```bash
46
+ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest status
47
+ ```
48
+
49
+ 应显示:
50
+
51
+ ```text
52
+ wss://<公网IP>/ws/mobile
53
+ ```
54
+
55
+ ## 4. 启动 DSH
56
+
57
+ ```bash
58
+ tmux new -s dsh
59
+ dsh web
60
+ ```
61
+
62
+ 看到 `dsh web: http://127.0.0.1:3080` 后,按 `Ctrl+B`,再按 `D`,让 DSH 在后台继续运行。
63
+
64
+ ## 5. 在终端生成配对字符串
65
+
66
+ 重新读取公网地址,并明确开启设备鉴权和移动网关:
67
+
68
+ ```bash
69
+ PUBLIC_IP="$(curl -4 -fsS https://ifconfig.me)"
70
+ PUBLIC_URL="wss://$PUBLIC_IP/ws/mobile"
71
+
72
+ curl -fsS -X POST http://127.0.0.1:3080/mgw/auth \
73
+ -H 'Content-Type: application/json' \
74
+ -d '{"enabled":true}'
75
+
76
+ curl -fsS -X POST http://127.0.0.1:3080/mgw/gateway \
77
+ -H 'Content-Type: application/json' \
78
+ -d '{"enabled":true}'
79
+ ```
80
+
81
+ 生成一次性 Base64URL 配对字符串:
82
+
83
+ ```bash
84
+ PAIRING_TEXT="$(
85
+ jq -nc --arg name 'iPhone' --arg url "$PUBLIC_URL" \
86
+ '{name:$name,publicUrl:$url}' \
87
+ | curl -fsS -X POST http://127.0.0.1:3080/mgw/pair \
88
+ -H 'Content-Type: application/json' \
89
+ --data-binary @- \
90
+ | jq -r '.qrPayload'
91
+ )"
92
+
93
+ if [ -z "$PAIRING_TEXT" ] || [ "$PAIRING_TEXT" = 'null' ]; then
94
+ echo '生成配对字符串失败,请检查 DSH 和插件日志'
95
+ else
96
+ printf '\n复制下面这一整行到 iPhone:\n\n%s\n\n' "$PAIRING_TEXT"
97
+ fi
98
+ ```
99
+
100
+ 配对字符串只能使用一次,并在 5 分钟后过期。如果超时,重新执行本节最后两段命令。
101
+
102
+ ## 6. 在 iPhone 完成绑定
103
+
104
+ 1. 打开 iOS 客户端的“设备认证”。
105
+ 2. 把终端输出的整行内容粘贴到“手动输入配对信息”。
106
+ 3. 点击“配对并连接”或“重新配对并连接”。
107
+ 4. 状态显示“已连接”后点击“完成”。
108
+
109
+ iOS 会把长期设备凭证保存到 Keychain,之后启动时会自动重新连接,不需要再次执行配对。
110
+
111
+ ## 7. 检查结果
112
+
113
+ 服务器查看可信设备:
114
+
115
+ ```bash
116
+ curl -fsS http://127.0.0.1:3080/mgw/devices | jq
117
+ ```
118
+
119
+ 查看插件日志:
120
+
121
+ ```bash
122
+ tail -f /tmp/mobile-gateway.log
123
+ ```
124
+
125
+ 常见结果:
126
+
127
+ - `503`:移动网关未开启,重新执行第 5 节的网关开启命令。
128
+ - `401`:配对字符串过期、已使用或客户端凭证无效,重新生成配对字符串。
129
+ - 公网连接超时:检查云安全组、UFW、Nginx,以及 TCP `80/443`。
130
+
131
+ 查看后台 DSH:
132
+
133
+ ```bash
134
+ tmux attach -t dsh
135
+ ```
package/lib/index.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
  // client -> server: { "type": "ping" }
19
19
  // { "type": "subscribe", "sessionId": "..." } (optional filter)
20
20
  // { "type": "unsubscribe" }
21
- // { "type": "message", "sessionId"?, "text", "mode"?: "queue"|"steer",
22
- // "workspaceId"?, "cwd"? }
21
+ // { "type": "message", "sessionId"?, "text"?, "images"?:
22
+ // [{ "mediaType", "data", "name"? }], "mode"?: "queue"|"steer",
23
+ // "workspaceId"?, "cwd"?, "clientTimeZone"? }
23
24
  // sessionId omitted -> a new session is created first; the
24
25
  // new session can be placed in a workspace via workspaceId
25
26
  // (or cwd; at most one, workspaceId wins)
@@ -30,6 +31,8 @@
30
31
  // -> raw SessionEvent page (scheme A), capped at maxBytes
31
32
  // (default 4 MiB); hasMore + nextBeforeSeq page backward;
32
33
  // view:"conversation" trims chunk/header events and tool output
34
+ // { "type": "attachment", "sessionId", "attachmentId" }
35
+ // -> verified image metadata + canonical base64 bytes
33
36
  // { "type": "search", "query" } -> session search
34
37
  // { "type": "host" } -> host.describe snapshot (incl. default provider/model)
35
38
  // { "type": "default-model" } -> agentDefaultModel.currentSelection()
@@ -50,11 +53,13 @@
50
53
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
51
54
  // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
52
55
  // { "type": "question-cancel", "rpcId", "sessionId" }
53
- // server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
56
+ // server -> client: { "kind": "hello", "protocol": 3, "capabilities": ["images"],
57
+ // "authenticated", "device"?, "port", "clients" }
54
58
  // { "kind": "pong", "at" }
55
59
  // { "kind": "subscribed", "sessionId" }
56
60
  // { "kind": "sent", "sessionId", "mode", "command"? }
57
61
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
62
+ // { "kind": "attachment", "sessionId", "attachment", "data" }
58
63
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
59
64
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
60
65
  // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
@@ -85,7 +90,12 @@ const LOG_FILE = '/tmp/mobile-gateway.log'
85
90
  const DEFAULT_WS_PATH = '/ws/mobile'
86
91
  const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
87
92
  const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
93
+ // DSH 0.1.1 allows up to 100 MiB of decoded images in one prompt. Base64 plus
94
+ // the JSON envelope needs roughly 4/3 of that on the wire. Keep this separately
95
+ // configurable so deployments may choose a lower transport ceiling.
96
+ const DEFAULT_MAX_WS_PAYLOAD_BYTES = 144 * 1024 * 1024
88
97
  const MAX_MANAGEMENT_BODY_BYTES = 16 * 1024
98
+ const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
89
99
 
90
100
  // Cordis validates this schema at plugin load and fills these defaults. Keep
91
101
  // the defaults conservative: installing the bundle must never create an
@@ -95,6 +105,7 @@ const Config = Schema.object({
95
105
  requireAuth: Schema.boolean().default(true),
96
106
  gatewayEnabled: Schema.boolean().default(false),
97
107
  gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(DEFAULT_GATEWAY_WAIT_TIMEOUT_MS),
108
+ maxPayloadBytes: Schema.natural().min(1024 * 1024).max(160 * 1024 * 1024).default(DEFAULT_MAX_WS_PAYLOAD_BYTES),
98
109
  adminLoopbackOnly: Schema.boolean().default(true),
99
110
  publicUrl: Schema.string().default(''),
100
111
  deviceFile: Schema.string().default(''),
@@ -126,20 +137,40 @@ function textOf(blocks) {
126
137
  return text
127
138
  }
128
139
 
140
+ function imagesOf(blocks) {
141
+ const images = []
142
+ for (const block of blocks) {
143
+ if (!block || block.type !== 'image' || !block.attachment) continue
144
+ const attachment = block.attachment
145
+ images.push({
146
+ attachmentId: attachment.attachmentId,
147
+ mediaType: attachment.mediaType,
148
+ bytes: attachment.bytes,
149
+ width: attachment.width,
150
+ height: attachment.height,
151
+ ...(attachment.name ? { name: attachment.name } : {}),
152
+ })
153
+ }
154
+ return images
155
+ }
156
+
129
157
  // Build the small, owned JSON wire record for one session event. Reads only
130
158
  // leaf fields of the live SessionEvent — never serializes live objects.
131
159
  function buildWireEvent(session, event) {
132
- const base = { sessionId: String(session.id), seq: event.seq, time: event.time }
160
+ const base = { kind: 'event', sessionId: String(session.id), seq: event.seq, time: event.time }
133
161
  const d = event.data || {}
134
162
  switch (event.type) {
135
- case 'user/message':
163
+ case 'user/message': {
164
+ const images = imagesOf(d.content || [])
136
165
  return Object.assign(base, {
137
166
  event: {
138
167
  type: 'user/message',
139
168
  text: textOf(d.content || []),
140
169
  source: d.source && d.source.kind,
170
+ ...(images.length ? { images } : {}),
141
171
  },
142
172
  })
173
+ }
143
174
  case 'assistant/chunk': {
144
175
  const chunk = d.chunk || {}
145
176
  const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
@@ -154,6 +185,7 @@ function buildWireEvent(session, event) {
154
185
  let text = ''
155
186
  let reasoning = ''
156
187
  const toolCalls = []
188
+ const images = imagesOf(blocks)
157
189
  for (const block of blocks) {
158
190
  if (!block) continue
159
191
  if (block.type === 'text' && typeof block.text === 'string') text += block.text
@@ -161,7 +193,7 @@ function buildWireEvent(session, event) {
161
193
  else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
162
194
  }
163
195
  return Object.assign(base, {
164
- event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls },
196
+ event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls, ...(images.length ? { images } : {}) },
165
197
  })
166
198
  }
167
199
  case 'tool/call':
@@ -203,8 +235,37 @@ function buildWireEvent(session, event) {
203
235
  // the wire frame to send back, or null when nothing should be sent.
204
236
  async function admitMessage(api, msg) {
205
237
  const text = typeof msg.text === 'string' ? msg.text.trim() : ''
206
- if (!text) {
207
- return { kind: 'error', code: 'bad-request', message: 'text must be a non-empty string' }
238
+ const images = msg.images === undefined ? [] : msg.images
239
+ if (!Array.isArray(images)) {
240
+ return { kind: 'error', code: 'bad-request', message: 'images must be an array' }
241
+ }
242
+ if (images.length > 20) {
243
+ return { kind: 'error', code: 'bad-request', message: 'a message can contain at most 20 images' }
244
+ }
245
+ const imageParts = []
246
+ for (let index = 0; index < images.length; index++) {
247
+ const image = images[index]
248
+ if (!image || typeof image !== 'object') {
249
+ return { kind: 'error', code: 'bad-request', message: `images[${index}] must be an object` }
250
+ }
251
+ if (!IMAGE_MEDIA_TYPES.has(image.mediaType)) {
252
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].mediaType is unsupported` }
253
+ }
254
+ if (typeof image.data !== 'string' || image.data.length === 0) {
255
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].data must be a non-empty base64 string` }
256
+ }
257
+ if (image.name !== undefined && (typeof image.name !== 'string' || image.name.length > 255)) {
258
+ return { kind: 'error', code: 'bad-request', message: `images[${index}].name must be a string of at most 255 characters` }
259
+ }
260
+ imageParts.push({
261
+ type: 'image',
262
+ mediaType: image.mediaType,
263
+ data: image.data,
264
+ ...(image.name ? { name: image.name } : {}),
265
+ })
266
+ }
267
+ if (!text && imageParts.length === 0) {
268
+ return { kind: 'error', code: 'bad-request', message: 'message requires non-empty text or at least one image' }
208
269
  }
209
270
  const mode = msg.mode === 'steer' ? 'steer' : 'queue'
210
271
 
@@ -231,10 +292,16 @@ async function admitMessage(api, msg) {
231
292
 
232
293
  const resp = await api.sessions.prompt({
233
294
  rpcId: crypto.randomUUID(),
234
- payload: { sessionId, mode, content: [{ type: 'text', text }] },
295
+ payload: {
296
+ sessionId,
297
+ mode,
298
+ // Match the official WebUI ordering: images first, optional text last.
299
+ content: [...imageParts, ...(text ? [{ type: 'text', text }] : [])],
300
+ ...(typeof msg.clientTimeZone === 'string' && msg.clientTimeZone.trim() ? { clientTimeZone: msg.clientTimeZone.trim() } : {}),
301
+ },
235
302
  })
236
303
  if (resp.result.ok) {
237
- log(`mobile message accepted: session=${sessionId} mode=${mode} text="${text.slice(0, 60)}"`)
304
+ log(`mobile message accepted: session=${sessionId} mode=${mode} images=${imageParts.length} text="${text.slice(0, 60)}"`)
238
305
  return {
239
306
  kind: 'sent',
240
307
  sessionId,
@@ -438,6 +505,20 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
438
505
  }
439
506
  return frame
440
507
  }
508
+ if (msg.type === 'attachment') {
509
+ const sessionId = requireSessionId(msg)
510
+ if (sessionId.error) return sessionId.error
511
+ const attachmentId = typeof msg.attachmentId === 'string' && msg.attachmentId.trim() !== '' ? msg.attachmentId.trim() : null
512
+ if (!attachmentId) {
513
+ return { kind: 'error', code: 'bad-request', message: 'attachment requires an attachmentId', requestType: 'attachment', sessionId: sessionId.value }
514
+ }
515
+ const frame = await proxyQuery(api, 'attachment', api.sessions.attachment.bind(api.sessions), {
516
+ sessionId: sessionId.value,
517
+ attachmentId,
518
+ })
519
+ if (frame.kind === 'attachment') frame.sessionId = sessionId.value
520
+ return frame
521
+ }
441
522
  if (msg.type === 'search') {
442
523
  const query = typeof msg.query === 'string' ? msg.query.trim() : ''
443
524
  if (!query) {
@@ -827,6 +908,7 @@ const plugin = {
827
908
  requireAuth: true,
828
909
  gatewayEnabled: false,
829
910
  gatewayWaitTimeoutMs: DEFAULT_GATEWAY_WAIT_TIMEOUT_MS,
911
+ maxPayloadBytes: DEFAULT_MAX_WS_PAYLOAD_BYTES,
830
912
  adminLoopbackOnly: true,
831
913
  publicUrl: '',
832
914
  deviceFile: '',
@@ -855,7 +937,7 @@ const plugin = {
855
937
  const registry = createRegistry(deviceFile, { pairingTtlMs: options.pairingTtlMs })
856
938
  const wss = new WebSocketServer({
857
939
  noServer: true,
858
- maxPayload: 1024 * 1024,
940
+ maxPayload: options.maxPayloadBytes,
859
941
  // Authentication data may ride in requested subprotocols, but a secret
860
942
  // must never be echoed as the negotiated protocol.
861
943
  handleProtocols(protocols) {
@@ -1186,7 +1268,7 @@ const plugin = {
1186
1268
  admitMessage(api, msg).then((frame) => {
1187
1269
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1188
1270
  })
1189
- } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' ||
1271
+ } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
1190
1272
  msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
1191
1273
  msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
1192
1274
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
@@ -1217,7 +1299,8 @@ const plugin = {
1217
1299
  }
1218
1300
  ws.send(JSON.stringify({
1219
1301
  kind: 'hello',
1220
- protocol: 2,
1302
+ protocol: 3,
1303
+ capabilities: ['images'],
1221
1304
  port: transport.port || webServer.port,
1222
1305
  clients: clients.size,
1223
1306
  authenticated: !!device,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [