dsh-mobile-plugin 0.1.84 → 0.1.86

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.
@@ -24,6 +24,16 @@ const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal
24
24
 
25
25
  const SSE_RETRY_MS = 3000;
26
26
  const DEDUP_WINDOW = 2048;
27
+ /**
28
+ * 写缓冲的两道水位(字节),与 `mobile/stream.js` 同一口径。
29
+ *
30
+ * `res.write()` 在客户端读得慢时返回 false,字节留在 Node 的写队列里。设备级流以前
31
+ * 完全不看这个信号,于是「手机挂着一条流、网又慢」时帧无限堆积 —— 2026-10-17 真机
32
+ * 现场:堆快照里 17.6 万条 SSE 帧(466MB)挂在 `WriteWrap._chunks` 上,宿主 70 分钟
33
+ * 涨到 3.9G 被 OOM 杀掉。软水位只丢信号帧(幂等、下一条变更会再补),硬水位才收尾。
34
+ */
35
+ const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
36
+ const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
27
37
  /** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
28
38
  const HEARTBEAT_MS = 15_000;
29
39
 
@@ -56,12 +66,40 @@ function stableEventId(payload, frame) {
56
66
  return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
57
67
  }
58
68
 
69
+ /**
70
+ * 这条连接上真正「还没发出去」的字节数。
71
+ *
72
+ * 只看 `res.writableLength` 是错的:它只算响应对象自己那一层,真正的积压在底下的套接字
73
+ * 写队列里(堆快照里那些 `{chunk, encoding, callback}`)。2026-10-18 复测证实:只看
74
+ * 前者时闸门从不触发,内存照涨。所以顺着 socket 一起看,取较大值。
75
+ */
76
+ function bufferedBytesOf(res) {
77
+ const own = Number(res.writableLength) || 0;
78
+ const socket = res.socket || res.connection;
79
+ const queued = socket ? Number(socket.writableLength) || 0 : 0;
80
+ return Math.max(own, queued);
81
+ }
82
+
83
+ /**
84
+ * 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 该收尾,
85
+ * `shed:true` 这条被丢掉,`needDrain:true` 写进去了但底下已经写不动。
86
+ *
87
+ * 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
88
+ * 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
89
+ * 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
90
+ */
59
91
  function writeSse(res, payload, frame) {
92
+ const buffered = bufferedBytesOf(res);
93
+ if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
94
+ if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
60
95
  const id = stableEventId(payload, frame);
61
96
  const fields = [`retry: ${SSE_RETRY_MS}`];
62
97
  if (id) fields.push(`id: ${id}`);
63
98
  fields.push(`data: ${JSON.stringify(payload)}`, '', '');
64
- try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
99
+ try {
100
+ const accepted = res.write(`${fields.join('\n')}\n`);
101
+ return { ok: true, needDrain: accepted === false };
102
+ } catch { return { ok: false }; }
65
103
  }
66
104
 
67
105
  async function* mergeHostAndMux(ctx, signal) {
@@ -153,13 +191,17 @@ async function handleDeviceStream(ctx, req, res) {
153
191
  /** 这条连接上真正写出去的帧数,只用于收尾日志(诊断「连着却一帧不发」)。 */
154
192
  let framesSent = 0;
155
193
  const writeFrame = (devicePayload) => {
156
- const written = writeSse(res, {
194
+ const result = writeSse(res, {
157
195
  type: 'device/event',
158
196
  version: 1,
159
197
  payload: devicePayload,
160
198
  }, null);
161
- if (written) framesSent += 1;
162
- return written;
199
+ if (!result.ok) return false;
200
+ if (result.shed) return true;
201
+ framesSent += 1;
202
+ // 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
203
+ // 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
204
+ return !result.needDrain;
163
205
  };
164
206
  /**
165
207
  * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
@@ -267,7 +309,9 @@ async function handleDeviceStream(ctx, req, res) {
267
309
  payload,
268
310
  };
269
311
  // Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
270
- if (!writeSse(res, envelope, frame)) { clientGone = true; break; }
312
+ const sent = writeSse(res, envelope, frame);
313
+ if (sent.shed) continue;
314
+ if (!sent.ok || sent.needDrain) { clientGone = true; break; }
271
315
  framesSent += 1;
272
316
  }
273
317
  if (!clientGone) loopEnded = true;
@@ -79,6 +79,26 @@ function normalizeResultDiffs(meta) {
79
79
  return out.length > 0 ? out : undefined;
80
80
  }
81
81
 
82
+ /**
83
+ * tool/result 的可见内容:两种形状都认。
84
+ *
85
+ * - 当前形状:`message.content` 直接就是内容块数组,成败在 `message.isError`;
86
+ * - 旧形状:`message.content[0]` 是 `{ isError, content: [内容块] }` 信封。
87
+ *
88
+ * 曾经只认旧形状,于是真实结果里的图片(`{type:'image', attachment}`)被当成信封剥掉、
89
+ * 整张图丢失,结果级 isError 也读不到(失败的工具显示成成功)。
90
+ */
91
+ function toolResultParts(msg) {
92
+ const direct = Array.isArray(msg.content) ? msg.content : [];
93
+ const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
94
+ && typeof direct[0].type !== 'string' ? direct[0] : null;
95
+ if (!envelope) return { parts: direct, isError: msg.isError === true };
96
+ return {
97
+ parts: Array.isArray(envelope.content) ? envelope.content : [],
98
+ isError: envelope.isError === true || msg.isError === true,
99
+ };
100
+ }
101
+
82
102
  /** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
83
103
  function buildToolResultMap(events) {
84
104
  const map = new Map();
@@ -88,10 +108,8 @@ function buildToolResultMap(events) {
88
108
  const msg = e.data.message;
89
109
  const src = msg.source;
90
110
  if (!src || typeof src.callId !== 'string') continue;
91
- const content = Array.isArray(msg.content) ? msg.content[0] : null;
92
- const isError = content && content.isError === true;
111
+ const { parts, isError } = toolResultParts(msg);
93
112
  const code = e.data.error && typeof e.data.error.code === 'string' ? e.data.error.code : undefined;
94
- const parts = content && Array.isArray(content.content) ? content.content : content ? [content] : [];
95
113
  const output = parts.map((part) => {
96
114
  if (part && typeof part.text === 'string') return part.text;
97
115
  if (part && typeof part === 'object' && part.type === 'image') return '';
@@ -18,6 +18,8 @@
18
18
  * - 三级体积预算(单图 / 单段文本 / 单次响应,见下方常量):超预算的图保持字面 markdown,
19
19
  * 不截断、不缩水成半张图。
20
20
  * - 读不到、不是普通文件、读取失败:原样保留 —— 一条图片取不到不能让整段历史或整帧 SSE 挂掉。
21
+ * - 围栏代码块与行内代码里的图片语法一律不碰:那是**写法示例**,不是要画的图;换掉会把
22
+ * 三行代码变成几十万字符的 base64,App 的代码块会铺成很长很长的一块。
21
23
  */
22
24
 
23
25
  const fs = require('node:fs/promises');
@@ -126,17 +128,97 @@ async function dataUriFor(filePath) {
126
128
  return uri;
127
129
  }
128
130
 
131
+ /**
132
+ * Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
133
+ *
134
+ * 代码块里的 `![](/tmp/a.png)` 是**写法示例**而不是要画的图。旧实现整段正则替换,
135
+ * 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
136
+ * 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
137
+ * `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
138
+ */
139
+ function literalRanges(text) {
140
+ const ranges = [];
141
+ const lines = text.split('\n');
142
+ const lineStarts = [];
143
+ let offset = 0;
144
+ for (const line of lines) {
145
+ lineStarts.push(offset);
146
+ offset += line.length + 1;
147
+ }
148
+ let fence = null;
149
+ let fenceStart = -1;
150
+ for (let index = 0; index < lines.length; index += 1) {
151
+ const line = lines[index];
152
+ if (fence) {
153
+ const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
154
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
155
+ ranges.push([fenceStart, lineStarts[index] + line.length]);
156
+ fence = null;
157
+ }
158
+ continue;
159
+ }
160
+ const open = /^(`{3,}|~{3,})/.exec(line.trim());
161
+ if (!open) continue;
162
+ fence = { char: open[1][0], length: open[1].length };
163
+ fenceStart = lineStarts[index];
164
+ }
165
+ // 没闭合的围栏按 App 的解析口径一直吃到文末。
166
+ if (fence) ranges.push([fenceStart, text.length]);
167
+
168
+ // 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
169
+ let cursor = 0;
170
+ while (cursor < text.length) {
171
+ if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
172
+ cursor += 1;
173
+ continue;
174
+ }
175
+ let ticks = 1;
176
+ while (text[cursor + ticks] === '`') ticks += 1;
177
+ let scan = cursor + ticks;
178
+ let closed = -1;
179
+ while (scan < text.length) {
180
+ if (text[scan] !== '`' || inRanges(scan, ranges)) {
181
+ scan += 1;
182
+ continue;
183
+ }
184
+ let run = 1;
185
+ while (text[scan + run] === '`') run += 1;
186
+ if (run === ticks) {
187
+ closed = scan + run;
188
+ break;
189
+ }
190
+ scan += run;
191
+ }
192
+ if (closed < 0) {
193
+ cursor += ticks;
194
+ continue;
195
+ }
196
+ ranges.push([cursor, closed]);
197
+ cursor = closed;
198
+ }
199
+ return ranges;
200
+ }
201
+
202
+ function inRanges(index, ranges) {
203
+ for (const [start, end] of ranges) {
204
+ if (index >= start && index < end) return true;
205
+ }
206
+ return false;
207
+ }
208
+
129
209
  /**
130
210
  * 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
131
211
  * 调用方据此免掉不必要的对象拷贝。
132
212
  */
133
213
  async function inlineTextImages(text, budget) {
134
214
  if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
135
- const matches = [...text.matchAll(IMAGE_RE)];
215
+ const ranges = literalRanges(text);
216
+ const matches = [...text.matchAll(IMAGE_RE)].filter((m) => !inRanges(m.index, ranges));
136
217
  if (!matches.length) return text;
137
218
  const pool = budget || createTextImageBudget();
138
219
  let textBudget = MAX_TEXT_CHARS;
139
- let out = text;
220
+ let out = '';
221
+ let cursor = 0;
140
222
  for (const m of matches) {
141
223
  if (textBudget <= 0 || pool.remaining <= 0) break;
142
224
  const filePath = localImagePathOf(m[2]);
@@ -145,9 +227,10 @@ async function inlineTextImages(text, budget) {
145
227
  if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
146
228
  textBudget -= uri.length;
147
229
  pool.remaining -= uri.length;
148
- out = out.replace(m[0], `![${m[1]}](${uri})`);
230
+ out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
231
+ cursor = m.index + m[0].length;
149
232
  }
150
- return out === text ? text : out;
233
+ return cursor === 0 ? text : out + text.slice(cursor);
151
234
  }
152
235
 
153
236
  module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
@@ -10,9 +10,9 @@
10
10
  *
11
11
  * 关键:这个宿主服务 **不在 apiProxy 上** —— 它是 Typert Remote 服务
12
12
  * (dsh-host-plugin-inventory 的 pluginInventory)。手机侧本来就能到它:
13
- * lib/mobile/sessions.js 早已用 `ctx.get('typertGateway')` 派发 commands.execute,
14
- * lib/mobile/references.js 通了 fileReferences / sessionReferenceResolver,
15
- * 这里照搬同一条路。
13
+ * lib/mobile/sessions.js 早已派发 commands.execute,lib/mobile/references.js 通了
14
+ * fileReferences / sessionReferenceResolver,这里照搬同一条路 —— 都经
15
+ * host-bridge/transport.js 的 `invoke`,由它统一补 Peer 身份与网关错误收敛。
16
16
  *
17
17
  * 接口(只转发宿主的部署快照,不做任何加工):
18
18
  * - GET /api/mobile/v1/plugins → pluginInventory.list
@@ -32,17 +32,13 @@
32
32
  */
33
33
 
34
34
  const { sendJson } = require('../shared/http-util');
35
+ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
35
36
 
36
37
  const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
37
38
  const PLUGIN_NAME_MAX_CHARS = 300; // moduleName 长度上限
38
39
  const PLUGIN_MAX_ITEMS = 500; // 手机侧兜底上限(真实宿主是几十条)
39
40
  const PLUGIN_PHASES = new Set(['failed', 'pending', 'active', 'loading', 'unloading']);
40
41
 
41
- /** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
42
- function gatewayOf(ctx) {
43
- return (ctx.get && ctx.get('typertGateway')) || null;
44
- }
45
-
46
42
  /** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
47
43
  const UNAVAILABLE = Object.freeze({
48
44
  code: 'plugin-inventory-unavailable',
@@ -57,35 +53,33 @@ function errorText(error) {
57
53
  }
58
54
 
59
55
  /**
60
- * 经 typertGateway 取一次插件清单快照。
56
+ * 经网关取一次插件清单快照。
57
+ *
58
+ * 走 host-bridge/transport.js 的 `invoke`:Peer 身份、`gateway/` 前缀的缺失类错误
59
+ * 都在那里统一处理,这里只做本域的错误码翻译。**不能直接 `gateway.invoke`** ——
60
+ * 0.2.1 起网关要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,
61
+ * 手机端看到的是「插件清单加载失败」。
61
62
  *
62
63
  * @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
63
64
  */
64
65
  async function callInventory(ctx) {
65
- const gateway = gatewayOf(ctx);
66
- if (!gateway) return { error: { ...UNAVAILABLE } };
67
- let result;
68
- try {
69
- // 没有声明参数 → args 必须是空对象;这个方法也没声明 cancellation,不能多塞 signal。
70
- result = await gateway.invoke({ namespace: 'pluginInventory', method: 'list', args: {} });
71
- } catch (error) {
72
- // 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
73
- // 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
74
- const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
75
- const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
76
- ? UNAVAILABLE.code
77
- : 'plugin-inventory-error';
78
- return { error: { code, message: errorText(error) } };
66
+ // 没有声明参数 → args 必须是空对象;这个方法也没声明 cancellation,不能多塞 signal。
67
+ const result = await invoke(ctx, 'pluginInventory', 'list', {});
68
+ if (result.error) {
69
+ // 宿主的服务/方法缺失由传输层收敛成同一个码:对客户端就是「这个能力没装」。
70
+ if (result.error.code === GATEWAY_UNAVAILABLE.code) return { error: { ...UNAVAILABLE } };
71
+ return { error: { code: 'plugin-inventory-error', message: errorText(result.error) } };
79
72
  }
73
+ const value = result.value;
80
74
  // decode 本体就是 { entries: […] };另外两种形状只是容错,防宿主以后改成联合。
81
- if (result && typeof result === 'object') {
82
- if (result.ok === true) return { value: result.value };
83
- if (result.ok === false) {
84
- const raw = result.error && typeof result.error === 'object' ? result.error : {};
75
+ if (value && typeof value === 'object') {
76
+ if (value.ok === true) return { value: value.value };
77
+ if (value.ok === false) {
78
+ const raw = value.error && typeof value.error === 'object' ? value.error : {};
85
79
  const code = typeof raw.code === 'string' && raw.code ? raw.code : 'plugin-inventory-error';
86
80
  return { error: { code, message: errorText(raw) } };
87
81
  }
88
- return { value: result };
82
+ return { value };
89
83
  }
90
84
  return { error: { code: 'plugin-inventory-error', message: '宿主插件清单返回了意外的结果' } };
91
85
  }
@@ -11,8 +11,8 @@
11
11
  * 关键:这两个宿主服务 **不在 apiProxy 上** —— 它们是 Typert Remote 服务
12
12
  * (dsh-file-reference 的 fileReferences、dsh-session-reference 的
13
13
  * sessionReferenceResolver)。手机侧本来就能到它们:dsh-mobile-plugin 与宿主同进程,
14
- * lib/mobile/sessions.js 已经用 `ctx.get('typertGateway')` 派发过 commands.execute,
15
- * lib/mobile/plugins.js 也走同一条路读 pluginInventory,这里照搬。
14
+ * lib/mobile/sessions.js 已经派发过 commands.execute,lib/mobile/plugins.js 也走同一条路
15
+ * 读 pluginInventory —— 都经 host-bridge/transport.js 的 `invoke`,这里照搬。
16
16
  *
17
17
  * 两条接口(都只是转发宿主的发现域,不做任何排序或截断之外的加工):
18
18
  * - GET /api/mobile/v1/file-references?sessionId=…&query=… → fileReferences.list
@@ -29,17 +29,13 @@
29
29
  */
30
30
 
31
31
  const { sendJson } = require('../shared/http-util');
32
+ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
32
33
 
33
34
  const REFERENCE_ID_MAX_CHARS = 200; // sessionId 长度上限
34
35
  const REFERENCE_QUERY_MAX_CHARS = 200; // 查询串长度上限(宿主 schema 只要求 string)
35
36
  const REFERENCE_MAX_ITEMS = 50; // 与宿主 session-reference 的 candidateLimit 默认值一致
36
37
  const REFERENCE_KINDS = new Set(['file', 'directory']);
37
38
 
38
- /** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
39
- function gatewayOf(ctx) {
40
- return (ctx.get && ctx.get('typertGateway')) || null;
41
- }
42
-
43
39
  /** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
44
40
  const UNAVAILABLE = Object.freeze({
45
41
  code: 'reference-unavailable',
@@ -54,9 +50,13 @@ function errorText(error) {
54
50
  }
55
51
 
56
52
  /**
57
- * 经 typertGateway 调一次引用发现。
53
+ * 经网关调一次引用发现。
54
+ *
55
+ * 走 host-bridge/transport.js 的 `invoke`:Peer 身份与「服务/方法不存在」的收敛都在
56
+ * 那里,本域只做自己的 503/400 翻译。**不要自己直连网关** —— 0.2.1 起
57
+ * 要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,手机端 @ 补全整块失效。
58
58
  *
59
- * 注意返回值形状:`gateway.invoke()` 返回的是 **decode 后的业务结果本体**
59
+ * 注意返回值形状:网关回的是 **decode 后的业务结果本体**
60
60
  * (dsh-api-gateway/lib/index.js:112 `return decode(descriptor.result, result, …)`),
61
61
  * 那层 `{ok:true,value}` 外壳是浏览器传输(connection.rpc.intercept('/api'))加的,
62
62
  * invoke 不负责包。所以形状取决于服务声明的 result schema:
@@ -67,28 +67,15 @@ function errorText(error) {
67
67
  * @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
68
68
  */
69
69
  async function callReference(ctx, namespace, method, agentId, query) {
70
- const gateway = gatewayOf(ctx);
71
- if (!gateway) return { error: { ...UNAVAILABLE } };
72
- let result;
73
- try {
74
- // 两个方法的声明参数都是 agent(wire=agentId) + query(wire=query),args 必须精确匹配;
75
- // 末位 signal 是可选的取消边界(gateway 会补 NEVER_ABORTED_SIGNAL),这里给真的,
76
- // 客户端断开时能让宿主那次发现提前收工。
77
- result = await gateway.invoke({
78
- namespace,
79
- method,
80
- args: { agentId, query },
81
- signal: new AbortController().signal,
82
- });
83
- } catch (error) {
84
- // 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
85
- // 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
86
- const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
87
- const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
88
- ? UNAVAILABLE.code
89
- : 'reference-error';
90
- return { error: { code, message: errorText(error) } };
70
+ // 两个方法的声明参数都是 agent(wire=agentId) + query(wire=query),args 必须精确匹配;
71
+ // 末位 signal 是可选的取消边界(gateway 会补 NEVER_ABORTED_SIGNAL),这里给真的,
72
+ // 客户端断开时能让宿主那次发现提前收工。
73
+ const called = await invoke(ctx, namespace, method, { agentId, query }, new AbortController().signal);
74
+ if (called.error) {
75
+ if (called.error.code === GATEWAY_UNAVAILABLE.code) return { error: { ...UNAVAILABLE } };
76
+ return { error: { code: 'reference-error', message: errorText(called.error) } };
91
77
  }
78
+ const result = called.value;
92
79
  // 正常形状:裸数组(宿主声明的 result 就是数组)
93
80
  if (Array.isArray(result)) return { value: result };
94
81
  if (result && typeof result === 'object') {
@@ -21,6 +21,7 @@ const {
21
21
  } = require('./session-cache.js');
22
22
  const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
23
23
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
24
+ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
24
25
  const readReceipt = require('../read-receipt.cjs');
25
26
 
26
27
  const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
@@ -34,7 +35,7 @@ function singleTextOf(content) {
34
35
  }
35
36
 
36
37
  /**
37
- * 经宿主 typertGateway 执行 slash 命令(commands.execute——桌面 Web 端同一条派发路径:
38
+ * 经网关执行 slash 命令(commands.execute——桌面 Web 端同一条派发路径:
38
39
  * 命令注册表命中才执行,绝不经 session.prompt 发给模型;agent 由 agentId 经同一
39
40
  * agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
40
41
  *
@@ -51,18 +52,13 @@ function singleTextOf(content) {
51
52
  * | {error} 查找或派发失败 | {missing} 网关服务未组合(调用方回退普通消息)
52
53
  */
53
54
  async function executeCommand(ctx, sessionId, line) {
54
- const gateway = ctx.get && ctx.get('typertGateway');
55
- if (!gateway) return { missing: true };
56
- try {
57
- const execution = await gateway.invoke({
58
- namespace: 'commands',
59
- method: 'execute',
60
- args: { agentId: sessionId, line, submittedAttachments: [] },
61
- });
62
- return { execution };
63
- } catch (error) {
64
- return { error };
55
+ // 走 host-bridge/transport.js 的 invoke:Peer 身份与「服务/方法不存在」的收敛都在
56
+ // 那里。这里只把「网关没组合」翻成 {missing},让调用方回退普通消息。
57
+ const called = await invoke(ctx, 'commands', 'execute', { agentId: sessionId, line, submittedAttachments: [] });
58
+ if (called.error) {
59
+ return called.error.code === GATEWAY_UNAVAILABLE.code ? { missing: true } : { error: called.error };
65
60
  }
61
+ return { execution: called.value };
66
62
  }
67
63
 
68
64
  /**
@@ -172,36 +168,30 @@ async function handleSend(ctx, req, res) {
172
168
  }
173
169
 
174
170
  /** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(与桌面 Web 端命令面板
175
- * 同源——typertGateway commands.list,即服务端命令注册表对该 agent 生效的完整清单)。
171
+ * 同源——commands.list,即服务端命令注册表对该 agent 生效的完整清单)。
176
172
  * 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
177
- * 客户端菜单据此只展示真实可执行的命令(不再硬编码)。 */
173
+ * 客户端菜单据此只展示真实可执行的命令(不再硬编码)。
174
+ *
175
+ * 失败一律降级成空清单是**刻意设计**:输入区不能因为目录拉不到就报错。代价是这个端点
176
+ * 出问题时手机上看不出任何提示,「+」菜单静默只剩媒体与文件两行 —— 2026-10 宿主升到
177
+ * 0.2.1 要求每个请求自带 Peer,这条路径没跟上,用户看到的就是「目标、计划、指令全没了」。 */
178
178
  async function handleCommands(ctx, req, res) {
179
179
  const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
180
180
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
181
- const gateway = ctx.get && ctx.get('typertGateway');
182
- if (!gateway) return sendJson(res, 200, { ok: true, items: [] });
183
- try {
184
- const descriptors = await gateway.invoke({
185
- namespace: 'commands',
186
- method: 'list',
187
- args: { agentId: sessionId },
188
- });
189
- const items = Array.isArray(descriptors)
190
- ? descriptors
191
- .filter((d) => d && typeof d.name === 'string' && d.name && typeof d.description === 'string')
192
- .map((d) => ({
193
- name: d.name,
194
- description: d.description,
195
- ...(d.input && typeof d.input === 'object' && typeof d.input.hint === 'string' && d.input.hint
196
- ? { input: { hint: d.input.hint } }
197
- : {}),
198
- }))
199
- : [];
200
- sendJson(res, 200, { ok: true, items });
201
- } catch (error) {
202
- // 目录拉取失败不阻塞输入区:降级为空清单(等价桌面网关缺失)。
203
- sendJson(res, 200, { ok: true, items: [] });
204
- }
181
+ const called = await invoke(ctx, 'commands', 'list', { agentId: sessionId });
182
+ const descriptors = called.error ? null : called.value;
183
+ const items = Array.isArray(descriptors)
184
+ ? descriptors
185
+ .filter((d) => d && typeof d.name === 'string' && d.name && typeof d.description === 'string')
186
+ .map((d) => ({
187
+ name: d.name,
188
+ description: d.description,
189
+ ...(d.input && typeof d.input === 'object' && typeof d.input.hint === 'string' && d.input.hint
190
+ ? { input: { hint: d.input.hint } }
191
+ : {}),
192
+ }))
193
+ : [];
194
+ sendJson(res, 200, { ok: true, items });
205
195
  }
206
196
 
207
197
  /** POST /api/mobile/v1/session/read(token){sessionId}:清除完成提醒。 */
@@ -30,6 +30,25 @@ const SSE_RETRY_MS = 3000;
30
30
  // internal progress threshold. Pad each SSE record so onprogress exposes it
31
31
  // promptly; the padding is an SSE comment and is ignored by clients.
32
32
  const SSE_FLUSH_PADDING_BYTES = 2048;
33
+ /**
34
+ * 写缓冲的两道水位(字节)。`res.write()` 在客户端读得慢时返回 false,字节留在 Node
35
+ * 的写队列里;以前没人看这个返回值,于是「手机停在一个生成中的会话上、网络又慢」时
36
+ * 帧无限堆积 —— 2026-10-17 真机现场:堆快照里 17.6 万条 SSE 帧(466MB)挂在
37
+ * `WriteWrap._chunks` 上,宿主 70 分钟涨到 3.9G 被 OOM 杀掉。
38
+ *
39
+ * 两道闸分开,因为两种帧的「丢了能不能补」不一样:
40
+ * - 软水位:只丢**没有 seq 的增量帧**(`assistant/chunk`)。它本来就是可丢弃的草稿
41
+ * 增量,同一轮收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐;
42
+ * - 硬水位:连有 seq 的帧也放不下了,就返回 false 让上层收尾。客户端带 `lastSeq`
43
+ * 重连后走 `replaySince` 把缺口补齐 —— 断流可恢复,堆内存不会。
44
+ */
45
+ const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
46
+ const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
47
+ /**
48
+ * 等一次 `drain` 的上限。超过这么久还没排空,说明对面不是慢、是真的不读了(手机切后台、
49
+ * 中转卡住),继续等只是把这条连接挂在那里,不如收尾让客户端重连。
50
+ */
51
+ const SSE_DRAIN_TIMEOUT_MS = 20_000;
33
52
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
34
53
 
35
54
  /**
@@ -74,13 +93,72 @@ function stableEventId(payload, frame) {
74
93
  return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
75
94
  }
76
95
 
96
+ /**
97
+ * 这条连接上真正「还没发出去」的字节数。
98
+ *
99
+ * `res.writableLength` 只算响应对象自己那一层缓冲,真正堆积的地方在它底下的套接字
100
+ * 写队列(堆快照里那些 `{chunk, encoding, callback}` 就是它)。第一版只看
101
+ * `res.writableLength`,它恒为 0,两道闸一次都没触发 —— 2026-10-18 复测:修完内存
102
+ * 仍从 410MB 涨到 1.4GB。所以要顺着 socket 一起看,取两者较大值。
103
+ */
104
+ function bufferedBytesOf(res) {
105
+ const own = Number(res.writableLength) || 0;
106
+ const socket = res.socket || res.connection;
107
+ const queued = socket ? Number(socket.writableLength) || 0 : 0;
108
+ return Math.max(own, queued);
109
+ }
110
+
111
+ /**
112
+ * 等底层把缓冲吐出去(`drain`)。背压的正解:写不动就停手,而不是继续塞。
113
+ *
114
+ * @returns {Promise<boolean>} 真的 drain 了 → true;超时或流被中断 → false(该收尾)。
115
+ */
116
+ function waitForDrain(res, signal, timeoutMs = SSE_DRAIN_TIMEOUT_MS) {
117
+ return new Promise((resolve) => {
118
+ if (signal && signal.aborted) return resolve(false);
119
+ let done = false;
120
+ const cleanup = () => {
121
+ clearTimeout(timer);
122
+ if (typeof res.off === 'function') res.off('drain', onDrain);
123
+ if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
124
+ };
125
+ const finish = (value) => { if (done) return; done = true; cleanup(); resolve(value); };
126
+ const onDrain = () => finish(true);
127
+ const onAbort = () => finish(false);
128
+ const timer = setTimeout(() => finish(false), timeoutMs);
129
+ if (typeof timer.unref === 'function') timer.unref();
130
+ if (typeof res.once === 'function') res.once('drain', onDrain);
131
+ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
132
+ });
133
+ }
134
+
135
+ /**
136
+ * 写一条 SSE 记录。
137
+ *
138
+ * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
139
+ * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
140
+ * `shed:true` → 这条**增量**帧被丢掉、连接还活着(超过软水位且没有 seq);
141
+ * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
142
+ *
143
+ * 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
144
+ * 留一个永久的洞,那比断流更糟。
145
+ */
77
146
  function writeSse(res, payload, frame) {
147
+ const buffered = bufferedBytesOf(res);
148
+ if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
78
149
  const id = stableEventId(payload, frame);
150
+ const hasSeq = Number.isFinite(payload && payload.seq);
151
+ if (buffered > SSE_SHED_BUFFERED_BYTES && !hasSeq) return { ok: true, shed: true };
79
152
  const fields = [`retry: ${SSE_RETRY_MS}`];
80
153
  if (id) fields.push(`id: ${id}`);
81
154
  fields.push(`data: ${JSON.stringify(payload)}`);
82
155
  fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
83
- try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
156
+ try {
157
+ // `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
158
+ // 堆起来。以前把它当成功吞掉,正是这次 4GB OOM 的直接原因。
159
+ const accepted = res.write(`${fields.join('\n')}\n`);
160
+ return { ok: true, needDrain: accepted === false };
161
+ } catch { return { ok: false }; }
84
162
  }
85
163
 
86
164
  /**
@@ -163,7 +241,7 @@ async function handleStream(ctx, req, res) {
163
241
  if (replay.gap && !ac.signal.aborted) {
164
242
  const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
165
243
  // 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
166
- if (!writeSse(res, value, { payload: value })) clientGone = true;
244
+ if (!writeSse(res, value, { payload: value }).ok) clientGone = true;
167
245
  }
168
246
  }
169
247
 
@@ -172,6 +250,19 @@ async function handleStream(ctx, req, res) {
172
250
  // 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
173
251
  // 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
174
252
  const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
253
+ // 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
254
+ // 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
255
+ // 而不是把帧继续往内存里塞。
256
+ const push = async (payload, frameArg) => {
257
+ const sent = writeSse(res, payload, frameArg);
258
+ if (sent.shed) return 'shed';
259
+ if (!sent.ok) return 'closed';
260
+ if (sent.needDrain) {
261
+ const drained = await waitForDrain(res, ac.signal);
262
+ if (!drained) { logStream(sessionId, 'drain timeout', null); return 'closed'; }
263
+ }
264
+ return 'sent';
265
+ };
175
266
  for await (const frame of frames) {
176
267
  const p = frame.payload;
177
268
  if (!p || p.sessionId !== sessionId) continue;
@@ -181,25 +272,31 @@ async function handleStream(ctx, req, res) {
181
272
  // 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
182
273
  if (seq !== null && seq <= lastDeliveredSeq) continue;
183
274
  const event = await resolveEventAttachments(ctx, sessionId, p.event);
184
- if (!writeSse(res, event, { ...frame, payload: { ...p, event } })) { clientGone = true; break; }
275
+ // 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
276
+ // 同一轮收尾的 assistant/message 会把完整正文补上。
277
+ const result = await push(event, { ...frame, payload: { ...p, event } });
278
+ if (result === 'shed') continue;
279
+ if (result === 'closed') { clientGone = true; break; }
185
280
  if (seq !== null) lastDeliveredSeq = seq;
186
281
  } else if (p.type === 'session/queue' || p.type === 'session/projection') {
187
282
  // 队列/投影整帧转发;不假定存在可重放的宿主 API。
188
- if (!writeSse(res, p, frame)) { clientGone = true; break; }
283
+ const result = await push(p, frame);
284
+ if (result === 'shed') continue;
285
+ if (result === 'closed') { clientGone = true; break; }
189
286
  } else if (p.type === 'session/jobs') {
190
287
  // 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
191
288
  // 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
192
289
  const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
193
- if (!writeSse(res, value, frame)) { clientGone = true; break; }
290
+ if (await push(value, frame) === 'closed') { clientGone = true; break; }
194
291
  } else if (p.type === 'question/requested') {
195
292
  // 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
196
293
  const value = { ...p, rpcId: frame.rpcId };
197
- if (!writeSse(res, value, { ...frame, payload: value })) { clientGone = true; break; }
294
+ if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
198
295
  } else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
199
296
  // Approval frames are server requests/resolutions, not session/event frames;
200
297
  // forward the rpcId and approval identity so the mobile client can answer safely.
201
298
  const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
202
- if (!writeSse(res, value, { ...frame, payload: value })) { clientGone = true; break; }
299
+ if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
203
300
  }
204
301
  }
205
302
  if (!clientGone) loopEnded = true;
@@ -255,7 +352,10 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
255
352
  // Keep replay data identical to live SSE data: the client reducer consumes
256
353
  // the event's own type (turn/start, assistant/message, etc.).
257
354
  const payload = { type: 'session/event', sessionId, event };
258
- if (!writeSse(res, event, { payload })) break;
355
+ const sent = writeSse(res, event, { payload });
356
+ if (!sent.ok) break;
357
+ // 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
358
+ if (sent.needDrain && !(await waitForDrain(res, signal))) break;
259
359
  if (Number.isFinite(event.seq)) onDelivered(event.seq);
260
360
  }
261
361
  return { gap, oldestSeq };
@@ -16,6 +16,9 @@
16
16
  * (pruneOrphanedArchivedSessionsOnce)。理由写在那个函数上:桌面端的会话列表是
17
17
  * 连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还留在
18
18
  * 快照里的行放回正常列表。
19
+ * - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
20
+ * 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
21
+ * 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
19
22
  */
20
23
  'use strict'
21
24
 
@@ -90,6 +93,9 @@ async function readHeader(file) {
90
93
  } finally {
91
94
  stream.destroy()
92
95
  source.destroy()
96
+ // Windows 上句柄没真的关掉之前,这个目录项删不掉(delete-pending → rmdir 报 ENOTEMPTY)。
97
+ // 删会话目录就紧跟在读 header 之后,所以先等 close 再往下走。
98
+ if (!source.closed) await new Promise(resolve => source.once('close', resolve))
93
99
  }
94
100
  return undefined
95
101
  }
@@ -116,12 +122,16 @@ async function deleteSessionData(sessionIds) {
116
122
  for (const file of sessionFiles(root)) {
117
123
  const header = await readHeader(file)
118
124
  if (!header || !ids.has(header.id)) continue
125
+ const directory = path.dirname(file)
119
126
  try {
120
- fs.rmSync(path.dirname(file), { recursive: true, force: true })
121
- removed.push(header.id)
127
+ fs.rmSync(directory, { recursive: true, force: true })
122
128
  } catch (error) {
123
- throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
129
+ // Windows:宿主握着 session.lock,日志 unlink 成功了但名字还在(delete-pending),
130
+ // rmdir 报 ENOTEMPTY。日志都没了,这条会话对宿主就等于已经删掉,按删成记账、继续清
131
+ // 下一条;日志还在才算真失败(别把没删掉的说成删掉了)。
132
+ if (sessionFiles(directory).length > 0) throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
124
133
  }
134
+ removed.push(header.id)
125
135
  }
126
136
  const cacheRoot = path.join(dshHome(), 'storages', 'session_projcache', 'sessions')
127
137
  for (const id of ids) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",