dsh-plugin-mobile-gateway 0.1.17
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/LICENSE +21 -0
- package/PROTOCOL.md +282 -0
- package/README.md +56 -0
- package/cordis.patch.yml +18 -0
- package/lib/index.js +692 -0
- package/package.json +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clarklevis1995
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# dsh Mobile Gateway — WebSocket 协议参考
|
|
2
|
+
|
|
3
|
+
移动端通过一个 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.1.17)。
|
|
4
|
+
|
|
5
|
+
- **端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
|
|
6
|
+
- **帧格式**:全部为 JSON 文本帧(UTF-8)
|
|
7
|
+
- **连接即推送**:连上后服务端立刻发送一条 `hello`,之后 agent 输出以 `event` 帧实时推送
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. 通用约定
|
|
12
|
+
|
|
13
|
+
### 客户端 → 服务端(请求帧)
|
|
14
|
+
```json
|
|
15
|
+
{ "type": "<消息类型>", ...参数 }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 服务端 → 客户端(响应/推送帧)
|
|
19
|
+
```json
|
|
20
|
+
{ "kind": "<类型>", ...数据 }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 统一错误帧
|
|
24
|
+
```json
|
|
25
|
+
{ "kind": "error", "code": "session-not-found", "message": "no such session",
|
|
26
|
+
"requestType": "history", "sessionId": "..." }
|
|
27
|
+
```
|
|
28
|
+
- `code`:`bad-request` / `session-not-found` / `agent-busy` / `model-unavailable` / `fork-unavailable` / `unknown-command` / `workspace-invalid-path` / `directory-unreadable` / `internal` 等(多为宿主错误码透传)
|
|
29
|
+
- `requestType`:出错请求的类型(`message` 直发类错误无此字段)
|
|
30
|
+
- `sessionId`:涉及会话时附带
|
|
31
|
+
|
|
32
|
+
### 会话 ID 获取
|
|
33
|
+
`{"type":"sessions"}` 列表取 `sessionId`,或 `{"type":"message"}` 省略 sessionId 自动创建后从 `sent` 响应拿。
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. 连接管理
|
|
38
|
+
|
|
39
|
+
| type | 参数 | 说明 |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `ping` | — | 心跳;回复 `pong` |
|
|
42
|
+
| `subscribe` | `sessionId` | 事件流过滤:之后只收到该会话的 `event`(不订阅 = 接收所有会话) |
|
|
43
|
+
| `unsubscribe` | — | 取消过滤 |
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{"type":"ping"}
|
|
47
|
+
→ {"kind":"pong","at":1786937352316}
|
|
48
|
+
|
|
49
|
+
{"type":"subscribe","sessionId":"session-abc"}
|
|
50
|
+
→ {"kind":"subscribed","sessionId":"session-abc"}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 3. 消息(手机 → agent)
|
|
56
|
+
|
|
57
|
+
### `message` — 发送消息(会话不存在则创建)
|
|
58
|
+
```json
|
|
59
|
+
{ "type": "message", "sessionId": "session-abc", "text": "你好",
|
|
60
|
+
"mode": "queue", "workspaceId": "w1", "cwd": "/path" }
|
|
61
|
+
```
|
|
62
|
+
- `sessionId`:可选。省略时**自动创建新会话**(可用 `workspaceId` 或 `cwd` 指定归属工作区,至多一个,workspaceId 优先)
|
|
63
|
+
- `mode`:`"queue"`(排队,默认)/ `"steer"`(打断当前回合)
|
|
64
|
+
- `text` 以 `/` 开头会被当作**斜杠命令**(如 `/permission ask`),宿主直接执行、**绝不发给模型**
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
→ { "kind": "sent", "sessionId": "session-abc", "mode": "queue" }
|
|
68
|
+
→ { "kind": "sent", "sessionId": "session-abc", "mode": "queue", "command": { "kind": "success", "text": "..." } } // 斜杠命令时
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 4. 会话与历史查询
|
|
74
|
+
|
|
75
|
+
| type | 参数 | 说明 |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `sessions` | — | 会话列表(`updatedAt/running/blank/cwd/agentPreset`) |
|
|
78
|
+
| `history` | `sessionId`, `beforeSeq?`, `maxMessages?`, `maxBytes?`, `view?` | 历史事件页(见下) |
|
|
79
|
+
| `search` | `query` | 会话全文搜索 |
|
|
80
|
+
| `session-stats` | `sessionId` | 执行统计投影(输入框统计条数据源) |
|
|
81
|
+
| `context-usage` | `sessionId` | token 用量 + 上下文占用投影 |
|
|
82
|
+
|
|
83
|
+
### `history` 详细
|
|
84
|
+
```json
|
|
85
|
+
{ "type": "history", "sessionId": "session-abc", "maxMessages": 60, "maxBytes": 4194304, "view": "conversation" }
|
|
86
|
+
```
|
|
87
|
+
- 返回**原始 SessionEvent**(`{type, seq, time, data}`,方案A),可选裁剪
|
|
88
|
+
- `maxBytes`:单帧字节预算,默认 **4 MiB**;超预算保留最新部分并给出 `nextBeforeSeq` 续页(客户端 16 MiB 上限的安全余量)
|
|
89
|
+
- `view: "conversation"`:**对话裁剪模式**——丢弃 `assistant/chunk`(token 回放)与 `request/header`(system prompt),`tool/result` 嵌套文本截断到 2000 字符
|
|
90
|
+
- 分页:`hasMore` 为真时用 `beforeSeq: nextBeforeSeq` 请求更早一页
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
→ { "kind": "history", "sessionId": "session-abc", "events": [ ...原始事件... ],
|
|
94
|
+
"bytes": 3521, "view": "conversation", "hasMore": true, "nextBeforeSeq": 128,
|
|
95
|
+
"projections": { "asOfSeq": 127, "values": { "tokenUsage": {...}, "contextPressure": {...}, "permissions": {...}, "sessionStats": {...} } } }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### `session-stats` 详细(输入框统计条)
|
|
99
|
+
```json
|
|
100
|
+
{ "type": "session-stats", "sessionId": "session-abc" }
|
|
101
|
+
→ { "kind": "session-stats", "sessionId": "session-abc", "asOfSeq": 42,
|
|
102
|
+
"sessionStats": { "turns": 6, "steps": 69, "llmMs": 2280000, "toolMs": 41400,
|
|
103
|
+
"ttftMs": 2600, "ttftSteps": 1, "decodeMs": 5000, "decodeTokens": 385,
|
|
104
|
+
"lastTurn": 6, "openStep": null, "pendingCalls": {} },
|
|
105
|
+
"tokenUsage": { "totals": { "inputTokens": 10, "outputTokens": 5, "cacheReadTokens": 1, "cacheWriteTokens": 0, "reasoningTokens": 0 } },
|
|
106
|
+
"contextPressure": { "contextWindow": 128000, "pressureTokens": 1500, "surfaceTokens": 2000 } }
|
|
107
|
+
```
|
|
108
|
+
展示公式(与浏览器同源):LLM 时长=`llmMs`、工具=`toolMs`、首 token 平均=`ttftMs/ttftSteps`、速率=`decodeTokens/(decodeMs/1000)`、缓存命中=`cacheHitPercent(tokenUsage.totals)`、输入=`billedInputTokens(totals)`、输出=`outputTokens`。
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 5. 工作区与目录
|
|
113
|
+
|
|
114
|
+
| type | 参数 | 说明 |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `workspaces` | — | 全部工作区(含每个的 `sessionIds`) |
|
|
117
|
+
| `workspace-create` | `path` | 对**已存在目录**创建工作区(已归属→`created:false` 幂等) |
|
|
118
|
+
| `directories` | `path?` | 浏览 server 目录(缺省 = home);`crumbs` 面包屑 + `entries`(含 `hidden` 标记) |
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{ "type": "workspace-create", "path": "/Users/lichaofan/DeepseekHarnessProject" }
|
|
122
|
+
→ { "kind": "workspace-create", "workspace": { "workspaceId": "w9", "path": "...", "title": "...", "sessionIds": [] },
|
|
123
|
+
"created": true }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 6. 模型与思考等级
|
|
129
|
+
|
|
130
|
+
| type | 参数 | 说明 |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `models` | `sessionId?` | **带 sessionId**:该会话的模型目录(`current` + `routable` + `groups`);**不带**:全局模型目录(`groups` + `failures`,无需会话) |
|
|
133
|
+
| `providers` | — | 可配置 provider 列表(含 live/dormant 状态) |
|
|
134
|
+
| `select-model` | `sessionId`, `provider`, `model`, `reasoningEffort?` | 切换**该会话**的模型/思考等级(写入会话日志) |
|
|
135
|
+
| `default-model` | — | **默认**模型选择(新会话用,含 `reasoningEffort`) |
|
|
136
|
+
| `save-default-model` | `provider`, `model`, `reasoningEffort?` | 修改**默认**模型 + 思考等级(全局) |
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "type": "models" }
|
|
140
|
+
→ { "kind": "models",
|
|
141
|
+
"groups": [ { "id": "deepseek", "name": "DeepSeek",
|
|
142
|
+
"models": [ { "id": "deepseek-chat", "name": "DeepSeek Chat",
|
|
143
|
+
"reasoning": { "efforts": [ { "id": "low", "name": "Low" }, ... ] } } ] } ],
|
|
144
|
+
"failures": [] }
|
|
145
|
+
|
|
146
|
+
{ "type": "models", "sessionId": "session-abc" }
|
|
147
|
+
→ { "kind": "models", "current": { "provider": "deepseek", "model": "deepseek-chat" },
|
|
148
|
+
"routable": true, "groups": [ ... ], "failures": [] }
|
|
149
|
+
|
|
150
|
+
{ "type": "providers" }
|
|
151
|
+
→ { "kind": "providers", "providers": [ { "provider": "deepseek", "displayName": "DeepSeek", "declared": true } ] }
|
|
152
|
+
|
|
153
|
+
{ "type": "select-model", "sessionId": "session-abc", "provider": "deepseek",
|
|
154
|
+
"model": "deepseek-chat", "reasoningEffort": "high" }
|
|
155
|
+
→ { "kind": "select-model", "selected": { "provider": "deepseek", "model": "deepseek-chat", "reasoningEffort": "high" } }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 7. 权限控制
|
|
161
|
+
|
|
162
|
+
| type | 参数 | 说明 |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `permission-options` | `sessionId?` | 可用权限 preset 列表(`namespace`)+ 该会话当前生效值(`sessionPermissions`) |
|
|
165
|
+
| `permission` | `sessionId`, `name` | 切换**该会话**的权限 preset(走官方 `/permission` 命令,不触发模型) |
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{ "type": "permission", "sessionId": "session-abc", "name": "workspace-write" }
|
|
169
|
+
→ { "kind": "permission", "sessionId": "session-abc", "set": "workspace-write",
|
|
170
|
+
"commandId": "cmd-1", "result": { "kind": "success", "text": "..." } }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 8. 新会话默认配置
|
|
176
|
+
|
|
177
|
+
| type | 参数 | 说明 |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `agent-presets` | — | preset 名册(含 `isDefault` 标记) |
|
|
180
|
+
| `defaults` | — | 读取默认 agent 预设 + 默认权限 |
|
|
181
|
+
| `set-default` | `target`(agent-preset\|permission), `value` | 修改默认预设/默认权限(全局) |
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{ "type": "defaults" }
|
|
185
|
+
→ { "kind": "defaults", "agentPresetDefault": "standard", "permissionDefault": "ask" }
|
|
186
|
+
|
|
187
|
+
{ "type": "set-default", "target": "agent-preset", "value": "minimal" }
|
|
188
|
+
→ { "kind": "set-default", "target": "agent-preset", "value": "minimal", "applied": true }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 9. 分支(fork)
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{ "type": "fork", "sessionId": "session-abc", "atSeq": 42 }
|
|
197
|
+
→ { "kind": "fork", "sessionId": "session-分支新会话" }
|
|
198
|
+
```
|
|
199
|
+
- `atSeq`:从该消息所在的**完整一轮**分叉(省略 = 最近完成的 turn);进行中的 turn 分叉会报 `fork-unavailable`
|
|
200
|
+
- 子会话继承源 cwd / 模型 / 血缘 / 标题 / 工作区
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 10. 宿主信息
|
|
205
|
+
|
|
206
|
+
| type | 返回 |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `host` | `version`, `cwd`, `provider`/`model`(默认模型精简版), `attachedSessions`, `canOpenPath` |
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{ "type": "host" }
|
|
212
|
+
→ { "kind": "host", "version": "0.1.0-rc.6", "cwd": "/Users/lichaofan",
|
|
213
|
+
"provider": "deepseek", "model": "deepseek-chat", "attachedSessions": 3, "canOpenPath": true }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## 11. 服务端主动推送
|
|
219
|
+
|
|
220
|
+
| kind | 触发时机 |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `hello` | 连接成功:`{ "kind":"hello", "protocol":1, "port":3080, "clients":1 }` |
|
|
223
|
+
| `event` | 任意会话的 agent 输出(见下) |
|
|
224
|
+
| `pong` / `subscribed` / `sent` | 对应请求的回复 |
|
|
225
|
+
|
|
226
|
+
### `event` 帧(agent 实时输出)
|
|
227
|
+
```json
|
|
228
|
+
{ "kind": "event", "sessionId": "session-abc", "seq": 42, "time": 1786937352,
|
|
229
|
+
"event": { "type": "assistant/chunk", "turn": 1, "step": 0, "chunkType": "text-delta", "text": "正在" } }
|
|
230
|
+
```
|
|
231
|
+
`event.type` 覆盖(精炼字段):
|
|
232
|
+
- `user/message` → `{text, source}`
|
|
233
|
+
- `assistant/chunk` → `{turn, step, chunkType: text-delta|reasoning-delta|tool-call-delta|usage|finish, text?/tool?/usage?/finish?}`
|
|
234
|
+
- `assistant/message` → `{turn, step, text, reasoning, toolCalls[]}`
|
|
235
|
+
- `tool/call` → `{turn, step, callId, name, arguments}`
|
|
236
|
+
- `tool/result` → `{turn, step, callId, isError, preview(≤400字符)}`
|
|
237
|
+
- `turn/start|end` / `step/start|end` → `{turn, step, reason?}`
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 12. 端到端示例(Postman)
|
|
242
|
+
|
|
243
|
+
1. Connect → 收到 `hello`
|
|
244
|
+
2. `{"type":"sessions"}` → 挑 `sessionId`(或直接下一步自动建)
|
|
245
|
+
3. `{"type":"subscribe","sessionId":"session-abc"}`
|
|
246
|
+
4. `{"type":"message","sessionId":"session-abc","text":"帮我查一下deepseek"}` → `sent`
|
|
247
|
+
5. 盯着 Messages 面板:`event` 流实时滚动(chunk → tool/call → tool/result → assistant/message)
|
|
248
|
+
6. `{"type":"history","sessionId":"session-abc","view":"conversation","maxMessages":60}` → 最近历史(自动分页用 `beforeSeq: nextBeforeSeq`)
|
|
249
|
+
7. `{"type":"session-stats","sessionId":"session-abc"}` → 统计条数据
|
|
250
|
+
8. 完事 `{"type":"unsubscribe"}` 或 Disconnect
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 13. 安全注意
|
|
255
|
+
|
|
256
|
+
- 当前 `/ws/mobile` **无认证**:任何能连到端口的人都能读会话、发消息、改配置(含全局默认值)
|
|
257
|
+
- 仅限本机/受信局域网使用;**暴露公网前必须加 token 认证**
|
|
258
|
+
- `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## 14. 版本历史(插件)
|
|
263
|
+
|
|
264
|
+
| 版本 | 新增 |
|
|
265
|
+
|---|---|
|
|
266
|
+
| v0.1.5 | workspace-create / directories / host |
|
|
267
|
+
| v0.1.6 | 修复消息分发器遗漏(host/directories/workspace-create 未路由) |
|
|
268
|
+
| v0.1.7 | models / select-model / permission-options / permission / context-usage |
|
|
269
|
+
| v0.1.8 | permission 改走 typert 网关(不再经 prompt) |
|
|
270
|
+
| v0.1.9 | 修复 `agentId` wire 键 |
|
|
271
|
+
| v0.1.10 | message 支持 workspaceId/cwd 建会话 |
|
|
272
|
+
| v0.1.11 | agent-presets / defaults / set-default |
|
|
273
|
+
| v0.1.12 | history 字节上限 + 自动续页 + conversation 裁剪 |
|
|
274
|
+
| v0.1.13 | session-stats |
|
|
275
|
+
| v0.1.14 | default-model |
|
|
276
|
+
| v0.1.15 | save-default-model |
|
|
277
|
+
| v0.1.16 | fork(新对话分支) |
|
|
278
|
+
| v0.1.17 | models 支持无 sessionId 全局目录;新增 providers |
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
*协议与插件源码同源维护:`dsh-plugin-mobile-gateway/lib/index.js` 顶部注释即协议摘要。*
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# dsh-plugin-mobile-gateway
|
|
2
|
+
|
|
3
|
+
dsh 的持久化 WebSocket 网关:在 web 服务器上注册 `/ws/mobile`,转发 agent 实时输出给移动端,并提供会话/工作区/模型/权限/默认配置/分支等 24 个信令。
|
|
4
|
+
|
|
5
|
+
- **协议文档**:[`PROTOCOL.md`](PROTOCOL.md)(与源码 `lib/index.js` 顶部注释同源)
|
|
6
|
+
- **日志**:`/tmp/mobile-gateway.log`(连接/查询/错误),宿主 stdout 同步输出
|
|
7
|
+
|
|
8
|
+
## 它是如何生效的
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
① package.json 声明 dsh.bundle.patch(门槛:有它才算 dsh 插件)
|
|
12
|
+
② dsh plugin --profile web add <路径> → pnpm 安装 + 自动加入 dsh.profile.bundles
|
|
13
|
+
③ 启动 dsh web → 按 bundles 顺序叠加各 bundle 的 cordis.patch.yml → 插件生效
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`dsh.bundle.patch` 缺失时 `dsh plugin add` 只装依赖、**不生效**。
|
|
17
|
+
|
|
18
|
+
## 管理命令速查
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 安装(file: 复制安装,自包含,依赖解析最稳)
|
|
22
|
+
dsh plugin --profile web add file:/Users/lichaofan/DeepseekHarnessProject/dsh-plugin-mobile-gateway
|
|
23
|
+
|
|
24
|
+
# 更新(file: 是复制安装——改源码后必须 remove + add 才会刷新副本)
|
|
25
|
+
dsh plugin --profile web remove dsh-plugin-mobile-gateway
|
|
26
|
+
dsh plugin --profile web add file:/Users/lichaofan/DeepseekHarnessProject/dsh-plugin-mobile-gateway
|
|
27
|
+
|
|
28
|
+
# 卸载
|
|
29
|
+
dsh plugin --profile web remove dsh-plugin-mobile-gateway
|
|
30
|
+
# 并从 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 里删除该包名
|
|
31
|
+
|
|
32
|
+
# 验证组合树(不启动)
|
|
33
|
+
dsh --profile web --dump-config | grep -A3 mobile-gateway
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 重要注意事项
|
|
37
|
+
|
|
38
|
+
| 事项 | 说明 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| **改代码必须重装** | `file:` 是复制,改 `lib/index.js` 后需 remove + add(或 bump package.json 版本) |
|
|
41
|
+
| **重装后必须重启** | composition 启动时解析,web profile 的 HMR 被禁用:`dsh web` 重启才生效 |
|
|
42
|
+
| **不要用 link:** | `link:` 符号链接会从源码真实路径解析依赖(`ws` 会找不到);`file:` 复制进 profile node_modules 才稳 |
|
|
43
|
+
| **补丁覆盖是整段替换** | 覆盖现有行要重述全部 config key;行 id 不能与现有行冲突 |
|
|
44
|
+
| **认证缺失** | `/ws/mobile` 无鉴权,暴露公网前必须加 token |
|
|
45
|
+
|
|
46
|
+
## 本地测试
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 完整分发链路测试(mock harness + 真实 ws 客户端,当前 34 个断言)
|
|
50
|
+
NODE_PATH=/Users/lichaofan/.npm/_npx/1e7f6d9597241db0/node_modules \
|
|
51
|
+
node test/gateway.test.js
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 版本历史
|
|
55
|
+
|
|
56
|
+
见 `PROTOCOL.md` §14。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# dsh-plugin-mobile-gateway bundle patch.
|
|
2
|
+
# Applied after dsh-base and dsh-web-app: inserts the persistent /ws/mobile
|
|
3
|
+
# gateway row into the host plane (it consumes the host `webServer` service
|
|
4
|
+
# and the host `session/event` feed, and publishes no service of its own).
|
|
5
|
+
- insert:
|
|
6
|
+
- id: mobile-gateway
|
|
7
|
+
name: 'dsh-plugin-mobile-gateway'
|
|
8
|
+
|
|
9
|
+
# The gateway's `search` query channel needs the full-text session index,
|
|
10
|
+
# which dsh-web-app deliberately ships as `openAt: never` (search disabled).
|
|
11
|
+
# This later layer opts the deployment into on-demand opening: the node:sqlite
|
|
12
|
+
# import and the in-memory index stay deferred until the first search call,
|
|
13
|
+
# so startup is unaffected. A patch replaces the row's whole config, so both
|
|
14
|
+
# keys are restated here.
|
|
15
|
+
- id: session-query-sqlite
|
|
16
|
+
config:
|
|
17
|
+
path: ':memory:'
|
|
18
|
+
openAt: first-search
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
// dsh-plugin-mobile-gateway — persistent Host plugin.
|
|
2
|
+
//
|
|
3
|
+
// Two directions:
|
|
4
|
+
// 1. Agent -> mobile: registers a WebSocket endpoint at /ws/mobile on the dsh
|
|
5
|
+
// web server and forwards agent session output (the same `session/event`
|
|
6
|
+
// feed the browser UI consumes) to connected mobile clients as curated
|
|
7
|
+
// JSON text frames.
|
|
8
|
+
// 2. Mobile -> agent: clients send a message through the same socket; the
|
|
9
|
+
// plugin admits it through the official host API (apiProxy.sessions.prompt)
|
|
10
|
+
// so it enters the target session exactly like a browser-submitted prompt.
|
|
11
|
+
//
|
|
12
|
+
// Wire protocol (JSON text frames):
|
|
13
|
+
// client -> server: { "type": "ping" }
|
|
14
|
+
// { "type": "subscribe", "sessionId": "..." } (optional filter)
|
|
15
|
+
// { "type": "unsubscribe" }
|
|
16
|
+
// { "type": "message", "sessionId"?, "text", "mode"?: "queue"|"steer",
|
|
17
|
+
// "workspaceId"?, "cwd"? }
|
|
18
|
+
// sessionId omitted -> a new session is created first; the
|
|
19
|
+
// new session can be placed in a workspace via workspaceId
|
|
20
|
+
// (or cwd; at most one, workspaceId wins)
|
|
21
|
+
// { "type": "workspaces" } -> workspace list
|
|
22
|
+
// { "type": "sessions" } -> session list
|
|
23
|
+
// { "type": "history", "sessionId", "beforeSeq"?, "maxMessages"?,
|
|
24
|
+
// "maxBytes"?, "view"?: "conversation" }
|
|
25
|
+
// -> raw SessionEvent page (scheme A), capped at maxBytes
|
|
26
|
+
// (default 4 MiB); hasMore + nextBeforeSeq page backward;
|
|
27
|
+
// view:"conversation" trims chunk/header events and tool output
|
|
28
|
+
// { "type": "search", "query" } -> session search
|
|
29
|
+
// { "type": "host" } -> host.describe snapshot (incl. default provider/model)
|
|
30
|
+
// { "type": "default-model" } -> agentDefaultModel.currentSelection()
|
|
31
|
+
// { "type": "save-default-model", "provider", "model", "reasoningEffort"? }
|
|
32
|
+
// { "type": "directories", "path"? } -> server dir listing (fs-based)
|
|
33
|
+
// { "type": "workspace-create", "path" } -> create workspace over a dir
|
|
34
|
+
// { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
|
|
35
|
+
// { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
|
|
36
|
+
// { "type": "providers" } -> configurable provider list (live/dormant)
|
|
37
|
+
// { "type": "select-model", "sessionId", "provider", "model", "reasoningEffort"? }
|
|
38
|
+
// { "type": "permission-options", "sessionId"? } -> permission presets (+ session knobs)
|
|
39
|
+
// { "type": "permission", "sessionId", "name" } -> switch preset via /permission command
|
|
40
|
+
// { "type": "context-usage", "sessionId" } -> tokenUsage + contextPressure projections
|
|
41
|
+
// { "type": "session-stats", "sessionId" } -> sessionStats + tokenUsage projections
|
|
42
|
+
// (the input-box stats strip source)
|
|
43
|
+
// { "type": "agent-presets" } -> preset roster (+ isDefault)
|
|
44
|
+
// { "type": "defaults" } -> default agent preset + default permission
|
|
45
|
+
// { "type": "set-default", "target": "agent-preset"|"permission", "value" }
|
|
46
|
+
// server -> client: { "kind": "hello", "protocol": 1, "port", "clients" }
|
|
47
|
+
// { "kind": "pong", "at" }
|
|
48
|
+
// { "kind": "subscribed", "sessionId" }
|
|
49
|
+
// { "kind": "sent", "sessionId", "mode", "command"? }
|
|
50
|
+
// { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
|
|
51
|
+
// { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
|
|
52
|
+
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
53
|
+
'use strict'
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs')
|
|
56
|
+
const fsp = require('node:fs/promises')
|
|
57
|
+
const os = require('node:os')
|
|
58
|
+
const path = require('node:path')
|
|
59
|
+
const crypto = require('node:crypto')
|
|
60
|
+
const { WebSocketServer } = require('ws')
|
|
61
|
+
|
|
62
|
+
const MAX_PREVIEW = 400
|
|
63
|
+
const LOG_FILE = '/tmp/mobile-gateway.log'
|
|
64
|
+
|
|
65
|
+
function log(line) {
|
|
66
|
+
const msg = `[${new Date().toISOString()}] ${line}`
|
|
67
|
+
console.log('mobile-gateway:', msg)
|
|
68
|
+
try {
|
|
69
|
+
fs.appendFileSync(LOG_FILE, msg + '\n')
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// never let logging break the gateway
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extract plain text from a ContentBlock[] (text blocks only).
|
|
76
|
+
function textOf(blocks) {
|
|
77
|
+
let text = ''
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
80
|
+
}
|
|
81
|
+
return text
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build the small, owned JSON wire record for one session event. Reads only
|
|
85
|
+
// leaf fields of the live SessionEvent — never serializes live objects.
|
|
86
|
+
function buildWireEvent(session, event) {
|
|
87
|
+
const base = { sessionId: String(session.id), seq: event.seq, time: event.time }
|
|
88
|
+
const d = event.data || {}
|
|
89
|
+
switch (event.type) {
|
|
90
|
+
case 'user/message':
|
|
91
|
+
return Object.assign(base, {
|
|
92
|
+
event: {
|
|
93
|
+
type: 'user/message',
|
|
94
|
+
text: textOf(d.content || []),
|
|
95
|
+
source: d.source && d.source.kind,
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
case 'assistant/chunk': {
|
|
99
|
+
const chunk = d.chunk || {}
|
|
100
|
+
const ev = { type: 'assistant/chunk', turn: d.turn, step: d.step, chunkType: chunk.type }
|
|
101
|
+
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') ev.text = chunk.text
|
|
102
|
+
if (chunk.type === 'tool-call-delta') ev.tool = { id: chunk.id, name: chunk.name, argumentsDelta: chunk.argumentsDelta }
|
|
103
|
+
if (chunk.type === 'usage') ev.usage = chunk.usage
|
|
104
|
+
if (chunk.type === 'finish') ev.finish = { kind: chunk.reason && chunk.reason.kind }
|
|
105
|
+
return Object.assign(base, { event: ev })
|
|
106
|
+
}
|
|
107
|
+
case 'assistant/message': {
|
|
108
|
+
const blocks = (d.message && d.message.content) || []
|
|
109
|
+
let text = ''
|
|
110
|
+
let reasoning = ''
|
|
111
|
+
const toolCalls = []
|
|
112
|
+
for (const block of blocks) {
|
|
113
|
+
if (!block) continue
|
|
114
|
+
if (block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
115
|
+
else if (block.type === 'reasoning' && typeof block.text === 'string') reasoning += block.text
|
|
116
|
+
else if (block.type === 'tool-call') toolCalls.push({ id: block.id, name: block.name, arguments: block.arguments })
|
|
117
|
+
}
|
|
118
|
+
return Object.assign(base, {
|
|
119
|
+
event: { type: 'assistant/message', turn: d.turn, step: d.step, text, reasoning, toolCalls },
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
case 'tool/call':
|
|
123
|
+
return Object.assign(base, {
|
|
124
|
+
event: { type: 'tool/call', turn: d.turn, step: d.step, callId: d.callId, name: d.name, arguments: d.arguments },
|
|
125
|
+
})
|
|
126
|
+
case 'tool/result': {
|
|
127
|
+
let preview = ''
|
|
128
|
+
for (const block of (d.message && d.message.content) || []) {
|
|
129
|
+
for (const inner of (block && block.content) || []) {
|
|
130
|
+
if (inner.type === 'text' && typeof inner.text === 'string') preview += inner.text
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (preview.length > MAX_PREVIEW) preview = preview.slice(0, MAX_PREVIEW) + '…'
|
|
134
|
+
return Object.assign(base, {
|
|
135
|
+
event: {
|
|
136
|
+
type: 'tool/result',
|
|
137
|
+
turn: d.turn,
|
|
138
|
+
step: d.step,
|
|
139
|
+
callId: d.message && d.message.source && d.message.source.callId,
|
|
140
|
+
isError: !!d.error,
|
|
141
|
+
preview,
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
case 'turn/start':
|
|
146
|
+
case 'turn/end':
|
|
147
|
+
case 'step/start':
|
|
148
|
+
case 'step/end':
|
|
149
|
+
return Object.assign(base, {
|
|
150
|
+
event: { type: event.type, turn: d.turn, step: d.step, reason: d.reason && d.reason.kind },
|
|
151
|
+
})
|
|
152
|
+
default:
|
|
153
|
+
return Object.assign(base, { event: { type: event.type } })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Handle one mobile -> agent message through the official host API. Returns
|
|
158
|
+
// the wire frame to send back, or null when nothing should be sent.
|
|
159
|
+
async function admitMessage(api, msg) {
|
|
160
|
+
const text = typeof msg.text === 'string' ? msg.text.trim() : ''
|
|
161
|
+
if (!text) {
|
|
162
|
+
return { kind: 'error', code: 'bad-request', message: 'text must be a non-empty string' }
|
|
163
|
+
}
|
|
164
|
+
const mode = msg.mode === 'steer' ? 'steer' : 'queue'
|
|
165
|
+
|
|
166
|
+
let sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : undefined
|
|
167
|
+
try {
|
|
168
|
+
if (sessionId === undefined) {
|
|
169
|
+
// New session: optionally place it in a workspace (at most one of
|
|
170
|
+
// workspaceId / cwd, per the host create contract; workspaceId wins).
|
|
171
|
+
const createPayload = {}
|
|
172
|
+
if (typeof msg.workspaceId === 'string' && msg.workspaceId.trim() !== '') {
|
|
173
|
+
createPayload.workspaceId = msg.workspaceId.trim()
|
|
174
|
+
}
|
|
175
|
+
if (typeof msg.cwd === 'string' && msg.cwd.trim() !== '') {
|
|
176
|
+
if (createPayload.workspaceId) log('mobile message: both workspaceId and cwd given, using workspaceId')
|
|
177
|
+
else createPayload.cwd = msg.cwd.trim()
|
|
178
|
+
}
|
|
179
|
+
const created = await api.sessions.create({ rpcId: crypto.randomUUID(), payload: createPayload })
|
|
180
|
+
if (!created.result.ok) {
|
|
181
|
+
return { kind: 'error', code: created.result.error.code, message: created.result.error.message }
|
|
182
|
+
}
|
|
183
|
+
sessionId = created.result.value.sessionId
|
|
184
|
+
log(`mobile message created new session ${sessionId} (${JSON.stringify(createPayload)})`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const resp = await api.sessions.prompt({
|
|
188
|
+
rpcId: crypto.randomUUID(),
|
|
189
|
+
payload: { sessionId, mode, content: [{ type: 'text', text }] },
|
|
190
|
+
})
|
|
191
|
+
if (resp.result.ok) {
|
|
192
|
+
log(`mobile message accepted: session=${sessionId} mode=${mode} text="${text.slice(0, 60)}"`)
|
|
193
|
+
return {
|
|
194
|
+
kind: 'sent',
|
|
195
|
+
sessionId,
|
|
196
|
+
mode,
|
|
197
|
+
...(resp.result.value.command ? { command: resp.result.value.command } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
log(`mobile message rejected: ${resp.result.error.code}: ${resp.result.error.message}`)
|
|
201
|
+
return {
|
|
202
|
+
kind: 'error',
|
|
203
|
+
code: resp.result.error.code,
|
|
204
|
+
message: resp.result.error.message,
|
|
205
|
+
...(sessionId ? { sessionId } : {}),
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
const message = error && error.message ? error.message : String(error)
|
|
209
|
+
log(`mobile message failed: ${message}`)
|
|
210
|
+
return { kind: 'error', code: 'internal', message, ...(sessionId ? { sessionId } : {}) }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Proxy one read-only query to the official host API (the same surface the
|
|
215
|
+
// browser uses). Success returns `{ kind: <type>, ...value }`; failure returns
|
|
216
|
+
// the uniform `{ kind: 'error', code, message, requestType }` frame.
|
|
217
|
+
async function proxyQuery(api, type, method, payload, signal) {
|
|
218
|
+
try {
|
|
219
|
+
const req = { rpcId: crypto.randomUUID(), payload }
|
|
220
|
+
const resp = signal ? await method(req, signal) : await method(req)
|
|
221
|
+
if (resp.result.ok) {
|
|
222
|
+
log(`query ok: ${type}`)
|
|
223
|
+
return { kind: type, ...resp.result.value }
|
|
224
|
+
}
|
|
225
|
+
log(`query rejected: ${type} -> ${resp.result.error.code}: ${resp.result.error.message}`)
|
|
226
|
+
return {
|
|
227
|
+
kind: 'error',
|
|
228
|
+
code: resp.result.error.code,
|
|
229
|
+
message: resp.result.error.message,
|
|
230
|
+
requestType: type,
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const message = error && error.message ? error.message : String(error)
|
|
234
|
+
log(`query failed: ${type} -> ${message}`)
|
|
235
|
+
return { kind: 'error', code: 'internal', message, requestType: type }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// List one directory level directly with node:fs, mirroring the official
|
|
240
|
+
// browse backend's semantics (crumbs + name-sorted child directories). Works
|
|
241
|
+
// on every deployment regardless of the composed picker capability, which
|
|
242
|
+
// `host.listDirectory` would otherwise gate behind the `browse` capability.
|
|
243
|
+
async function listServerDirectory(target) {
|
|
244
|
+
try {
|
|
245
|
+
const home = os.homedir()
|
|
246
|
+
const requested = target ? path.resolve(target) : home
|
|
247
|
+
const crumbs = []
|
|
248
|
+
let cur = requested
|
|
249
|
+
for (;;) {
|
|
250
|
+
crumbs.unshift({ name: cur === path.parse(cur).root ? cur : path.basename(cur), path: cur, hidden: false })
|
|
251
|
+
const parent = path.dirname(cur)
|
|
252
|
+
if (parent === cur) break
|
|
253
|
+
cur = parent
|
|
254
|
+
}
|
|
255
|
+
const entries = []
|
|
256
|
+
const dirents = await fsp.readdir(requested, { withFileTypes: true })
|
|
257
|
+
for (const dirent of dirents) {
|
|
258
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
|
|
259
|
+
entries.push({ name: dirent.name, path: path.join(requested, dirent.name), hidden: dirent.name.startsWith('.') })
|
|
260
|
+
}
|
|
261
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
262
|
+
log(`query ok: directories (${requested}, ${entries.length} entries)`)
|
|
263
|
+
return { kind: 'directories', path: requested, home, crumbs, entries, truncated: false }
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const message = error && error.message ? error.message : String(error)
|
|
266
|
+
log(`query failed: directories -> ${message}`)
|
|
267
|
+
return { kind: 'error', code: 'directory-unreadable', message, requestType: 'directories' }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Validate one sessionId field; returns { value } or { error }.
|
|
272
|
+
function requireSessionId(msg) {
|
|
273
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
274
|
+
if (!sessionId) {
|
|
275
|
+
return { error: { kind: 'error', code: 'bad-request', message: 'this request requires a sessionId', requestType: typeof msg.type === 'string' ? msg.type : 'query' } }
|
|
276
|
+
}
|
|
277
|
+
return { value: sessionId }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// History frame sizing: raw session logs can be huge (trajectory, tool output,
|
|
282
|
+
// context), so a single history response must never blow the client's
|
|
283
|
+
// WebSocket frame limit. We cap the serialized size per frame, keep the NEWEST
|
|
284
|
+
// suffix within the budget, and hand the client `hasMore` + `nextBeforeSeq` so
|
|
285
|
+
// it can page backward. An optional `view: "conversation"` trims events that
|
|
286
|
+
// are not needed for a chat page (token-level chunks, system-prompt headers)
|
|
287
|
+
// and truncates oversized tool-result text.
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
const HISTORY_DEFAULT_MAX_BYTES = 4 * 1024 * 1024 // 4 MiB per frame
|
|
290
|
+
const HISTORY_TOOL_RESULT_MAX_CHARS = 2000 // per text block in conversation view
|
|
291
|
+
|
|
292
|
+
function eventBytes(event) {
|
|
293
|
+
return Buffer.byteLength(JSON.stringify(event), 'utf8')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Trim one event for the conversation page: null drops it, otherwise a shallow
|
|
297
|
+
// copy with oversized payloads truncated. The events come from the host RPC as
|
|
298
|
+
// plain JSON, so copying is safe.
|
|
299
|
+
function trimConversationEvent(event) {
|
|
300
|
+
switch (event.type) {
|
|
301
|
+
case 'assistant/chunk':
|
|
302
|
+
case 'request/header':
|
|
303
|
+
// token-level replay / system-prompt header: not rendered on the chat page
|
|
304
|
+
return null
|
|
305
|
+
case 'tool/result': {
|
|
306
|
+
const d = event.data || {}
|
|
307
|
+
const message = d.message
|
|
308
|
+
if (!message || !Array.isArray(message.content)) return event
|
|
309
|
+
let changed = false
|
|
310
|
+
const truncateBlock = (block) => {
|
|
311
|
+
if (!block || typeof block !== 'object') return block
|
|
312
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text.length > HISTORY_TOOL_RESULT_MAX_CHARS) {
|
|
313
|
+
changed = true
|
|
314
|
+
return { ...block, text: block.text.slice(0, HISTORY_TOOL_RESULT_MAX_CHARS) + '…' }
|
|
315
|
+
}
|
|
316
|
+
if (Array.isArray(block.content)) {
|
|
317
|
+
return { ...block, content: block.content.map(truncateBlock) }
|
|
318
|
+
}
|
|
319
|
+
return block
|
|
320
|
+
}
|
|
321
|
+
const content = message.content.map(truncateBlock)
|
|
322
|
+
if (!changed) return event
|
|
323
|
+
return { ...event, data: { ...d, message: { ...message, content } } }
|
|
324
|
+
}
|
|
325
|
+
default:
|
|
326
|
+
return event
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Keep the newest suffix of `events` (ascending seq) within `maxBytes`.
|
|
331
|
+
// Always keeps the newest event even if it alone exceeds the budget (an event
|
|
332
|
+
// cannot be split); drops older events beyond the budget. Returns the kept
|
|
333
|
+
// events, their total serialized bytes, and whether anything was dropped.
|
|
334
|
+
function capHistoryEvents(events, maxBytes, trim) {
|
|
335
|
+
const processed = trim ? events.map(trimConversationEvent).filter(Boolean) : events
|
|
336
|
+
if (processed.length === 0) return { events: [], bytes: 0, dropped: 0 }
|
|
337
|
+
let total = 0
|
|
338
|
+
let keptStart = processed.length
|
|
339
|
+
for (let i = processed.length - 1; i >= 0; i--) {
|
|
340
|
+
const size = eventBytes(processed[i])
|
|
341
|
+
if (keptStart === processed.length) {
|
|
342
|
+
// always keep the newest event
|
|
343
|
+
total = size
|
|
344
|
+
keptStart = i
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (total + size > maxBytes) break
|
|
348
|
+
total += size
|
|
349
|
+
keptStart = i
|
|
350
|
+
}
|
|
351
|
+
return { events: processed.slice(keptStart), bytes: total, dropped: keptStart }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Dispatch one mobile query frame; returns the wire frame to send back.
|
|
355
|
+
async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
356
|
+
if (msg.type === 'workspaces') {
|
|
357
|
+
return proxyQuery(api, 'workspaces', api.workspace.list.bind(api.workspace), {})
|
|
358
|
+
}
|
|
359
|
+
if (msg.type === 'sessions') {
|
|
360
|
+
return proxyQuery(api, 'sessions', api.sessions.list.bind(api.sessions), {})
|
|
361
|
+
}
|
|
362
|
+
if (msg.type === 'history') {
|
|
363
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
364
|
+
if (!sessionId) {
|
|
365
|
+
return { kind: 'error', code: 'bad-request', message: 'history requires a sessionId', requestType: 'history' }
|
|
366
|
+
}
|
|
367
|
+
const payload = { sessionId }
|
|
368
|
+
if (typeof msg.beforeSeq === 'number' && Number.isFinite(msg.beforeSeq)) payload.beforeSeq = msg.beforeSeq
|
|
369
|
+
if (typeof msg.maxMessages === 'number' && Number.isFinite(msg.maxMessages)) payload.maxMessages = msg.maxMessages
|
|
370
|
+
const frame = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), payload)
|
|
371
|
+
if (frame.kind === 'history') {
|
|
372
|
+
// Scheme A base: pass the raw SessionEvent list through (drop the host
|
|
373
|
+
// render intent); keep the projections block and echo the sessionId.
|
|
374
|
+
const rawEvents = Array.isArray(frame.events) ? frame.events.map((entry) => entry.event) : []
|
|
375
|
+
// Byte budget + optional conversation trim — keeps the frame under the
|
|
376
|
+
// client's WebSocket limit and pages the rest via nextBeforeSeq.
|
|
377
|
+
const maxBytes = typeof msg.maxBytes === 'number' && Number.isFinite(msg.maxBytes) && msg.maxBytes > 0
|
|
378
|
+
? Math.floor(msg.maxBytes)
|
|
379
|
+
: HISTORY_DEFAULT_MAX_BYTES
|
|
380
|
+
const trim = msg.view === 'conversation'
|
|
381
|
+
const capped = capHistoryEvents(rawEvents, maxBytes, trim)
|
|
382
|
+
frame.events = capped.events
|
|
383
|
+
frame.sessionId = sessionId
|
|
384
|
+
frame.bytes = capped.bytes
|
|
385
|
+
if (trim) frame.view = 'conversation'
|
|
386
|
+
// hasMore combines the host's message-count pagination with byte-drop.
|
|
387
|
+
const apiHasMore = frame.hasMore === true
|
|
388
|
+
const byteDropped = capped.dropped > 0
|
|
389
|
+
frame.hasMore = apiHasMore || byteDropped > 0
|
|
390
|
+
if (frame.hasMore && capped.events.length > 0) {
|
|
391
|
+
frame.nextBeforeSeq = capped.events[0].seq // oldest kept event: page back from here
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return frame
|
|
395
|
+
}
|
|
396
|
+
if (msg.type === 'search') {
|
|
397
|
+
const query = typeof msg.query === 'string' ? msg.query.trim() : ''
|
|
398
|
+
if (!query) {
|
|
399
|
+
return { kind: 'error', code: 'bad-request', message: 'search requires a query', requestType: 'search' }
|
|
400
|
+
}
|
|
401
|
+
return proxyQuery(api, 'search', api.sessions.search.bind(api.sessions), { query }, new AbortController().signal)
|
|
402
|
+
}
|
|
403
|
+
if (msg.type === 'host') {
|
|
404
|
+
return proxyQuery(api, 'host', api.host.describe.bind(api.host), {})
|
|
405
|
+
}
|
|
406
|
+
if (msg.type === 'default-model') {
|
|
407
|
+
try {
|
|
408
|
+
const selection = agentDefaultModel.currentSelection()
|
|
409
|
+
log(`default-model queried: ${selection.provider}/${selection.model}`)
|
|
410
|
+
return { kind: 'default-model', selection }
|
|
411
|
+
} catch (error) {
|
|
412
|
+
const message = error && error.message ? error.message : String(error)
|
|
413
|
+
return { kind: 'error', code: 'internal', message, requestType: 'default-model' }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (msg.type === 'save-default-model') {
|
|
417
|
+
const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
|
|
418
|
+
const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
|
|
419
|
+
if (!provider || !model) {
|
|
420
|
+
return { kind: 'error', code: 'bad-request', message: 'save-default-model requires provider and model', requestType: 'save-default-model' }
|
|
421
|
+
}
|
|
422
|
+
const selection = { provider, model }
|
|
423
|
+
if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') selection.reasoningEffort = msg.reasoningEffort.trim()
|
|
424
|
+
try {
|
|
425
|
+
await agentDefaultModel.saveSelection(selection)
|
|
426
|
+
log(`default model saved: ${provider}/${model}${selection.reasoningEffort ? ' (effort=' + selection.reasoningEffort + ')' : ''}`)
|
|
427
|
+
return { kind: 'save-default-model', saved: selection }
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const message = error && error.message ? error.message : String(error)
|
|
430
|
+
log(`save-default-model failed: ${message}`)
|
|
431
|
+
return { kind: 'error', code: 'internal', message, requestType: 'save-default-model' }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (msg.type === 'fork') {
|
|
435
|
+
const sessionId = requireSessionId(msg)
|
|
436
|
+
if (sessionId.error) return sessionId.error
|
|
437
|
+
const payload = { sessionId: sessionId.value }
|
|
438
|
+
if (typeof msg.atSeq === 'number' && Number.isFinite(msg.atSeq)) payload.atSeq = Math.floor(msg.atSeq)
|
|
439
|
+
const frame = await proxyQuery(api, 'fork', api.sessions.fork.bind(api.sessions), payload)
|
|
440
|
+
if (frame.kind === 'fork') {
|
|
441
|
+
log(`forked session ${sessionId.value} -> ${frame.sessionId}${payload.atSeq !== undefined ? ' at seq ' + payload.atSeq : ''}`)
|
|
442
|
+
}
|
|
443
|
+
return frame
|
|
444
|
+
}
|
|
445
|
+
if (msg.type === 'workspace-create') {
|
|
446
|
+
const wsPath = typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : null
|
|
447
|
+
if (!wsPath) {
|
|
448
|
+
return { kind: 'error', code: 'bad-request', message: 'workspace-create requires a path', requestType: 'workspace-create' }
|
|
449
|
+
}
|
|
450
|
+
return proxyQuery(api, 'workspace-create', api.workspace.create.bind(api.workspace), { path: wsPath })
|
|
451
|
+
}
|
|
452
|
+
if (msg.type === 'directories') {
|
|
453
|
+
return listServerDirectory(typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : undefined)
|
|
454
|
+
}
|
|
455
|
+
if (msg.type === 'models') {
|
|
456
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
457
|
+
if (sessionId) {
|
|
458
|
+
// per-session catalog: current selection + routable + full groups
|
|
459
|
+
return proxyQuery(api, 'models', api.sessions.models.bind(api.sessions), { sessionId })
|
|
460
|
+
}
|
|
461
|
+
// global catalog: every provider route, no session needed
|
|
462
|
+
return proxyQuery(api, 'models', api.llm.models.bind(api.llm), {})
|
|
463
|
+
}
|
|
464
|
+
if (msg.type === 'providers') {
|
|
465
|
+
return proxyQuery(api, 'providers', api.llm.providers.bind(api.llm), {})
|
|
466
|
+
}
|
|
467
|
+
if (msg.type === 'select-model') {
|
|
468
|
+
const sessionId = requireSessionId(msg)
|
|
469
|
+
if (sessionId.error) return sessionId.error
|
|
470
|
+
const provider = typeof msg.provider === 'string' && msg.provider.trim() !== '' ? msg.provider.trim() : null
|
|
471
|
+
const model = typeof msg.model === 'string' && msg.model.trim() !== '' ? msg.model.trim() : null
|
|
472
|
+
if (!provider || !model) {
|
|
473
|
+
return { kind: 'error', code: 'bad-request', message: 'select-model requires provider and model', requestType: 'select-model' }
|
|
474
|
+
}
|
|
475
|
+
const payload = { sessionId: sessionId.value, provider, model }
|
|
476
|
+
if (typeof msg.reasoningEffort === 'string' && msg.reasoningEffort.trim() !== '') payload.reasoningEffort = msg.reasoningEffort.trim()
|
|
477
|
+
return proxyQuery(api, 'select-model', api.sessions.selectModel.bind(api.sessions), payload)
|
|
478
|
+
}
|
|
479
|
+
if (msg.type === 'permission-options') {
|
|
480
|
+
const frame = await proxyQuery(api, 'permission-options', api.settings.describe.bind(api.settings), {})
|
|
481
|
+
if (frame.kind !== 'permission-options') return frame
|
|
482
|
+
const out = {
|
|
483
|
+
kind: 'permission-options',
|
|
484
|
+
namespace: (frame.namespaces || []).find((n) => n.ns === 'permission') || null,
|
|
485
|
+
}
|
|
486
|
+
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
487
|
+
if (sessionId) {
|
|
488
|
+
const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId })
|
|
489
|
+
if (hist.kind === 'history' && hist.projections && hist.projections.values) {
|
|
490
|
+
out.sessionPermissions = hist.projections.values.permissions || null
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out
|
|
494
|
+
}
|
|
495
|
+
if (msg.type === 'permission') {
|
|
496
|
+
const sessionId = requireSessionId(msg)
|
|
497
|
+
if (sessionId.error) return sessionId.error
|
|
498
|
+
const name = typeof msg.name === 'string' && msg.name.trim() !== '' ? msg.name.trim() : null
|
|
499
|
+
if (!name) {
|
|
500
|
+
return { kind: 'error', code: 'bad-request', message: 'permission requires a name', requestType: 'permission' }
|
|
501
|
+
}
|
|
502
|
+
// Execute the /permission command through the Typert Remote gateway — the
|
|
503
|
+
// same endpoint the browser calls (remote.commands.execute). This runs the
|
|
504
|
+
// command registry handler WITHOUT sending anything to the model, and the
|
|
505
|
+
// api-remotes agent lookup resumes cold sessions just like prompt does.
|
|
506
|
+
try {
|
|
507
|
+
const execution = await typertGateway.invoke({
|
|
508
|
+
namespace: 'commands',
|
|
509
|
+
method: 'execute',
|
|
510
|
+
args: { agentId: sessionId.value, line: '/permission ' + name },
|
|
511
|
+
signal: new AbortController().signal,
|
|
512
|
+
})
|
|
513
|
+
if (execution === undefined || execution === null) {
|
|
514
|
+
return { kind: 'error', code: 'unknown-command', message: 'command not found: /permission', requestType: 'permission', sessionId: sessionId.value }
|
|
515
|
+
}
|
|
516
|
+
log(`permission switched: session=${sessionId.value} preset=${name} (commandId=${execution.commandId})`)
|
|
517
|
+
return {
|
|
518
|
+
kind: 'permission',
|
|
519
|
+
sessionId: sessionId.value,
|
|
520
|
+
set: name,
|
|
521
|
+
commandId: execution.commandId,
|
|
522
|
+
result: execution.result,
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
const code = error && error.code ? error.code : 'internal'
|
|
526
|
+
const message = error && error.message ? error.message : String(error)
|
|
527
|
+
log(`permission rejected: ${code}: ${message}`)
|
|
528
|
+
return { kind: 'error', code, message, requestType: 'permission', sessionId: sessionId.value }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (msg.type === 'context-usage') {
|
|
532
|
+
const sessionId = requireSessionId(msg)
|
|
533
|
+
if (sessionId.error) return sessionId.error
|
|
534
|
+
const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
|
|
535
|
+
if (hist.kind !== 'history') return hist
|
|
536
|
+
const values = (hist.projections && hist.projections.values) || {}
|
|
537
|
+
return {
|
|
538
|
+
kind: 'context-usage',
|
|
539
|
+
sessionId: sessionId.value,
|
|
540
|
+
asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
|
|
541
|
+
tokenUsage: values.tokenUsage || null,
|
|
542
|
+
contextPressure: values.contextPressure || null,
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (msg.type === 'session-stats') {
|
|
546
|
+
const sessionId = requireSessionId(msg)
|
|
547
|
+
if (sessionId.error) return sessionId.error
|
|
548
|
+
const hist = await proxyQuery(api, 'history', api.sessions.history.bind(api.sessions), { sessionId: sessionId.value })
|
|
549
|
+
if (hist.kind !== 'history') return hist
|
|
550
|
+
const values = (hist.projections && hist.projections.values) || {}
|
|
551
|
+
return {
|
|
552
|
+
kind: 'session-stats',
|
|
553
|
+
sessionId: sessionId.value,
|
|
554
|
+
asOfSeq: hist.projections ? hist.projections.asOfSeq : undefined,
|
|
555
|
+
sessionStats: values.sessionStats || null,
|
|
556
|
+
tokenUsage: values.tokenUsage || null,
|
|
557
|
+
contextPressure: values.contextPressure || null,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (msg.type === 'agent-presets') {
|
|
561
|
+
return proxyQuery(api, 'agent-presets', api.agentPresets.list.bind(api.agentPresets), {})
|
|
562
|
+
}
|
|
563
|
+
if (msg.type === 'defaults') {
|
|
564
|
+
const frame = await proxyQuery(api, 'defaults', api.settings.describe.bind(api.settings), {})
|
|
565
|
+
if (frame.kind !== 'defaults') return frame
|
|
566
|
+
const agentPresetNs = (frame.namespaces || []).find((n) => n.ns === 'agent-presets')
|
|
567
|
+
const permissionNs = (frame.namespaces || []).find((n) => n.ns === 'permission')
|
|
568
|
+
return {
|
|
569
|
+
kind: 'defaults',
|
|
570
|
+
agentPresetDefault: agentPresetNs && agentPresetNs.value && agentPresetNs.value.default !== undefined ? agentPresetNs.value.default : null,
|
|
571
|
+
permissionDefault: permissionNs && permissionNs.value && permissionNs.value.defaultPreset !== undefined ? permissionNs.value.defaultPreset : null,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (msg.type === 'set-default') {
|
|
575
|
+
const target = typeof msg.target === 'string' && msg.target.trim() !== '' ? msg.target.trim() : null
|
|
576
|
+
const value = typeof msg.value === 'string' && msg.value.trim() !== '' ? msg.value.trim() : null
|
|
577
|
+
if (target !== 'agent-preset' && target !== 'permission') {
|
|
578
|
+
return { kind: 'error', code: 'bad-request', message: 'set-default target must be "agent-preset" or "permission"', requestType: 'set-default' }
|
|
579
|
+
}
|
|
580
|
+
if (!value) {
|
|
581
|
+
return { kind: 'error', code: 'bad-request', message: 'set-default requires a value', requestType: 'set-default' }
|
|
582
|
+
}
|
|
583
|
+
const ns = target === 'agent-preset' ? 'agent-presets' : 'permission'
|
|
584
|
+
const patch = target === 'agent-preset' ? { default: value } : { defaultPreset: value }
|
|
585
|
+
const updated = await proxyQuery(api, 'set-default', api.settings.update.bind(api.settings), { ns, patch })
|
|
586
|
+
if (updated.kind !== 'set-default') return updated
|
|
587
|
+
log(`default updated: ${ns}.${Object.keys(patch)[0]} = ${value}`)
|
|
588
|
+
return { kind: 'set-default', target, value, applied: true, namespace: updated }
|
|
589
|
+
}
|
|
590
|
+
return null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = {
|
|
594
|
+
name: 'mobile-gateway',
|
|
595
|
+
// Hard dependencies: the gateway registers on the host web server and
|
|
596
|
+
// admits mobile messages through the host API gateway. Cordis must not
|
|
597
|
+
// activate this row before either service exists.
|
|
598
|
+
inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
|
|
599
|
+
// exported for local tests; Cordis ignores unknown plugin fields
|
|
600
|
+
admitMessage,
|
|
601
|
+
handleQuery,
|
|
602
|
+
apply(ctx, config) {
|
|
603
|
+
const webServer = ctx.webServer
|
|
604
|
+
const api = ctx.apiProxy
|
|
605
|
+
const typertGateway = ctx.typertGateway
|
|
606
|
+
const agentDefaultModel = ctx.agentDefaultModel
|
|
607
|
+
const path = (config && config.path) || '/ws/mobile'
|
|
608
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
609
|
+
const clients = new Set()
|
|
610
|
+
let counter = 0
|
|
611
|
+
|
|
612
|
+
log(`applying: path=${path}, webServer.port=${webServer.port}, apiProxy=${api ? 'present' : 'absent'}`)
|
|
613
|
+
|
|
614
|
+
const disposeUpgrade = webServer.registerUpgrade({
|
|
615
|
+
path,
|
|
616
|
+
handler(req, socket, head) {
|
|
617
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
618
|
+
const id = ++counter
|
|
619
|
+
ws.filterSessionId = undefined
|
|
620
|
+
clients.add(ws)
|
|
621
|
+
|
|
622
|
+
ws.on('message', (data) => {
|
|
623
|
+
let msg
|
|
624
|
+
try {
|
|
625
|
+
msg = JSON.parse(data.toString())
|
|
626
|
+
} catch (error) {
|
|
627
|
+
ws.send(JSON.stringify({ kind: 'error', message: 'invalid json' }))
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
if (!msg || typeof msg.type !== 'string') return
|
|
631
|
+
|
|
632
|
+
if (msg.type === 'ping') {
|
|
633
|
+
ws.send(JSON.stringify({ kind: 'pong', at: Date.now() }))
|
|
634
|
+
} else if (msg.type === 'subscribe') {
|
|
635
|
+
ws.filterSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : undefined
|
|
636
|
+
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: ws.filterSessionId || null }))
|
|
637
|
+
} else if (msg.type === 'unsubscribe') {
|
|
638
|
+
ws.filterSessionId = undefined
|
|
639
|
+
ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
|
|
640
|
+
} else if (msg.type === 'message') {
|
|
641
|
+
admitMessage(api, msg).then((frame) => {
|
|
642
|
+
if (frame) ws.send(JSON.stringify(frame))
|
|
643
|
+
})
|
|
644
|
+
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' ||
|
|
645
|
+
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
|
|
646
|
+
msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
|
|
647
|
+
msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
|
|
648
|
+
msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
|
|
649
|
+
msg.type === 'session-stats' || msg.type === 'default-model' ||
|
|
650
|
+
msg.type === 'save-default-model' || msg.type === 'fork' || msg.type === 'providers') {
|
|
651
|
+
handleQuery(api, typertGateway, agentDefaultModel, msg).then((frame) => {
|
|
652
|
+
if (frame) ws.send(JSON.stringify(frame))
|
|
653
|
+
})
|
|
654
|
+
} else {
|
|
655
|
+
ws.send(JSON.stringify({ kind: 'error', message: 'unknown message type: ' + msg.type }))
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
ws.on('close', () => {
|
|
660
|
+
clients.delete(ws)
|
|
661
|
+
log(`client disconnected (id=${id}, remaining=${clients.size})`)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
log(`client connected (id=${id}, total=${clients.size})`)
|
|
665
|
+
ws.send(JSON.stringify({ kind: 'hello', protocol: 1, port: webServer.port, clients: clients.size }))
|
|
666
|
+
})
|
|
667
|
+
},
|
|
668
|
+
})
|
|
669
|
+
log(`upgrade route registered: ${path}`)
|
|
670
|
+
|
|
671
|
+
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
672
|
+
if (clients.size === 0) return
|
|
673
|
+
const wire = buildWireEvent(session, event)
|
|
674
|
+
if (!wire) return
|
|
675
|
+
const payload = JSON.stringify(wire)
|
|
676
|
+
for (const client of clients) {
|
|
677
|
+
if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
|
|
678
|
+
if (client.readyState === 1) client.send(payload)
|
|
679
|
+
}
|
|
680
|
+
})
|
|
681
|
+
log('session/event listener attached')
|
|
682
|
+
|
|
683
|
+
ctx.effect(() => () => {
|
|
684
|
+
disposeUpgrade()
|
|
685
|
+
disposeEvents()
|
|
686
|
+
for (const client of clients) client.terminate()
|
|
687
|
+
clients.clear()
|
|
688
|
+
wss.close()
|
|
689
|
+
log('plugin stopped, all sockets closed')
|
|
690
|
+
})
|
|
691
|
+
},
|
|
692
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
+
"version": "0.1.17",
|
|
4
|
+
"description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"cordis.patch.yml",
|
|
9
|
+
"PROTOCOL.md"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"ws": "^8.18.0"
|
|
13
|
+
},
|
|
14
|
+
"dsh": {
|
|
15
|
+
"bundle": {
|
|
16
|
+
"patch": "./cordis.patch.yml"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|