dsh-mobile-plugin 0.1.84 → 0.1.85

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,23 @@ function stableEventId(payload, frame) {
56
66
  return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
57
67
  }
58
68
 
69
+ /**
70
+ * 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 表示该收尾,
71
+ * `shed:true` 表示这条帧被主动丢掉(写缓冲超过软水位)。
72
+ *
73
+ * 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
74
+ * 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
75
+ * 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
76
+ */
59
77
  function writeSse(res, payload, frame) {
78
+ const buffered = Number(res.writableLength) || 0;
79
+ if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
80
+ if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
60
81
  const id = stableEventId(payload, frame);
61
82
  const fields = [`retry: ${SSE_RETRY_MS}`];
62
83
  if (id) fields.push(`id: ${id}`);
63
84
  fields.push(`data: ${JSON.stringify(payload)}`, '', '');
64
- try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
85
+ try { res.write(`${fields.join('\n')}\n`); return { ok: true }; } catch { return { ok: false }; }
65
86
  }
66
87
 
67
88
  async function* mergeHostAndMux(ctx, signal) {
@@ -153,13 +174,13 @@ async function handleDeviceStream(ctx, req, res) {
153
174
  /** 这条连接上真正写出去的帧数,只用于收尾日志(诊断「连着却一帧不发」)。 */
154
175
  let framesSent = 0;
155
176
  const writeFrame = (devicePayload) => {
156
- const written = writeSse(res, {
177
+ const result = writeSse(res, {
157
178
  type: 'device/event',
158
179
  version: 1,
159
180
  payload: devicePayload,
160
181
  }, null);
161
- if (written) framesSent += 1;
162
- return written;
182
+ if (result.ok && !result.shed) framesSent += 1;
183
+ return result.ok;
163
184
  };
164
185
  /**
165
186
  * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
@@ -267,7 +288,9 @@ async function handleDeviceStream(ctx, req, res) {
267
288
  payload,
268
289
  };
269
290
  // Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
270
- if (!writeSse(res, envelope, frame)) { clientGone = true; break; }
291
+ const sent = writeSse(res, envelope, frame);
292
+ if (sent.shed) continue;
293
+ if (!sent.ok) { clientGone = true; break; }
271
294
  framesSent += 1;
272
295
  }
273
296
  if (!clientGone) loopEnded = true;
@@ -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,20 @@ 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;
33
47
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
34
48
 
35
49
  /**
@@ -74,13 +88,27 @@ function stableEventId(payload, frame) {
74
88
  return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
75
89
  }
76
90
 
91
+ /**
92
+ * 写一条 SSE 记录。
93
+ *
94
+ * @returns {{ok: boolean, shed?: boolean}} `ok:false` 表示这条流该收尾了(客户端已经
95
+ * 走了,或者写缓冲超过硬水位);`shed:true` 表示这条**增量**帧被主动丢掉、连接还
96
+ * 活着(见 SSE_SHED_BUFFERED_BYTES)。
97
+ *
98
+ * 丢增量必须只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机
99
+ * 上留一个永久的洞,那比断流更糟。
100
+ */
77
101
  function writeSse(res, payload, frame) {
102
+ const buffered = Number(res.writableLength) || 0;
103
+ if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
78
104
  const id = stableEventId(payload, frame);
105
+ const hasSeq = Number.isFinite(payload && payload.seq);
106
+ if (buffered > SSE_SHED_BUFFERED_BYTES && !hasSeq) return { ok: true, shed: true };
79
107
  const fields = [`retry: ${SSE_RETRY_MS}`];
80
108
  if (id) fields.push(`id: ${id}`);
81
109
  fields.push(`data: ${JSON.stringify(payload)}`);
82
110
  fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
83
- try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
111
+ try { res.write(`${fields.join('\n')}\n`); return { ok: true }; } catch { return { ok: false }; }
84
112
  }
85
113
 
86
114
  /**
@@ -163,7 +191,7 @@ async function handleStream(ctx, req, res) {
163
191
  if (replay.gap && !ac.signal.aborted) {
164
192
  const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
165
193
  // 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
166
- if (!writeSse(res, value, { payload: value })) clientGone = true;
194
+ if (!writeSse(res, value, { payload: value }).ok) clientGone = true;
167
195
  }
168
196
  }
169
197
 
@@ -181,25 +209,31 @@ async function handleStream(ctx, req, res) {
181
209
  // 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
182
210
  if (seq !== null && seq <= lastDeliveredSeq) continue;
183
211
  const event = await resolveEventAttachments(ctx, sessionId, p.event);
184
- if (!writeSse(res, event, { ...frame, payload: { ...p, event } })) { clientGone = true; break; }
212
+ // 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
213
+ // 同一轮收尾的 assistant/message 会把完整正文补上。
214
+ const sent = writeSse(res, event, { ...frame, payload: { ...p, event } });
215
+ if (sent.shed) continue;
216
+ if (!sent.ok) { clientGone = true; break; }
185
217
  if (seq !== null) lastDeliveredSeq = seq;
186
218
  } else if (p.type === 'session/queue' || p.type === 'session/projection') {
187
219
  // 队列/投影整帧转发;不假定存在可重放的宿主 API。
188
- if (!writeSse(res, p, frame)) { clientGone = true; break; }
220
+ const sent = writeSse(res, p, frame);
221
+ if (sent.shed) continue;
222
+ if (!sent.ok) { clientGone = true; break; }
189
223
  } else if (p.type === 'session/jobs') {
190
224
  // 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
191
225
  // 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
192
226
  const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
193
- if (!writeSse(res, value, frame)) { clientGone = true; break; }
227
+ if (!writeSse(res, value, frame).ok) { clientGone = true; break; }
194
228
  } else if (p.type === 'question/requested') {
195
229
  // 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
196
230
  const value = { ...p, rpcId: frame.rpcId };
197
- if (!writeSse(res, value, { ...frame, payload: value })) { clientGone = true; break; }
231
+ if (!writeSse(res, value, { ...frame, payload: value }).ok) { clientGone = true; break; }
198
232
  } else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
199
233
  // Approval frames are server requests/resolutions, not session/event frames;
200
234
  // forward the rpcId and approval identity so the mobile client can answer safely.
201
235
  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; }
236
+ if (!writeSse(res, value, { ...frame, payload: value }).ok) { clientGone = true; break; }
203
237
  }
204
238
  }
205
239
  if (!clientGone) loopEnded = true;
@@ -255,7 +289,7 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
255
289
  // Keep replay data identical to live SSE data: the client reducer consumes
256
290
  // the event's own type (turn/start, assistant/message, etc.).
257
291
  const payload = { type: 'session/event', sessionId, event };
258
- if (!writeSse(res, event, { payload })) break;
292
+ if (!writeSse(res, event, { payload }).ok) break;
259
293
  if (Number.isFinite(event.seq)) onDelivered(event.seq);
260
294
  }
261
295
  return { gap, oldestSeq };
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.85",
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",