dsh-mobile-plugin 0.1.82 → 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.
@@ -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') {
@@ -85,7 +85,7 @@ function sessionListResponseState(ctx, deviceKey = 'legacy') {
85
85
  function sessionCleanupStateOf(ctx) {
86
86
  let state = deletedSessionState.get(ctx);
87
87
  if (!state) {
88
- state = { deleted: new Set(), pendingUnarchive: new Set() };
88
+ state = { deleted: new Set() };
89
89
  deletedSessionState.set(ctx, state);
90
90
  }
91
91
  return state;
@@ -96,7 +96,6 @@ function rememberDeletedSessionIds(ctx, sessionIds) {
96
96
  for (const sessionId of sessionIds) {
97
97
  if (typeof sessionId !== 'string' || !sessionId) continue;
98
98
  state.deleted.add(sessionId);
99
- state.pendingUnarchive.add(sessionId);
100
99
  }
101
100
  }
102
101
 
@@ -104,15 +103,6 @@ function deletedSessionIdsOf(ctx) {
104
103
  return sessionCleanupStateOf(ctx).deleted;
105
104
  }
106
105
 
107
- function pendingUnarchiveSessionIdsOf(ctx) {
108
- return sessionCleanupStateOf(ctx).pendingUnarchive;
109
- }
110
-
111
- function clearPendingUnarchiveSessionIds(ctx, sessionIds) {
112
- const pending = sessionCleanupStateOf(ctx).pendingUnarchive;
113
- for (const sessionId of sessionIds) pending.delete(sessionId);
114
- }
115
-
116
106
  function sessionPreviewStateOf(ctx) {
117
107
  let state = sessionPreviewCache.get(ctx);
118
108
  if (!state) {
@@ -162,24 +152,41 @@ function completionStateOf(ctx, deviceKey = 'legacy') {
162
152
  return states;
163
153
  }
164
154
 
155
+ function completionStateMapsOf(ctx) {
156
+ return [...(completionStates.get(ctx)?.values() || [])];
157
+ }
158
+
165
159
  /**
166
- * 清掉某个会话在**所有**设备维度上的完成提醒,返回真正清掉的设备数。
160
+ * 标记「这条会话读过了」:记一个水位,把当前的完成 / 失败提醒清掉,返回清掉的条数。
167
161
  *
168
- * 与 handleSessionRead 里那句 `completionStateOf(ctx, deviceKey).delete(...)` 的区别:
169
- * 那个只清「刚点开的那台手机」自己的提醒(每台手机各有一份,互不干扰);这个用于
170
- * **桌面端说「我读过了」**——读一次就是读过了,不该只清某台设备。
162
+ * **不删条目**(旧实现是 delete)。删掉之后重建的条目会丢掉水位,于是「读过的轮次」
163
+ * 会在下一次 history 折叠里重新点亮 —— 2026-10-17 真机:桌面读过之后手机上红点 / 绿点
164
+ * 还在或者反过来。水位才是「这个时刻之前的轮次不算数」的唯一凭据。
165
+ */
166
+ function markCompletionRead(ctx, deviceKey, sessionId, at = Date.now()) {
167
+ const state = completionStates.get(ctx)?.get(deviceKey)?.get(sessionId);
168
+ if (!state) return 0;
169
+ const had = state.completed === true || !!state.failure;
170
+ state.clearedAt = at;
171
+ state.completed = false;
172
+ state.failure = undefined;
173
+ // 已读要压过「本进程看见过它跑」这一条(见 completion.js 的 baselineOf):
174
+ // 否则同一轮会在下一次列表折叠里又亮回来。
175
+ state.wasRunning = false;
176
+ return had ? 1 : 0;
177
+ }
178
+
179
+ /**
180
+ * 清掉某个会话在**所有**设备维度上的完成提醒,返回真正清掉过的设备数。
171
181
  *
172
- * 清掉(而不是标记成已读)是刻意的:重建出来的条目 `wasRunning` 为 false,历史折叠
173
- * (completion.js 的 confirmCompletionFromHistory)会因此拒绝确认,于是**之后**完成的
174
- * 轮次照样能重新亮起来 —— 「新一轮完成压过更早的已读」这条语义就落在这里。
182
+ * 用于**桌面端说「我读过了」**——读一次就是读过了,不该只清某台设备。手机上点开
183
+ * 走 markCompletionRead 只清自己那台(每台手机各有一份,互不干扰)。
175
184
  */
176
- function clearCompletionForAllDevices(ctx, sessionId) {
185
+ function clearCompletionForAllDevices(ctx, sessionId, at = Date.now()) {
177
186
  const byDevice = completionStates.get(ctx);
178
187
  if (!byDevice) return 0;
179
188
  let cleared = 0;
180
- for (const states of byDevice.values()) {
181
- if (states.delete(sessionId)) cleared += 1;
182
- }
189
+ for (const deviceKey of byDevice.keys()) cleared += markCompletionRead(ctx, deviceKey, sessionId, at);
183
190
  return cleared;
184
191
  }
185
192
 
@@ -283,14 +290,14 @@ module.exports = {
283
290
  invalidateSessionList,
284
291
  rememberDeletedSessionIds,
285
292
  deletedSessionIdsOf,
286
- pendingUnarchiveSessionIdsOf,
287
- clearPendingUnarchiveSessionIds,
288
293
  dropSessionListCache,
289
294
  sessionPreviewStateOf,
290
295
  sessionPreviewInFlightStateOf,
291
296
  sessionPreviewClockOf,
292
297
  mirrorHistoryStateOf,
293
298
  completionStateOf,
299
+ completionStateMapsOf,
300
+ markCompletionRead,
294
301
  clearCompletionForAllDevices,
295
302
  completionGenerationOf,
296
303
  bumpCompletionGeneration,
@@ -17,10 +17,11 @@ const { unwrap } = require('./envelope.js');
17
17
  const { callSessions, mobilePromptRpcId } = require('./rpc.js');
18
18
  const {
19
19
  canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
20
- completionStateOf, sendIdempotencyStateOf,
20
+ markCompletionRead, sendIdempotencyStateOf,
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}:清除完成提醒。 */
@@ -211,7 +201,8 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
211
201
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId }' } });
212
202
  }
213
203
  const deviceKey = canonicalDeviceKey(req, fallbackDeviceKey);
214
- completionStateOf(ctx, deviceKey).delete(body.sessionId);
204
+ // 只清这台设备自己的提醒;水位留在状态里,读过的轮次就不会再亮回来。
205
+ markCompletionRead(ctx, deviceKey, body.sessionId);
215
206
  // Invalidate the cache and any build already reading history. Its generation
216
207
  // check prevents a stale build from restoring `completed` after this ack.
217
208
  invalidateSessionList(ctx, deviceKey);
@@ -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 };
@@ -381,6 +381,35 @@ class RelayTunnel {
381
381
  if (this.connected) this.sendJson(T.updatePairing, 0, { pairingCode: code });
382
382
  }
383
383
 
384
+ /** 注册帧的载荷。连接建立时发一次,之后元信息变了再由 updateRegistration 重发。 */
385
+ registrationPayload() {
386
+ return {
387
+ instanceId: this.instanceId,
388
+ pairingCode: this.pairingCode,
389
+ name: this.name,
390
+ // 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
391
+ ...(this.registerSecret ? { secret: this.registerSecret } : {}),
392
+ ...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
393
+ ...(this.lanCandidates.length ? { lanCandidates: this.lanCandidates } : {}),
394
+ };
395
+ }
396
+
397
+ /**
398
+ * 在**现有连接上**重报一次注册信息(配对码 / 名字 / 局域网候选),不断连接。
399
+ *
400
+ * 中转那边同一个实例重复注册是幂等的(不会把自己顶掉),所以这条只是「更新名片」。
401
+ * 以前这类变化是把整条隧道拆了重建,代价是手机所有在途请求一起断——真机现场就是
402
+ * 「切个网络,接下来几秒点什么都失败」。真正决定「连到哪台」的字段(实例、中转
403
+ * 地址、注册密钥)不在这里更新,它们变了必须重建,由 worker 的 configure 判定。
404
+ */
405
+ updateRegistration(patch = {}) {
406
+ if (typeof patch.pairingCode === 'string' && patch.pairingCode) this.pairingCode = patch.pairingCode;
407
+ if (typeof patch.name === 'string') this.name = patch.name;
408
+ if (Array.isArray(patch.lanUrls)) this.lanUrls = patch.lanUrls.slice();
409
+ if (Array.isArray(patch.lanCandidates)) this.lanCandidates = patch.lanCandidates.slice();
410
+ if (this.connected) this.sendJson(T.register, 0, this.registrationPayload());
411
+ }
412
+
384
413
  /**
385
414
  * 回传桌面端弹窗授权结果:approved + 桌面签发的移动 token(拒绝时无 token)。
386
415
  * `hostPlatform` 是**宿主自己的** `process.platform`,经中转原样回给手机,
@@ -445,15 +474,7 @@ class RelayTunnel {
445
474
  });
446
475
 
447
476
  ws.on('open', () => {
448
- this.sendJson(T.register, 0, {
449
- instanceId: this.instanceId,
450
- pairingCode: this.pairingCode,
451
- name: this.name,
452
- // 可选:与中转端 TUNNEL_REGISTER_SECRET 对应,防止他人抢注 instanceId。
453
- ...(this.registerSecret ? { secret: this.registerSecret } : {}),
454
- ...(this.lanUrls.length ? { lanUrls: this.lanUrls } : {}),
455
- ...(this.lanCandidates.length ? { lanCandidates: this.lanCandidates } : {}),
456
- });
477
+ this.sendJson(T.register, 0, this.registrationPayload());
457
478
  });
458
479
  ws.on('message', (data, isBinary) => push(Buffer.isBuffer(data) ? data : Buffer.from(data)));
459
480
  ws.on('close', (code, reason) => {
@@ -9,11 +9,13 @@
9
9
  * CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
10
10
  * 曾经只认 `session.jsonl.zstd`,于是 0.2.0 真在用的 `session.v4.jsonl.zstd`
11
11
  * 一个都认不到 —— 表现是「清理已归档」恒回 `deleted: 0`,静默不删。
12
- * - 删之前先从宿主归档集合里摘掉这些 id:宿主不会自己修剪,删完还留着就是「已归档」
13
- * 里一排点不开的死行,且每清一次只增不减。
14
12
  * - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
15
13
  * 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
16
14
  * undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
15
+ * - **进程活着的时候不擦宿主的归档集合**,只删日志;擦集合放到宿主启动时
16
+ * (pruneOrphanedArchivedSessionsOnce)。理由写在那个函数上:桌面端的会话列表是
17
+ * 连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还留在
18
+ * 快照里的行放回正常列表。
17
19
  */
18
20
  'use strict'
19
21
 
@@ -24,6 +26,9 @@ const zlib = require('node:zlib')
24
26
  const DEFAULT_OLDER_THAN_MINUTES = 30
25
27
  const MINUTES = 60 * 1000
26
28
 
29
+ /** 认为「宿主刚起来」的上限:装载插件只在这段时间里算启动,之后出现的调用只能是热替换。 */
30
+ const BOOT_PRUNE_WINDOW_SECONDS = 60
31
+
27
32
  // 宿主认可的会话日志名。非规范名(迁移中途的临时文件、大写、前导零、带 v0)宿主自己
28
33
  // 也不认,这里同样不认 —— 认多了会把没提交的代次当会话删掉。
29
34
  const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
@@ -128,18 +133,12 @@ async function deleteSessionData(sessionIds) {
128
133
  }
129
134
 
130
135
  /**
131
- * 把已经从磁盘删掉的 id 从宿主归档集合里擦掉(删除路径的最后一步,best-effort)。
132
- *
133
- * 宿主不会自己修剪这份集合:日志删了它照样留着那些 id,于是「已归档」里是一排点不开的
134
- * 死行,而且每清一次就多几条、只增不减。它排在 `deleteSessionData` **之后**:日志没删成
135
- * 前先擦集合,中途失败就会留下「已取消归档、日志还在」的会话——用户看到的是清理只把会话
136
- * 移出了归档,还要重启宿主才恢复。先删后擦则日志一没就是真的删掉了,立刻生效;擦失败最多
137
- * 留一行死行,重试即可。宿主对本来就未归档的 id 是 no-op。
136
+ * 把一批 id 从宿主归档集合里擦掉(宿主对本来就未归档的 id 是 no-op)。
138
137
  *
139
- * 只传**真删掉**的 id:没匹配到日志的还留着日志,擦掉它的名字只会让那一行消失而没有东西被删。
138
+ * 唯一的调用方是宿主启动时的孤儿修剪:进程活着的时候擦集合会把桌面端快照里那些已经
139
+ * 删掉的行放回正常列表,理由见 pruneOrphanedArchivedSessionsOnce。
140
140
  *
141
- * 返回擦失败的 id(`failed`),**不抛异常**:日志已经删成功,擦集合坏了不该让整条清理
142
- * 报错——那等于告诉用户「没删掉」,而事实上已经删掉了。
141
+ * 返回擦失败的 id(`failed`),**不抛异常**:修剪是收尾,一条擦不动不该让整轮修剪停摆。
143
142
  */
144
143
  async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
145
144
  const failed = []
@@ -154,6 +153,69 @@ async function forgetArchivedSessions(ctx, sessionIds, rpcId) {
154
153
  return failed
155
154
  }
156
155
 
156
+ /**
157
+ * 归档集合里那些日志确实已经不在磁盘上的 id。
158
+ *
159
+ * 判据是磁盘而不是宿主的会话列表:`sessions.list` 会不会漏一条(比如没有 cwd 的会话、
160
+ * 宿主服务还没装好)不是这里能判断的,而「归档集合里挂着、磁盘上已经没有日志」正是
161
+ * 清理真正删掉的那个状态。宿主把会话 id 原样当目录名(`encodeSegment` 对正常 id 恒等),
162
+ * 所以先用目录名筛掉绝大多数,只对候选读一遍 header 复核 —— 别为了修剪把整库日志解一遍。
163
+ */
164
+ async function archivedIdsWithoutLogs(archivedSessionIds) {
165
+ const orphans = new Set(archivedSessionIds)
166
+ if (orphans.size === 0) return []
167
+ for (const file of sessionFiles(path.join(dshHome(), 'sessions'))) {
168
+ const name = path.basename(path.dirname(file))
169
+ if (!orphans.has(name)) continue
170
+ const header = await readHeader(file)
171
+ // 日志读不出来也算「还在」:宁可这一轮不修剪,也不能把一条还有日志的会话移出归档。
172
+ orphans.delete(name)
173
+ if (header && typeof header.id === 'string') orphans.delete(header.id)
174
+ if (orphans.size === 0) break
175
+ }
176
+ return [...orphans]
177
+ }
178
+
179
+ async function pruneOrphanedArchivedSessions(ctx) {
180
+ const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
181
+ if (typeof workspaceList !== 'function') return 0
182
+ const rpcId = 'dsh-mobile-plugin-archive-prune'
183
+ const { value, error } = unwrap(await workspaceList({ rpcId, method: 'workspace.list', payload: {} }))
184
+ if (error) throw new Error(`workspace.list 失败:${error.message || error.code || '未知错误'}`)
185
+ const archived = value && Array.isArray(value.archivedSessionIds)
186
+ ? value.archivedSessionIds.filter((id) => typeof id === 'string' && id)
187
+ : []
188
+ const orphans = await archivedIdsWithoutLogs(archived)
189
+ if (orphans.length === 0) return 0
190
+ const failed = await forgetArchivedSessions(ctx, orphans, rpcId)
191
+ return orphans.length - failed.length
192
+ }
193
+
194
+ /**
195
+ * 宿主进程启动时修剪一次归档集合,然后整个进程不再碰。
196
+ *
197
+ * 这件事只能在启动这一刻做:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件
198
+ * 增量」,宿主本身没有「删除会话」的接口,插件在磁盘上删日志它也看不见。进程活着的时候
199
+ * 擦归档集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里那些其实已经
200
+ * 没有日志、本来被归档藏着的行放回正常列表 —— 用户报的正是「清理后它们又回来了,重启
201
+ * DSH 才真的消失」。启动时桌面端还没连上(或正要重连,快照本来就从磁盘重新拉),擦掉
202
+ * 不会放出任何行。留在集合里的死 id 不显示在任何地方:「已归档」取的是会话列表与归档
203
+ * 集合的交集,没有日志就没有那一行。
204
+ *
205
+ * 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
206
+ */
207
+ const PRUNE_ONCE = Symbol.for('dsh-mobile-plugin.archived-prune')
208
+ async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
209
+ if (globalThis[PRUNE_ONCE]) return
210
+ globalThis[PRUNE_ONCE] = true
211
+ try {
212
+ const pruned = await pruneOrphanedArchivedSessions(ctx)
213
+ if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
214
+ } catch (error) {
215
+ log?.(`archived session prune failed: ${error && error.message ? error.message : String(error)}`)
216
+ }
217
+ }
218
+
157
219
  async function cleanupSessions(ctx, args) {
158
220
  const list = ctx.apiProxy?.sessions?.list
159
221
  const workspaceList = ctx.apiProxy?.workspace?.list
@@ -188,11 +250,9 @@ async function cleanupSessions(ctx, args) {
188
250
  }
189
251
  if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
190
252
  const ids = selected.map(item => item.sessionId)
191
- // 与移动端那条路由同一口径:**先删日志、再擦归档集合**。反过来会在链路中途失败时
192
- // 留下「名字已从归档集合消失、日志还在」的会话(看着像只是被移出了归档,得重启宿主
193
- // 才恢复);先删则日志一没就是真的删掉了,立刻生效。擦集合是收尾,失败不改变结果。
253
+ // 与移动端那条路由同一口径:**只删日志**,归档集合留到宿主下次启动时修剪。进程里擦
254
+ // 集合会把桌面端快照里那些已经删掉的行放回正常列表(见 pruneOrphanedArchivedSessionsOnce)。
194
255
  const deletedIds = await deleteSessionData(ids)
195
- await forgetArchivedSessions(ctx, deletedIds, 'dsh-mobile-plugin-session-cleanup')
196
256
  return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
197
257
  }
198
258
 
@@ -219,4 +279,23 @@ function registerSessionCleanupTool(ctx) {
219
279
  }))
220
280
  }
221
281
 
222
- module.exports = { cleanupSessions, registerSessionCleanupTool, deleteSessionData, forgetArchivedSessions }
282
+ /**
283
+ * 装配层的入口:注册 agent 的清理工具,同时在「宿主刚起来」时修剪一遍归档集合。
284
+ *
285
+ * 两件事都是会话清理的一部分,所以共用装配层那一处 effect;impl.js 有行数兜底守卫,
286
+ * 别把实现搬回去。
287
+ */
288
+ function installSessionCleanup(ctx) {
289
+ // 「宿主启动时」不能只看进程级标记:升级到这份实现那次是**热替换**进来的(进程早就在跑),
290
+ // 标记还没被谁设过。那一刻桌面端的会话列表快照里可能正挂着之前删掉的行,擦集合就会把它们
291
+ // 放回列表。所以只在进程刚起的这点窗口里修剪;晚于它就留给下次启动。
292
+ if (process.uptime() <= BOOT_PRUNE_WINDOW_SECONDS) {
293
+ // 修剪先挂:没有 tools 注入的 profile 注册不了工具(外层会吞掉这条错),但归档集合照样要收尾。
294
+ void pruneOrphanedArchivedSessionsOnce(ctx, (message) => {
295
+ try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响修剪 */ }
296
+ })
297
+ }
298
+ registerSessionCleanupTool(ctx)
299
+ }
300
+
301
+ module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }