dsh-mobile-plugin 0.1.88 → 0.1.90

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/lib/impl.js CHANGED
@@ -353,6 +353,13 @@ function apply(ctx) {
353
353
  // 桌面端「我读过了」那条路由(/api/relay/desktop-read)要清掉宿主侧的完成提醒,
354
354
  // 需要插件 ctx 才能拿到按设备隔离的状态表。
355
355
  ctx,
356
+ // 内存自检台账(`/api/relay/status` 的 `selfCheck`)的四个取值器:台账本体在
357
+ // `lib/mobile/self-check.cjs`。侧车内存与两条路的流数都只有 worker 的 `/status`
358
+ // 快照这一条既有通道(局域网那份由 lan-proxy 的 stats() 经快照带出来)。
359
+ getSidecarSnapshot: sidecarLifecycle.getSnapshot,
360
+ getMirrorPush: mirror.getMirrorPush,
361
+ getMirrorPushFailures: mirror.getPushFailures,
362
+ getLanProxyStats: () => sidecarLifecycle.getSnapshot()?.streams?.lan ?? null,
356
363
  })
357
364
 
358
365
 
package/lib/index.js CHANGED
@@ -151,6 +151,8 @@ export function apply(ctx) {
151
151
  check,
152
152
  apply: applyUpdate,
153
153
  reload: version => runtime.reload(version),
154
+ /** 就地把盘上这一份重新装载一遍:版本身份没变也照换(开发机改完代码用,见 runtime 的 reread)。 */
155
+ reread: () => runtime.reload(undefined, { reread: true }),
154
156
  deviceOta,
155
157
  setAutoUpdate: enabled => {
156
158
  const value = updater.setAutoUpdate(enabled)
@@ -194,6 +194,8 @@ function createMirrorPusher(deps) {
194
194
 
195
195
  // 镜像推送的唯一出口:所有 pushSync 都必须经此,保证状态与真实推送一致。
196
196
  const pushMirrorPayload = async (payload) => {
197
+ // 每一次推上去的清单都顺手记一下规模(含短路那两条路径:手机侧栏看的就是这份清单)。
198
+ noteMirrorList(payload && payload.sessions);
197
199
  if (!tunnelConnected()) {
198
200
  recordMirrorPush(false, '隧道未连接,镜像未推送');
199
201
  // 没推成镜像,但「正文长到哪儿了」这件事仍然要说给手机听(见 announceSessionActivity)。
@@ -256,6 +258,28 @@ function createMirrorPusher(deps) {
256
258
 
257
259
  const isHighPriority = (s) => s && (s.running || (s.updatedAt && Date.now() - s.updatedAt < HIGH_PRIORITY_WINDOW_MS));
258
260
 
261
+ /**
262
+ * 镜像清单本身的规模(自检台账用):会话总数、其中活跃的、其中子会话。
263
+ *
264
+ * 这是插件侧能**诚实**报出的那部分规模——「宿主内存里挂着多少会话、正文占多少」插件看不到
265
+ * (它住在宿主进程里,没有独立视角,见 lib/mobile/self-check.cjs 的口径段)。清单每 tick 都
266
+ * 在重建,顺手记三个计数不额外花销。
267
+ *
268
+ * 只在真拿到清单时更新:拿不到时保留上一次的读数(连带 `at`),与「清单突然变成 0」区分开。
269
+ */
270
+ let mirrorListSessions = 0;
271
+ let mirrorListActiveSessions = 0;
272
+ let mirrorListSubagents = 0;
273
+ let mirrorListAt = 0;
274
+ const noteMirrorList = (sessionsBody) => {
275
+ const items = sessionsBody && Array.isArray(sessionsBody.items) ? sessionsBody.items : null;
276
+ if (!items) return;
277
+ mirrorListSessions = items.length;
278
+ mirrorListActiveSessions = items.filter(isHighPriority).length;
279
+ mirrorListSubagents = items.filter((s) => s && s.origin === 'subagent').length;
280
+ mirrorListAt = Date.now();
281
+ };
282
+
259
283
  // Every session snapshot fans out to three Host RPCs. Bound the number of sessions
260
284
  // being built globally; event work gets priority, but batch/cold work gets one slot
261
285
  // after at most three consecutive event jobs to prevent starvation.
@@ -323,6 +347,33 @@ function createMirrorPusher(deps) {
323
347
  // 清单里没有这一行(比如事件先到、清单还没刷出来)时退回 Date.now():这个值只参与
324
348
  // 缓存指纹,不影响正文内容本身。
325
349
  const updatedAt = (summary && summary.updatedAt) || Date.now();
350
+ /**
351
+ * 子会话的正文**刻意不建**(2026-10-19)。
352
+ *
353
+ * 普通历史接口读子会话会被宿主按 durable parent address 拒掉
354
+ * (`session/agent-busy: subagent Sessions require their durable parent address`),
355
+ * 而镜像这份正文手机本来就用不到:手机打开子代理读的是子代理那条专用通道(带父会话
356
+ * 地址与 mode,见 lib/mobile/subagents.js 的文件头)。以前每次都照读、每次都被拒,
357
+ * 于是 5 秒一次的冷轮转里固定夹着一次注定失败的读取,`skippedBodies` 一路涨
358
+ * (真机实测 4 分钟 408 → 451),日志里那条 skip 也永远说不清到底哪坏了。
359
+ *
360
+ * 这里提前认出清单行上的 `origin === 'subagent'` 就不发那次请求,且 **skipReason 留空**:
361
+ * 它不是失败,不该进 `skippedBodies` 那本故障账。清单行本身照旧推给手机(App 靠行上的
362
+ * origin/parentSessionId 认子代理),stats/models 照旧构建,所以镜像载荷的形状与既有的
363
+ * 「正文缺席」那条路径完全一致。
364
+ */
365
+ if (summary && summary.origin === 'subagent') {
366
+ const [stats, models] = await Promise.all([
367
+ sessions.buildStatsSnapshot(ctx, sessionId),
368
+ sessions.buildModelsSnapshot(ctx, sessionId),
369
+ ]);
370
+ return {
371
+ messages: null,
372
+ skipReason: null,
373
+ stats: stats?.ok ? stats : null,
374
+ models: models?.ok ? models : null,
375
+ };
376
+ }
326
377
  const [messages, stats, models] = await Promise.all([
327
378
  sessions.buildMirrorWarmSnapshots(
328
379
  ctx,
@@ -707,6 +758,13 @@ function createMirrorPusher(deps) {
707
758
  // 正文缺失而清单照推的次数与最近原因:与 ok/pushedAt 分开,避免再把
708
759
  // 「某会话正文读不到」误读成「镜像死了」(见 recordMirrorPushSkipped)。
709
760
  skippedBodies: mirrorBodySkipCount, skipReason: mirrorBodySkipReason,
761
+ // 清单规模(自检台账读它):会话总数 / 其中活跃 / 其中子会话 / 这份读数的时间。
762
+ // 这是「手机侧栏那份清单有多大」,**不是**「宿主内存里挂着多少会话、正文占多少」——
763
+ // 后者插件看不到(见 lib/mobile/self-check.cjs 的口径段)。
764
+ listSessions: mirrorListSessions,
765
+ listActiveSessions: mirrorListActiveSessions,
766
+ listSubagents: mirrorListSubagents,
767
+ listAt: mirrorListAt,
710
768
  }),
711
769
  /** 失败会话的定向重试账(诊断 + 守卫用):失败几次、下次什么时候补推。 */
712
770
  getPushFailures: () => [...sessionPushFailures.entries()].map(([sessionId, entry]) => ({
@@ -17,23 +17,11 @@
17
17
  */
18
18
  'use strict';
19
19
 
20
- const crypto = require('crypto');
21
-
22
20
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
23
21
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
22
+ const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
24
23
 
25
- const SSE_RETRY_MS = 3000;
26
24
  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;
37
25
  /** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
38
26
  const HEARTBEAT_MS = 15_000;
39
27
 
@@ -51,55 +39,23 @@ function logDeviceStream(message, error) {
51
39
  } catch { /* ignore */ }
52
40
  }
53
41
 
54
- function stableEventId(payload, frame) {
55
- if (payload && typeof payload === 'object') {
56
- if (payload.type === 'session/event' && payload.sessionId && payload.event && Number.isFinite(payload.event.seq)) {
57
- return `${payload.sessionId}:${payload.event.seq}`;
58
- }
59
- for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
60
- if (typeof payload[key] === 'string' && payload[key]) return payload[key];
61
- }
62
- const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'device';
63
- const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
64
- return `${sessionId}:${payload.type || 'event'}:${digest}`;
65
- }
66
- return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
67
- }
68
-
69
42
  /**
70
- * 这条连接上真正「还没发出去」的字节数。
43
+ * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
71
44
  *
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` 写进去了但底下已经写不动。
45
+ * 这条流的策略:软水位**一律** shed。设备级流的帧全是「有东西变了」的信号,本来就幂等
46
+ * —— 丢掉一帧的代价是那一次刷新要等下一次变更或 60s 兜底轮询,而堆在写缓冲里的代价是
47
+ * 宿主 OOM(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。
48
+ * 不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
86
49
  *
87
- * 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
88
- * 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
89
- * 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
50
+ * 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉,`needDrain:true`
51
+ * 写进去了但底下已经写不动。
90
52
  */
91
53
  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 };
95
- const id = stableEventId(payload, frame);
96
- const fields = [`retry: ${SSE_RETRY_MS}`];
97
- if (id) fields.push(`id: ${id}`);
98
- fields.push(`data: ${JSON.stringify(payload)}`, '', '');
99
- try {
100
- const accepted = res.write(`${fields.join('\n')}\n`);
101
- return { ok: true, needDrain: accepted === false };
102
- } catch { return { ok: false }; }
54
+ return sharedWriteSse(res, payload, {
55
+ frame,
56
+ namespace: 'device',
57
+ shedWhenBuffered: () => true,
58
+ });
103
59
  }
104
60
 
105
61
  async function* mergeHostAndMux(ctx, signal) {
@@ -290,7 +246,7 @@ async function handleDeviceStream(ctx, req, res) {
290
246
  const payload = listRelevantPayload(rawPayload);
291
247
  if (!payload) continue;
292
248
  const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
293
- const eventId = stableEventId(rawPayload, frame);
249
+ const eventId = sharedEventId(rawPayload, frame, 'device');
294
250
  if (eventId) {
295
251
  if (seenEventIds.has(eventId)) continue;
296
252
  seenEventIds.add(eventId);
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 内存自检台账:内存出问题之后,用来回答「当时到底堆在哪儿」。
5
+ *
6
+ * 起因:宿主进程内存涨到 3.5~5G,排查时手上只有 Activity Monitor 的一个总数,
7
+ * 分不出「插件推的镜像在堆」「遗弃的流订阅在堆」还是「宿主自己」。所以这里把三件事
8
+ * 一次性报出来:两个**进程**的内存、各路**活着的流数**、以及插件自己那几个
9
+ * 只增不减的容器有多大。
10
+ *
11
+ * ## 最重要的一条:口径
12
+ *
13
+ * **插件住在宿主进程里,没有独立内存可测。** `hostProcess` 报的是**整个宿主进程**
14
+ * (DSH host)的 `process.memoryUsage()`——插件、宿主自身、其它插件、V8 的堆外缓冲
15
+ * 全在这个数里。它不是「插件占了多少」。要判断「插件占多少」只能拿它和流数、镜像
16
+ * 缓存估算字节一起看趋势(插件侧唯一能量化的是 `mirror.cachedBytes`,那还是估算)。
17
+ * 字段名与 `caveat` 都按这个口径写死,别把它改名成 pluginMemory 之类。
18
+ *
19
+ * 侧车(sidecar facade worker)是**另一个进程**(lib/sidecar-facade.cjs 的 fork),
20
+ * 它的内存在 `sidecarProcess` 下,与宿主分列——两个数不能相加成「插件内存」。
21
+ *
22
+ * ## 数据从哪儿来(全走既有通道,没有新开一条)
23
+ *
24
+ * - 宿主进程内存:本进程 `process.memoryUsage()` / `v8.getHeapStatistics()`。
25
+ * - 侧车进程内存、流数:**worker 的 `/status` 快照**(`sidecarLifecycle.getSnapshot()`,
26
+ * 与 `tunnel`/`lan` 状态同一个来源)。worker 没上报对应字段时如实报 `null` 并记进
27
+ * `unavailable`——**不拿 0 顶替**:0 是「一条流都没有」,与「不知道」是两件事。
28
+ * - 局域网直连路的流数:由注入的 `getLanProxyStats()` 提供,约定形状
29
+ * `() => ({ upstreamOpen, sseOpen }) | null`(`lan-proxy.js` 的 `stats()`)。
30
+ * - 镜像:`mirror.getMirrorPush()` / `getPushFailures()`(不在本模块重复记账),
31
+ * 缓存本体走 `session-cache.js` 的 `mirrorHistoryStateOf(ctx)`。
32
+ * - 凭据库:`credentials.listActive().length`(凭据包只暴露「活跃」这一档)。
33
+ *
34
+ * ## 为什么是工厂而不是模块级单例
35
+ *
36
+ * `desktop-half-stats.cjs` 是单例:它只记进程级的计数。这份不一样——镜像缓存按
37
+ * `ctx` 隔离(WeakMap),拿不到 ctx 就读不到。所以做成 `createSelfCheck(deps)`,
38
+ * 由 control-plane 用注入的 ctx 建一次,而不是往模块级塞一个 ctx。
39
+ *
40
+ * ## 字节估算的口径
41
+ *
42
+ * `mirror.cachedBytes` 是**估算**,不是精确占用:字符串按 `String.length`(会话正文
43
+ * 以 ASCII 为主,字符数≈字节)、数字 8 字节、布尔 4、每个对象/数组 24 字节固定开销。
44
+ * 走查有节点预算(`MAX_WALK_NODES`),用尽时 `cachedBytesComplete` 为 false,
45
+ * 此时那个数是**下限**而不是总量。
46
+ */
47
+
48
+ const v8 = require('node:v8');
49
+
50
+ const { mirrorHistoryStateOf } = require('./session-cache.js');
51
+
52
+ /** 字节估算的节点预算:status 每 3s 被面板拉一次,走查必须是有界的。 */
53
+ const MAX_WALK_NODES = 20000;
54
+ /** 每个对象/数组的固定开销估计(V8 里一个空对象约这个量级)。 */
55
+ const OBJECT_OVERHEAD_BYTES = 24;
56
+
57
+ /** 只认有限数:NaN/Infinity/字符串一律记 null,「不知道」不能写成 0。 */
58
+ function finiteNumber(value) {
59
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
60
+ }
61
+
62
+ /** `process.memoryUsage()` 的白名单副本 + V8 堆上限(超了这个数就是 heap OOM)。 */
63
+ function memoryOf(usage, heapStatistics) {
64
+ if (!usage || typeof usage !== 'object') return null;
65
+ return {
66
+ rss: finiteNumber(usage.rss),
67
+ heapTotal: finiteNumber(usage.heapTotal),
68
+ heapUsed: finiteNumber(usage.heapUsed),
69
+ external: finiteNumber(usage.external),
70
+ arrayBuffers: finiteNumber(usage.arrayBuffers),
71
+ heapSizeLimit: heapStatistics && typeof heapStatistics === 'object'
72
+ ? finiteNumber(heapStatistics.heap_size_limit)
73
+ : null,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * 估算一棵对象树占的字节(口径见文件头)。节点预算用尽时 `complete:false`,`bytes` 是下限。
79
+ */
80
+ function estimateBytes(root, budget = MAX_WALK_NODES) {
81
+ const seen = new Set();
82
+ const stack = [root];
83
+ let bytes = 0;
84
+ let nodes = 0;
85
+ while (stack.length) {
86
+ if (nodes >= budget) return { bytes, complete: false };
87
+ const node = stack.pop();
88
+ nodes += 1;
89
+ if (typeof node === 'string') { bytes += node.length; continue; }
90
+ if (typeof node === 'number') { bytes += 8; continue; }
91
+ if (typeof node === 'boolean') { bytes += 4; continue; }
92
+ if (!node || typeof node !== 'object') continue;
93
+ if (seen.has(node)) continue;
94
+ seen.add(node);
95
+ bytes += OBJECT_OVERHEAD_BYTES;
96
+ if (Array.isArray(node)) {
97
+ for (const item of node) stack.push(item);
98
+ continue;
99
+ }
100
+ for (const key of Object.keys(node)) {
101
+ bytes += key.length;
102
+ stack.push(node[key]);
103
+ }
104
+ }
105
+ return { bytes, complete: true };
106
+ }
107
+
108
+ /**
109
+ * 镜像缓存本体:条目数 / 消息条数 / 估算字节。
110
+ *
111
+ * ⚠️ `mirrorHistoryStateOf(ctx)` 在 ctx 上还没有这张表时会**建一张空的**——这与第一次
112
+ * 镜像推送本来就会做的事完全一样,没有额外状态。ctx 为空(本文件被单独 require 时)
113
+ * 返回 null,不抛。
114
+ */
115
+ function mirrorCacheOf(ctx) {
116
+ if (!ctx) return null;
117
+ const cache = mirrorHistoryStateOf(ctx);
118
+ if (!cache || typeof cache.size !== 'number') return null;
119
+ let messages = 0;
120
+ const bodies = [];
121
+ for (const entry of cache.values()) {
122
+ const items = entry && entry.body && Array.isArray(entry.body.items) ? entry.body.items : null;
123
+ if (items) messages += items.length;
124
+ bodies.push(entry ? entry.body : null);
125
+ }
126
+ const estimate = estimateBytes(bodies);
127
+ return { entries: cache.size, messages, bytes: estimate.bytes, complete: estimate.complete };
128
+ }
129
+
130
+ /**
131
+ * 建一份自检台账。所有依赖都是取值器或只读模块,本模块不持有 ctx 之外的可变状态。
132
+ *
133
+ * @param {object} [deps]
134
+ * @param {object} [deps.ctx] 插件 ctx(读镜像缓存需要;缺省时镜像缓存报 null)
135
+ * @param {() => object|null} [deps.getSidecarSnapshot] worker 的 `/status` 快照取值器
136
+ * @param {() => object|null} [deps.getMirrorPush] 镜像推送记账(pusher 提供)
137
+ * @param {() => Array} [deps.getMirrorPushFailures] 待补推会话账(pusher 提供)
138
+ * @param {() => ({upstreamOpen: number, sseOpen: number})|null} [deps.getLanProxyStats]
139
+ * 局域网直连路的活连接数(`lan-proxy.js` 的 `stats()`)
140
+ * @param {object|null} [deps.credentials] 本机设备凭据库
141
+ */
142
+ function createSelfCheck(deps = {}) {
143
+ const {
144
+ ctx = null,
145
+ getSidecarSnapshot = () => null,
146
+ getMirrorPush = () => null,
147
+ getMirrorPushFailures = () => null,
148
+ getLanProxyStats = () => null,
149
+ credentials = null,
150
+ } = deps;
151
+ const since = Date.now();
152
+
153
+ function hostProcess() {
154
+ const usage = process.memoryUsage();
155
+ return {
156
+ // ⚠️ 口径:整个宿主进程,不是插件自己(见文件头)。
157
+ scope: 'host-process',
158
+ caveat: '插件住在宿主里,没有独立内存;这里是整个 DSH 宿主进程,含宿主自身与其它插件',
159
+ ...memoryOf(usage, v8.getHeapStatistics()),
160
+ startedAt: since,
161
+ };
162
+ }
163
+
164
+ function sidecarProcess(snapshot) {
165
+ const reported = snapshot && snapshot.process && snapshot.process.memoryUsage;
166
+ const memory = memoryOf(reported);
167
+ return { memory };
168
+ }
169
+
170
+ function streams(snapshot) {
171
+ const lan = getLanProxyStats();
172
+ const relaySseOpen = snapshot && snapshot.streams ? finiteNumber(snapshot.streams.relaySseOpen) : null;
173
+ return {
174
+ // 中转路:worker 里 `relay/tunnel.js` 的 sseClients.size。
175
+ relaySseOpen,
176
+ // 局域网直连路:`lan-proxy.js` 的 stats()。
177
+ lanUpstreamOpen: lan ? finiteNumber(lan.upstreamOpen) : null,
178
+ lanSseOpen: lan ? finiteNumber(lan.sseOpen) : null,
179
+ };
180
+ }
181
+
182
+ function mirror() {
183
+ // 拿不到记账时整块报 null 并记进 unavailable:`skippedBodies` 报 0 会被读成
184
+ // 「一次都没跳过」,而那是「不知道」——两者在排查时结论正好相反。
185
+ const push = getMirrorPush();
186
+ const known = Boolean(push) && typeof push === 'object';
187
+ const failures = getMirrorPushFailures();
188
+ const cache = mirrorCacheOf(ctx);
189
+ return {
190
+ pushedAt: known ? (finiteNumber(push.pushedAt) === null ? 0 : finiteNumber(push.pushedAt)) : null,
191
+ ok: known && typeof push.ok === 'boolean' ? push.ok : null,
192
+ error: known && typeof push.error === 'string' ? push.error : null,
193
+ skippedBodies: known ? (finiteNumber(push.skippedBodies) === null ? 0 : finiteNumber(push.skippedBodies)) : null,
194
+ skipReason: known && typeof push.skipReason === 'string' ? push.skipReason : null,
195
+ // 待补推的会话数(pusher 的失败重试账长度)。
196
+ pushFailures: Array.isArray(failures) ? failures.length : null,
197
+ // 清单规模:会话总数 / 其中活跃(在跑或 5 分钟内动过)/ 其中子会话 / 这份读数的时间。
198
+ // 这是插件侧**能诚实报出的**「会话有多少」——手机侧栏读的就是这份清单。
199
+ // ⚠️ 它**不是**「宿主内存里挂着多少会话、正文占多少」:插件住在宿主进程里,看不到那个
200
+ // 口径(见文件头)。正文规模只能由 `cachedBytes` 那一格回答「插件自己握着多少」。
201
+ listSessions: known ? finiteNumber(push.listSessions) : null,
202
+ listActiveSessions: known ? finiteNumber(push.listActiveSessions) : null,
203
+ listSubagents: known ? finiteNumber(push.listSubagents) : null,
204
+ listAt: known ? (finiteNumber(push.listAt) === null ? 0 : finiteNumber(push.listAt)) : null,
205
+ cachedSessions: cache ? cache.entries : null,
206
+ cachedMessages: cache ? cache.messages : null,
207
+ cachedBytes: cache ? cache.bytes : null,
208
+ cachedBytesComplete: cache ? cache.complete : null,
209
+ };
210
+ }
211
+
212
+ function credentialsBlock() {
213
+ const list = credentials && typeof credentials.listActive === 'function'
214
+ ? credentials.listActive()
215
+ : null;
216
+ return { active: Array.isArray(list) ? list.length : null };
217
+ }
218
+
219
+ /** 当前读数副本:每次调用都是新对象,status 视图读它、改它都不影响下一份。 */
220
+ function snapshot() {
221
+ const sidecarSnapshot = getSidecarSnapshot();
222
+ const sidecar = sidecarProcess(sidecarSnapshot);
223
+ const flow = streams(sidecarSnapshot);
224
+ const mir = mirror();
225
+ const creds = credentialsBlock();
226
+ const unavailable = [];
227
+ if (!sidecar.memory) {
228
+ unavailable.push('sidecarProcess.memory: worker 的 /status 未上报 process.memoryUsage');
229
+ }
230
+ if (flow.relaySseOpen === null) {
231
+ unavailable.push('streams.relaySseOpen: worker 的 /status 未上报 streams.relaySseOpen');
232
+ }
233
+ if (flow.lanUpstreamOpen === null) {
234
+ unavailable.push('streams.lanUpstreamOpen: 拿不到局域网 stats()(见 self-check.cjs 文件头)');
235
+ }
236
+ if (mir.cachedSessions === null) {
237
+ unavailable.push('mirror.cachedSessions: 没有 ctx,读不到镜像缓存');
238
+ }
239
+ if (mir.pushedAt === null) {
240
+ unavailable.push('mirror.pushedAt: 拿不到镜像推送记账(getMirrorPush)');
241
+ }
242
+ if (mir.pushFailures === null) {
243
+ unavailable.push('mirror.pushFailures: 拿不到镜像待补推账(getMirrorPushFailures)');
244
+ }
245
+ if (creds.active === null) {
246
+ unavailable.push('credentials.active: 拿不到凭据库');
247
+ }
248
+ return {
249
+ since,
250
+ hostProcess: hostProcess(),
251
+ sidecarProcess: sidecar,
252
+ streams: flow,
253
+ mirror: mir,
254
+ credentials: creds,
255
+ unavailable,
256
+ };
257
+ }
258
+
259
+ return { snapshot };
260
+ }
261
+
262
+ module.exports = { createSelfCheck, estimateBytes, memoryOf, MAX_WALK_NODES };
@@ -41,7 +41,7 @@ const {
41
41
  } = require('./session-cache.js');
42
42
  const { resolveEventAttachments } = require('./attachments.js');
43
43
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
44
- const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
44
+ const { eventSeqOf, buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
45
45
  const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
46
46
  const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
47
47
  const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSessionAttachment, handleStop,
@@ -17,38 +17,21 @@
17
17
  * 要 unref —— 它只是保活,不该把进程退出按在 15s 一拍上。
18
18
  */
19
19
 
20
- const crypto = require('crypto');
21
-
22
20
  const { sendJson } = require('../shared/http-util');
21
+ const {
22
+ writeSse: sharedWriteSse,
23
+ waitForDrain,
24
+ ssePadding,
25
+ } = require('../shared/sse-write.js');
23
26
  const { resolveEventAttachments } = require('./sessions.js');
24
27
  const { callSessions } = require('./rpc.js');
25
28
  const { unwrap } = require('./envelope.js');
26
29
  const { normalizeJobs } = require('./jobs.js');
27
30
 
28
- const SSE_RETRY_MS = 3000;
29
31
  // Android React Native's XHR may buffer tiny chunked responses until its
30
32
  // internal progress threshold. Pad each SSE record so onprogress exposes it
31
33
  // promptly; the padding is an SSE comment and is ignored by clients.
32
34
  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;
52
35
  const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
53
36
 
54
37
  /**
@@ -75,90 +58,23 @@ function logStream(sessionId, message, error) {
75
58
  } catch { /* ignore */ }
76
59
  }
77
60
 
78
- function stableEventId(payload, frame) {
79
- const source = payload && payload.type === 'session/event' ? payload : frame?.payload;
80
- if (source && typeof source === 'object') {
81
- if (source.type === 'session/event' && source.sessionId && source.event && Number.isFinite(source.event.seq)) {
82
- return `${source.sessionId}:${source.event.seq}`;
83
- }
84
- }
85
- if (payload && typeof payload === 'object') {
86
- for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
87
- if (typeof payload[key] === 'string' && payload[key]) return payload[key];
88
- }
89
- const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'session';
90
- const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
91
- return `${sessionId}:${payload.type || 'event'}:${digest}`;
92
- }
93
- return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
94
- }
95
-
96
61
  /**
97
- * 这条连接上真正「还没发出去」的字节数。
62
+ * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
98
63
  *
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 记录。
64
+ * 这条流的策略:软水位只丢**没有 seq** 的增量(`assistant/chunk` 是可丢弃草稿,同一轮
65
+ * 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);每条记录补 2048 字节
66
+ * 注释,让 RN 的 XHR 尽快吐出来。
137
67
  *
138
68
  * @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
139
69
  * `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
140
- * `shed:true` → 这条**增量**帧被丢掉、连接还活着(超过软水位且没有 seq);
70
+ * `shed:true` → 这条**增量**帧被丢掉、连接还活着;
141
71
  * `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
142
- *
143
- * 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
144
- * 留一个永久的洞,那比断流更糟。
145
72
  */
146
73
  function writeSse(res, payload, frame) {
147
- const buffered = bufferedBytesOf(res);
148
- if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
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 };
152
- const fields = [`retry: ${SSE_RETRY_MS}`];
153
- if (id) fields.push(`id: ${id}`);
154
- fields.push(`data: ${JSON.stringify(payload)}`);
155
- fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
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 }; }
74
+ return sharedWriteSse(res, payload, {
75
+ frame,
76
+ padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
77
+ });
162
78
  }
163
79
 
164
80
  /**