dsh-mobile-plugin 0.1.89 → 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/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]) => ({
@@ -194,6 +194,14 @@ function createSelfCheck(deps = {}) {
194
194
  skipReason: known && typeof push.skipReason === 'string' ? push.skipReason : null,
195
195
  // 待补推的会话数(pusher 的失败重试账长度)。
196
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,
197
205
  cachedSessions: cache ? cache.entries : null,
198
206
  cachedMessages: cache ? cache.messages : null,
199
207
  cachedBytes: cache ? cache.bytes : null,
@@ -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,
@@ -726,6 +726,33 @@ function registerControlPlaneRoutes(deps) {
726
726
  const result = await restartSidecar()
727
727
  sendJson(res, result.ok ? 200 : 500, { ...result, status: status() })
728
728
  }) })
729
+ // 开发机专用:就地把**盘上这一份**重新装载一遍,不重启 DSH。
730
+ //
731
+ // 与 `/update/apply` 的分界线:那条是「去 npm 装新版」(新目录 → 新 realpath,天然绕开
732
+ // 全部缓存);这条是「版本号没变、目录没变,只是文件被改了」。默认那条换代判据看到
733
+ // 「版本身份一致」就直接返回,于是开发时改完代码触发一次,什么都不会发生 —— 现场就是
734
+ // 「我改了、也触发了、还是旧行为」。
735
+ //
736
+ // 刻意做成**本机接口**(localOnly + POST),而不是包一层开关:它能把这台机器上跑着的
737
+ // 插件实现换成盘上任意一份代码,权限面与「改 profile 里的文件」等价,只该给本机运维。
738
+ registerRoute({ kind: 'exact', path: '/api/relay/reload-implementation', handler: localOnly('POST', async (_req, res) => {
739
+ const runtime = pluginRuntime()
740
+ // 没有外壳(实现被直接挂载)时换不了:如实说清楚,别假装成功。
741
+ if (!runtime || typeof runtime.reread !== 'function') {
742
+ return sendJson(res, 409, runtimeUnavailable)
743
+ }
744
+ try {
745
+ const result = await runtime.reread()
746
+ return sendJson(res, 200, { ok: true, data: result })
747
+ } catch (error) {
748
+ // 装载失败时外壳已经按老规矩回滚到上一代了,这里只把原因与回滚后的状态一起交出去。
749
+ return sendJson(res, 500, {
750
+ ok: false,
751
+ error: { code: 'reload_failed', message: String(error?.message || error) },
752
+ data: runtime.status(),
753
+ })
754
+ }
755
+ }) })
729
756
  }
730
757
 
731
758
  // 路由字符串清单放在注册区之后,避免被按路由源码切片的契约测试误认为是真正的 handler。
@@ -755,6 +782,8 @@ const WEB_ACCESS_ROUTES = new Set([
755
782
  '/api/relay/update/ota/check',
756
783
  '/api/relay/update/ota/apply',
757
784
  '/api/relay/restart-sidecar',
785
+ // 开发机的「就地重载」:能换掉本机跑着的实现,属于最该只允许本机调的那一类。
786
+ '/api/relay/reload-implementation',
758
787
  ]);
759
788
 
760
789
  // `panelDiagnosis` 一并导出,只为让 test/panel-artifact.test.cjs 能拿临时目录跑那三条分支:
@@ -19,7 +19,7 @@
19
19
  * 卡住的请求不该拖住一次修复性更新)。
20
20
  */
21
21
 
22
- import { importGeneration, resolveGeneration, resolveGenerations } from './manager.js'
22
+ import { importGeneration, invalidateGeneration, resolveGeneration, resolveGenerations } from './manager.js'
23
23
 
24
24
  /** 换代前等在飞请求归零的上限。 */
25
25
  const DRAIN_TIMEOUT_MS = 2000
@@ -118,15 +118,29 @@ export function createRuntime() {
118
118
  *
119
119
  * 旧代先停、新代后起;新代起不来就回滚把旧代装回去,并把错误留在 `lastError` 里——
120
120
  * 「更新把自己弄挂了」必须能被看见,而不是静默少个插件。
121
+ *
122
+ * `options.reread` 是给**开发机**的那条路(2026-10-19):版本号与目录都没变,只是盘上
123
+ * 那几个文件被改了。默认会走上面那句「身份一致就直接返回」,于是改完代码再触发也没有
124
+ * 任何变化 —— 开发时看到的「改了不生效」就是这么来的。带上它就先把这一代从
125
+ * `imported` 里作废、再让 `importGeneration` 清掉 CJS 子树,强制重新读盘。
126
+ *
127
+ * 这条**不给市场更新用**:市场每次都是新目录(新 realpath),天然绕开全部缓存,
128
+ * 走 reread 只是白清一遍缓存。
121
129
  */
122
- async function reload(preferVersion) {
130
+ async function reload(preferVersion, options = {}) {
123
131
  if (detached) throw new Error('插件已经卸载,不能再换代')
124
132
  if (swapping) return swapping
125
133
  swapping = (async () => {
126
134
  const target = resolveGeneration(preferVersion)
127
135
  if (!target) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
136
+ const sameGeneration = current
137
+ && target.version === current.version
138
+ && target.source === current.source
139
+ && target.dir === current.hostPath
128
140
  // 同版本也可能从 OTA 切到 npm:来源与 realpath 不同就必须重新装载。
129
- if (current && target.version === current.version && target.source === current.source && target.dir === current.hostPath) return status()
141
+ if (sameGeneration && options.reread !== true) return status()
142
+ // 同一份目录里改了文件:不清缓存的话,重新 import 拿回来的还是内存里那份旧实现。
143
+ if (options.reread === true) invalidateGeneration(target.version)
130
144
 
131
145
  await drain()
132
146
  const oldFiber = fiber
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.89",
3
+ "version": "0.1.90",
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",