dsh-mobile-plugin 0.1.82 → 0.1.84

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/README.md CHANGED
@@ -74,16 +74,19 @@ pnpm add dsh-mobile-plugin@<版本>
74
74
 
75
75
  新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
76
76
 
77
- 发布一个紧急完整包:在插件仓库根目录 `npm pack`,把打出来的 tgz 原样 POST 给中转(包本身就是 tar.gz,不用再解包重打):
77
+ 发布一个紧急完整包:用仓库里的打包脚本,不要手工 `tar`。
78
78
 
79
79
  ```sh
80
- curl -X POST -H "authorization: Bearer <中转的 OTA 发布 token>" --data-binary @dsh-mobile-plugin-0.1.61.tgz \
81
- "https://你的中转/api/desktop-plugins/ota/publish?platform=darwin&channel=stable&version=0.1.61"
80
+ # 只打包(产物在当前目录)
81
+ node scripts/pack-ota.mjs
82
+
83
+ # 打包并发布,发布后会下载回来核对哈希
84
+ node scripts/pack-ota.mjs --publish https://你的中转 --token <中转的 OTA 发布 token>
82
85
  ```
83
86
 
84
- `platform` 填 `process.platform`(`darwin` / `linux` / `win32`),`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
87
+ 脚本做四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对——半个包、被中间层改过的包都装不上去。
85
88
 
86
- 打包时**别用 macOS 默认的 tar 参数**——带 PAX 扩展头,宿主那边的解析器不认,会报「OTA 包结构不完整」。要 `COPYFILE_DISABLE=1 tar --no-xattrs --no-mac-metadata -czf …`,且条目以 `package/` 打头。
89
+ `--platform` 默认 `process.platform`(`darwin` / `linux` / `win32`);这个插件是纯 JS,也可以 `--platform any` 发一份三个系统通用,客户端在自己平台没有包时会回落去问 `any`。`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
87
90
 
88
91
  ## 开发
89
92
 
package/lib/client.js CHANGED
@@ -271,27 +271,39 @@ window.__ModuleLoader__.load({
271
271
  /**
272
272
  * 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
273
273
  *
274
- * 两类会话都算「桌面读过了」:
275
- * 1. **未读绿点刚掉出去**的(用户点了那一行)—— 只报 true → false 的边沿,首帧的
276
- * 全量未读集合一律不上报(那些还没读,照报会把全部绿点一次清空);会话从快照里
277
- * 消失(列表还没加载完、会话被删)同样不算读过。
278
- * 2. **此刻正开在桌面主视图里的那个**(`retainedBy.mainView > 0`)。这类会话在桌面侧
279
- * 永远不会进未读集合(`observeRunning` 对 mainView 直接跳过、`reconcileStatus`
280
- * 还会主动删),所以第 1 条那个边沿对它**一次都不会发生** —— 用户盯着它读完,
281
- * 手机上却一直亮着绿点。2026-10-10 真机截图就是这一格:手机有绿点、桌面没有。
282
- * 用户明摆着在看它,就该算读过;会话重新跑起来时把标记摘掉,下一轮结束再报一次。
274
+ * 三种情形算「桌面读过了」,每种只报一次(变回未读或重新跑起来时摘掉标记、可以再报):
275
+ * - 本次打开页面之后**看见它从别的状态变成未读、又掉出去**(普通的点开);
276
+ * - 此刻**正开在主视图里**(`retainedBy.mainView > 0`:用户盯着它读完,它压根不进未读集合);
277
+ * - 本次打开页面之后**它变过、而且现在不是未读**(补掉漏采的那一瞬间)。
278
+ *
279
+ * **首帧一条都不报**:桌面页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`
280
+ * (`completionUnread` 只在浏览器内存里,重开页面就没了),照单全收会把手机上真的
281
+ * 未读全灭掉。所以「页面打开之后变过」这条要求 `updatedAt > 页面打开时刻`,
282
+ * 没拿到会话清单时宁可不报。
283
283
  */
284
284
  function startDesktopReadSync(ctx) {
285
285
  withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
286
- let primed = false;
286
+ /** 页面打开的时刻:只有这之后动过的会话才敢当成「看见过」。 */
287
+ const openedAt = Date.now();
287
288
  let sending = false;
288
- /** sessionId → 上一轮是否在未读集合里。 */
289
- const seen = new Map();
290
- /** 已经替「正开着」的会话报过读的标记;它重新跑起来就摘掉。 */
291
- const reportedOpen = new Set();
289
+ /** 已经替它报过「读过」的会话;变回未读 / 运行中就摘掉。 */
290
+ const reported = new Set();
291
+ /** 本次页面里见过它处于未读的(普通的「点开」边沿就靠这个)。 */
292
+ const everUnread = new Set();
292
293
  /** 待上报的会话 id;POST 失败就留着下一轮再发。 */
293
294
  const pending = new Set();
294
295
 
296
+ /** 会话清单里每行的 `updatedAt` / `retainedBy`(拿不到就是空表,方向一退化成边沿判定)。 */
297
+ const retainedRows = () => {
298
+ try {
299
+ const list = currentSessions && currentSessions.list;
300
+ const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
301
+ return (snapshot && snapshot.byId) || {};
302
+ } catch {
303
+ return {};
304
+ }
305
+ };
306
+
295
307
  const flush = async () => {
296
308
  if (sending || !pending.size) return;
297
309
  const ids = [];
@@ -310,22 +322,6 @@ window.__ModuleLoader__.load({
310
322
  }
311
323
  };
312
324
 
313
- /**
314
- * 清单里每行的 `retainedBy`:「谁正开着它」只在这里。
315
- *
316
- * 读的是上面 `captureSessions` 抓回来的那份服务(可选依赖):拿不到就只少
317
- * 「正开着」这一项增强,第 1 类边沿上报照常工作 —— 所以它不该是硬门禁。
318
- */
319
- const retainedRows = () => {
320
- try {
321
- const list = currentSessions && currentSessions.list;
322
- const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
323
- return (snapshot && snapshot.byId) || {};
324
- } catch {
325
- return {};
326
- }
327
- };
328
-
329
325
  const scan = () => {
330
326
  if (isDisposed()) return;
331
327
  const uiSession = scope.uiSession;
@@ -338,28 +334,25 @@ window.__ModuleLoader__.load({
338
334
  return;
339
335
  }
340
336
  const rows = retainedRows();
341
- const next = new Map();
342
337
  for (const [id, status] of statusEntries(snapshot)) {
343
338
  const unread = !!(status && status.completionUnread === true);
344
- next.set(id, unread);
345
- if (primed && seen.get(id) === true && !unread) pending.add(id);
346
- // 第 2 类:正开着的,且不是运行中 —— 报一次就够了,跑起来就重新排队。
347
- const row = rows[id];
348
- const open = !!(row && row.retainedBy && Number(row.retainedBy.mainView) > 0);
349
- if (!open || (status && status.running === true)) {
350
- reportedOpen.delete(id);
339
+ const running = !!(status && status.running === true);
340
+ // 未读 = 还没读;运行中 = 这一轮的结局还没出来。两者都不是「读过了」。
341
+ if (unread || running) {
342
+ if (unread) everUnread.add(id);
343
+ reported.delete(id);
351
344
  continue;
352
345
  }
353
- if (reportedOpen.has(id)) continue;
354
- reportedOpen.add(id);
346
+ if (reported.has(id)) continue;
347
+ const row = rows[id];
348
+ const open = !!(row && Number(row.retainedBy && row.retainedBy.mainView) > 0);
349
+ const changedWhileOpen = !!(row && typeof row.updatedAt === 'number' && row.updatedAt > openedAt);
350
+ // 既没看见它掉出未读、也没见它这次变过、更没开着:多半只是页面刚加载,
351
+ // 全是不带未读标记的空快照。一个都不许报。
352
+ if (!everUnread.has(id) && !open && !changedWhileOpen) continue;
353
+ reported.add(id);
355
354
  pending.add(id);
356
355
  }
357
- seen.clear();
358
- for (const [id, unread] of next) seen.set(id, unread);
359
- if (!primed) {
360
- primed = true;
361
- return;
362
- }
363
356
  if (pending.size) void flush();
364
357
  };
365
358
 
@@ -413,9 +406,9 @@ window.__ModuleLoader__.load({
413
406
  }
414
407
 
415
408
  /**
416
- * 把 sessions 服务抓进一个可变引用,供方向一的可选增强(「正开着」判定)使用。
417
- * 它缺席时宿主侧看不到这条 effect 的标签,自检上报里 `sessions` 那一格也正是 false ——
418
- * 排查时能一眼看出「不是没读到,是服务没拿到」。
409
+ * 把 sessions 服务抓进一个可变引用,供方向一判断「正开着」与「这次变过」。
410
+ * 它缺席时少这一路增强(方向一仍有边沿与主视图两条判据),宿主侧自检里
411
+ * `sessions` 那一格正是 false,排查时能一眼看出是服务没拿到。
419
412
  */
420
413
  let currentSessions = null;
421
414
  function captureSessions(ctx) {
@@ -726,8 +719,8 @@ window.__ModuleLoader__.load({
726
719
  ctx.effect(() => ctx.locale.register(NS, DICT), 'mobile-plugin: settings dictionaries');
727
720
  const t = ctx.locale.bind(NS);
728
721
  startDesktopViewSync(ctx);
729
- startDesktopReadSync(ctx);
730
722
  captureSessions(ctx);
723
+ startDesktopReadSync(ctx);
731
724
  startReadReceiptSync(ctx);
732
725
  startClientHalfReport(ctx);
733
726
  // 插槽是「先声明后注册」:宿主可能比我们晚一步声明 settings.section,
package/lib/impl.js CHANGED
@@ -35,7 +35,7 @@ const { createApiProxy } = require('./mobile/host-bridge.js')
35
35
  // 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
36
36
  const activity = require('./mobile/activity.cjs')
37
37
  const { installAutoArchive } = require('./auto-archive.cjs')
38
- const { registerSessionCleanupTool } = require('./session-cleanup.cjs')
38
+ const { installSessionCleanup } = require('./session-cleanup.cjs')
39
39
  const { activeGuard } = require('./host-guard.cjs')
40
40
  // 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
41
41
  // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
@@ -299,7 +299,7 @@ function apply(ctx) {
299
299
  ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
300
300
  ctx.effect(() => {
301
301
  // Profiles without the optional tools injection still support core remote control.
302
- try { registerSessionCleanupTool(ctx) } catch (error) {
302
+ try { installSessionCleanup(ctx) } catch (error) {
303
303
  if (!String(error).includes('without inject')) throw error
304
304
  }
305
305
  return undefined
@@ -34,6 +34,7 @@ const { dshHome } = require('../shared/paths.js');
34
34
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
35
35
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
36
36
  const { dropSessionListCache } = require('../mobile/session-cache.js');
37
+ const { observeCompletionEvent } = require('../mobile/completion.js');
37
38
 
38
39
  /** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
39
40
  const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
@@ -683,6 +684,7 @@ function createMirrorPusher(deps) {
683
684
  // 同一轮收尾时的 assistant/message 会把完整消息带进来,手机侧的实时文本由
684
685
  // session/stream 的 SSE 负责。不跳的话每条增量都要重建五份全量快照并推一次
685
686
  // sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
687
+ observeCompletionEvent(ctx, sessionId, p?.event);
686
688
  if (p?.event?.type === 'assistant/chunk') continue;
687
689
  void pushSessionImmediately(sessionId);
688
690
  }
@@ -1,30 +1,30 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并交由落地函数删除。
4
+ * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并删掉它们的日志。
5
5
  *
6
6
  * 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
7
7
  * 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
8
8
  *
9
- * 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存 → 擦归档集合这套只在 session-cleanup.cjs
10
- * 里存在(见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
9
+ * 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存只在 session-cleanup.cjs 里存在
10
+ * (见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
11
+ *
12
+ * **这里不擦宿主的归档集合**(只把删掉的 id 记进本进程的删除集,挡住手机端列表里可能
13
+ * 还没失效的旧快照)。进程里擦集合会把桌面端那份会话列表快照里、其实已经删掉的行放回
14
+ * 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
15
+ * 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
11
16
  */
12
17
 
13
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
14
19
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
15
- const {
16
- canonicalDeviceKey,
17
- rememberDeletedSessionIds,
18
- pendingUnarchiveSessionIdsOf,
19
- clearPendingUnarchiveSessionIds,
20
- } = require('./session-cache.js');
20
+ const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
21
21
  const { invalidateSessionList } = require('./sessions.js');
22
22
  const { unwrap } = require('./envelope.js');
23
- const { deleteSessionData, forgetArchivedSessions } = require('../session-cleanup.cjs');
23
+ const { deleteSessionData } = require('../session-cleanup.cjs');
24
24
 
25
25
  /**
26
26
  * POST /api/mobile/v1/workspace/clear-archived(token)
27
- * body: { confirm: true } → { ok: true, deleted: N, unarchiveFailedSessionIds }。confirm 不为 true 一律 400。
27
+ * body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
28
28
  */
29
29
  async function handleClearArchived(ctx, req, res) {
30
30
  const body = await readJsonBody(req, 64 * 1024);
@@ -40,36 +40,13 @@ async function handleClearArchived(ctx, req, res) {
40
40
  }
41
41
  const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
42
42
  const selected = archivedIds.filter((id) => typeof id === 'string' && id);
43
- if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0, archivedSessionIds: archivedIds } };
44
- // 顺序是**先删日志、再擦归档集合**。删日志才是用户要的那件事,擦集合只是收尾。
45
- //
46
- // 反过来(先擦后删)曾导致用户报的现象:擦归档是逐条 RPC,中途任何一条失败或链路超时,
47
- // 日志一份没删、名字却已经从归档集合里消失——会话退回普通列表,看着像「清理只是把它
48
- // 移出了归档」,必须重启桌面端才恢复。先删就没这个窗口:日志没了就是**真的清理掉了**,
49
- // 立刻生效、不必等桌面端重启;擦集合失败最多在「已归档」里留一行点不开的死行,
50
- // 重试一次即可,不会让操作白做。
51
- //
52
- // 擦集合是 best-effort:它失败**不**让整条清理报错(日志已经删成功,报错等于骗用户)。
43
+ if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
53
44
  const removed = await deleteSessionData(selected);
45
+ // 删的是列表里可见的行:手机端正开着列表,宿主那份快照可能还带着它们,先在本进程压住。
54
46
  rememberDeletedSessionIds(ctx, removed);
55
- const pendingUnarchive = pendingUnarchiveSessionIdsOf(ctx);
56
- const retryable = [...new Set(selected)].filter((id) => pendingUnarchive.has(id));
57
- const failedToUnarchive = await forgetArchivedSessions(ctx, retryable, 'mobile');
58
- const failed = new Set(failedToUnarchive);
59
- clearPendingUnarchiveSessionIds(ctx, retryable.filter((id) => !failed.has(id)));
60
- // 删的是列表里可见的行:手机端正开着列表,不立刻失效最长 10 秒还能点到死行。
47
+ // 不立刻失效的话,最长 10 秒还能点到已删的行。
61
48
  invalidateSessionList(ctx);
62
- // 只从返回的归档集合中移除宿主确认已取消归档的 ID;失败项保留为重试候选。
63
- const cleared = new Set(retryable.filter((id) => !failed.has(id)));
64
- return {
65
- status: 200,
66
- body: {
67
- ok: true,
68
- deleted: removed.length,
69
- archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)),
70
- unarchiveFailedSessionIds: failedToUnarchive,
71
- },
72
- };
49
+ return { status: 200, body: { ok: true, deleted: removed.length } };
73
50
  });
74
51
  sendJson(res, outcome.status, outcome.body);
75
52
  }
@@ -1,22 +1,47 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 完成提醒状态机:从宿主会话列表快照与 history 事件日志里确认「跑完了」。
4
+ * 完成提醒状态机:一轮怎么结束以 history 为准(turn/end 的 reason.kind),实时 turn
5
+ * 事件只是让结论早一步到达。提醒算不算数看两个水位:读过的时刻之前不算,本进程启动
6
+ * 之前结束的轮次也不重播 —— 不再要求「先看见 running」。
5
7
  *
6
- * 三条铁律写在代码注释里,这里也复述一遍,因为它们各自都防过一次误报:
7
- * 1. **不**从 running→idle 的状态边沿推断完成。agent-loop 是先 append 最终
8
- * assistant/message 再 append turn/end,所以必须由 history 折出同一轮的这两个事件
9
- * 才算完成;
10
- * 2. `wasRunning` 是进程内边沿标记,只有本次 Host 存活期间观察到的轮次才有资格报完成,
11
- * 否则冷启动的一份旧列表会把上次开机前的轮次当成新完成弹一遍通知;
12
- * 3. 一轮的结局由 `turn/end` 的 `reason.kind` 说了算:`error` 记失败(宿主会话列表里
13
- * 没有任何失败字段,App 侧栏的红点全靠这里折出来),`completed` 才算完成,其余
14
- * kind(max-tokens / aborted)既不算完成也不算失败。
8
+ * 旧要求的后果(2026-10-17 真机):宿主没采到那一瞬间的轮次永远不亮,桌面亮着绿点 /
9
+ * 红点,手机上什么都没有。
15
10
  */
16
11
 
17
- const { completionStateOf } = require('./session-cache.js');
12
+ const { completionStateOf, completionStateMapsOf } = require('./session-cache.js');
13
+
14
+ /** 本进程起点:比它更早结束的轮次不重播。 */
15
+ const PROCESS_STARTED_AT = Date.now();
16
+
17
+ /** 「不算数」水位:读过按读过时刻;没读过时,本进程见过它跑就放开(0)。 */
18
+ function baselineOf(state) {
19
+ if (!state) return PROCESS_STARTED_AT;
20
+ if (typeof state.clearedAt === 'number') return state.clearedAt;
21
+ return state.wasRunning === true ? 0 : PROCESS_STARTED_AT;
22
+ }
23
+
24
+ /** Consume one live Host turn event for every device with an initialized completion state. */
25
+ function observeCompletionEvent(ctx, sessionId, event) {
26
+ if (!sessionId || !event || !event.data || typeof event.data.turn !== 'number') return;
27
+ const turn = event.data.turn;
28
+ for (const states of completionStateMapsOf(ctx)) {
29
+ const previous = states.get(sessionId);
30
+ if (!previous) continue;
31
+ if (event.type === 'turn/start') {
32
+ states.set(sessionId, { ...previous, running: true, completed: false, wasRunning: true, turn, completionEvents: [event] });
33
+ continue;
34
+ }
35
+ if ((event.type !== 'assistant/message' && event.type !== 'turn/end')
36
+ || previous.turn !== turn || previous.wasRunning !== true) continue;
37
+ const priorEvents = Array.isArray(previous.completionEvents) ? previous.completionEvents : [];
38
+ const events = [...priorEvents.filter((item) => item.type !== event.type), event];
39
+ const next = { ...previous, running: event.type === 'turn/end' ? false : previous.running, completionEvents: events };
40
+ if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false);
41
+ states.set(sessionId, next);
42
+ }
43
+ }
18
44
 
19
- /** Update shared completion reminders from one Host session-list snapshot. */
20
45
  function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
21
46
  const states = completionStateOf(ctx, deviceKey);
22
47
  const seen = new Set();
@@ -25,37 +50,30 @@ function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
25
50
  seen.add(summary.sessionId);
26
51
  const running = !!summary.running;
27
52
  const previous = states.get(summary.sessionId);
28
- // Do not infer completion from the status edge. The history fold below
29
- // must prove the matching turn/end plus its final assistant message.
53
+ // 不靠状态边沿猜完成:下面的 history 折叠必须自己找到同轮的 turn/end。
30
54
  const completed = previous?.completed === true;
31
- // `wasRunning` is an internal edge marker. A completed notification is
32
- // only eligible for a turn observed while this Host was alive; a cold
33
- // list snapshot must not replay an old turn as a new completion.
34
55
  states.set(summary.sessionId, {
35
56
  running,
36
57
  completed: running ? false : completed,
37
58
  turn: previous?.turn,
38
- wasRunning: previous?.running === true,
39
- // 「上一轮失败」是历史事实:宿主列表摘要里没有这个信息,唯一来源是下面的 history
40
- // 折叠。它必须跨快照、跨下一轮延续 —— 否则「失败之后用户又发一句话」会让
41
- // state.turn 前进,红点还没被看见就没了。
59
+ // 只决定「允许追到多久以前」(见 baselineOf),不再是确认完成的前提。
60
+ wasRunning: previous?.running === true || previous?.wasRunning === true,
61
+ completionEvents: previous?.completionEvents,
62
+ clearedAt: previous?.clearedAt, // 已读水位跨快照延续,读过的轮次不许再亮回来
63
+ // 「上一轮失败」只能由 history 折出,必须跨快照、跨下一轮延续:否则用户失败后
64
+ // 又发一句话,state.turn 前进,红点还没被看见就没了。
42
65
  failure: previous?.failure,
43
66
  });
44
67
  }
45
- // The Host list is canonical for this device scope; discard detached
46
- // sessions so completion state cannot leak across reused session IDs.
68
+ // 列表是本设备作用的权威:不在列表里的条目要丢(防止复用 id 串状态)。带已读水位的
69
+ // 留着 —— 丢了水位,会话离开列表再回来时旧轮次会重新点亮。
47
70
  for (const sessionId of states.keys()) {
48
- if (!seen.has(sessionId)) states.delete(sessionId);
71
+ if (!seen.has(sessionId) && typeof states.get(sessionId)?.clearedAt !== 'number') states.delete(sessionId);
49
72
  }
50
73
  return states;
51
74
  }
52
75
 
53
- /**
54
- * Confirm the running -> idle edge against the event log. Agent status is a
55
- * coarse activity signal: the agent-loop appends the final assistant/message
56
- * and only then appends turn/end. Keep the edge tied to that exact turn so a
57
- * late/stale list response cannot complete a newer (or an older) turn.
58
- */
76
+ /** 把 history 最后一轮的结局折到 state 上:结束时刻越过 baselineOf 才算提醒。 */
59
77
  function confirmCompletionFromHistory(state, events, running) {
60
78
  if (!state || !Array.isArray(events)) return;
61
79
  let latestTurn;
@@ -76,15 +94,19 @@ function confirmCompletionFromHistory(state, events, running) {
76
94
  }
77
95
  }
78
96
  if (latestTurn !== undefined) state.turn = latestTurn;
79
- if (running || !state.wasRunning || state.turn === undefined) return;
97
+ if (running || state.turn === undefined) return;
80
98
  const end = ends.get(state.turn);
99
+ if (!end) return;
100
+ const baseline = baselineOf(state);
101
+ const endedAt = typeof end.time === 'number' ? end.time : baseline + 1;
102
+ if (endedAt <= baseline) return; // 读过的 / 冷启动之前结束的那一轮
81
103
  const assistant = assistants.get(state.turn);
82
- const reason = end && end.data ? end.data.reason : undefined;
104
+ const reason = end.data ? end.data.reason : undefined;
83
105
  const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
84
106
  // 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 下面那条
85
107
  // 「assistant 早于 turn/end 就算完成」的规则会把这种轮次判成「已完成待读」,侧栏于是
86
108
  // 把一次失败显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
87
- if (end && kind === 'error') {
109
+ if (kind === 'error') {
88
110
  const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
89
111
  state.completed = false;
90
112
  state.failure = {
@@ -97,18 +119,18 @@ function confirmCompletionFromHistory(state, events, running) {
97
119
  }
98
120
  // max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
99
121
  // 所以只清完成标记,不产生红点。
100
- if (end && kind !== undefined && kind !== 'completed') {
122
+ if (kind !== undefined && kind !== 'completed') {
101
123
  state.completed = false;
102
124
  state.failure = undefined;
103
125
  return;
104
126
  }
105
127
  // The assistant message must be the settled message for this turn, not an
106
128
  // interrupted partial and not merely a preceding turn's message.
107
- if (end && assistant && assistant.seq <= end.seq && assistant.data.interrupted !== true) {
129
+ if (assistant && assistant.seq <= end.seq && assistant.data.interrupted !== true) {
108
130
  state.completed = true;
109
131
  // 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
110
132
  state.failure = undefined;
111
133
  }
112
134
  }
113
135
 
114
- module.exports = { updateCompletionStates, confirmCompletionFromHistory };
136
+ module.exports = { observeCompletionEvent, updateCompletionStates, confirmCompletionFromHistory };
@@ -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,7 +17,7 @@ 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');
@@ -211,7 +211,8 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
211
211
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId }' } });
212
212
  }
213
213
  const deviceKey = canonicalDeviceKey(req, fallbackDeviceKey);
214
- completionStateOf(ctx, deviceKey).delete(body.sessionId);
214
+ // 只清这台设备自己的提醒;水位留在状态里,读过的轮次就不会再亮回来。
215
+ markCompletionRead(ctx, deviceKey, body.sessionId);
215
216
  // Invalidate the cache and any build already reading history. Its generation
216
217
  // check prevents a stale build from restoring `completed` after this ack.
217
218
  invalidateSessionList(ctx, deviceKey);
@@ -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) => {