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 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。
@@ -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
+ }