dsh-mobile-plugin 0.1.94 → 0.1.95

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.
@@ -7,9 +7,21 @@
7
7
  *
8
8
  * 旧要求的后果(2026-10-17 真机):宿主没采到那一瞬间的轮次永远不亮,桌面亮着绿点 /
9
9
  * 红点,手机上什么都没有。
10
+ *
11
+ * ⚠️ 这里有两笔**性质不同**的账,别混回去:
12
+ *
13
+ * - `completionState*`(按设备):**哪台手机还没看过这一轮**。它是提醒,只有 App 的
14
+ * `/session/read` 能清,天然属于「谁在问」。后台通知(/monitor)还靠它。
15
+ * - `lastTurnFactsOf`(不分设备):**这一轮什么时候结束的、成没成**。它是事实,跟
16
+ * 谁在问无关,所以列表行可以直接带它去镜像快照 —— 而快照是全局一份、给所有设备读的。
17
+ *
18
+ * 之前列表行只带前者,镜像又是用固定设备键构建的,于是快照里那份「未读」永远清不掉:
19
+ * 手机上点开看过,侧栏的绿点/红点照样挂着,而绕过镜像的那种读又立刻变干净 —— 同一行
20
+ * 一会儿一个状态。现在 App 侧自己拿事实 + 本机已读水位算「亮不亮」,两笔账各归各位。
10
21
  */
11
22
 
12
23
  const { completionStateOf, completionStateMapsOf } = require('./session-cache.js');
24
+ const { settleOutcome, recordLastTurn } = require('./session-last-turn.js');
13
25
 
14
26
  /** 本进程起点:比它更早结束的轮次不重播。 */
15
27
  const PROCESS_STARTED_AT = Date.now();
@@ -37,7 +49,7 @@ function observeCompletionEvent(ctx, sessionId, event) {
37
49
  const priorEvents = Array.isArray(previous.completionEvents) ? previous.completionEvents : [];
38
50
  const events = [...priorEvents.filter((item) => item.type !== event.type), event];
39
51
  const next = { ...previous, running: event.type === 'turn/end' ? false : previous.running, completionEvents: events };
40
- if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false);
52
+ if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false, ctx, sessionId);
41
53
  states.set(sessionId, next);
42
54
  }
43
55
  }
@@ -73,8 +85,14 @@ function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
73
85
  return states;
74
86
  }
75
87
 
76
- /** 把 history 最后一轮的结局折到 state 上:结束时刻越过 baselineOf 才算提醒。 */
77
- function confirmCompletionFromHistory(state, events, running) {
88
+ /**
89
+ * 把 history 最后一轮的结局折到 state 上:结束时刻越过 baselineOf 才算提醒。
90
+ *
91
+ * 事实那一半(这一轮什么时候结束、成没成)由 `session-last-turn.js` 记下来,与设备无关;
92
+ * 这里只维护提醒。传了 `ctx` + `sessionId` 就顺手把事实记进去,**不受已读水位影响** ——
93
+ * 它说的是「这一轮怎么结束的」,跟哪台手机看过没关系(见文件头)。
94
+ */
95
+ function confirmCompletionFromHistory(state, events, running, ctx = null, sessionId = null) {
78
96
  if (!state || !Array.isArray(events)) return;
79
97
  let latestTurn;
80
98
  const ends = new Map();
@@ -97,40 +115,30 @@ function confirmCompletionFromHistory(state, events, running) {
97
115
  if (running || state.turn === undefined) return;
98
116
  const end = ends.get(state.turn);
99
117
  if (!end) return;
118
+ const outcome = settleOutcome({ turn: state.turn, end, assistant: assistants.get(state.turn) });
119
+ if (ctx && sessionId) recordLastTurn(ctx, sessionId, outcome);
100
120
  const baseline = baselineOf(state);
101
- const endedAt = typeof end.time === 'number' ? end.time : baseline + 1;
102
- if (endedAt <= baseline) return; // 读过的 / 冷启动之前结束的那一轮
103
- const assistant = assistants.get(state.turn);
104
- const reason = end.data ? end.data.reason : undefined;
105
- const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
106
- // 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 下面那条
107
- // 「assistant 早于 turn/end 就算完成」的规则会把这种轮次判成「已完成待读」,侧栏于是
108
- // 把一次失败显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
109
- if (kind === 'error') {
110
- const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
121
+ if (outcome.endedAt <= baseline) return; // 读过的 / 冷启动之前结束的那一轮
122
+ if (outcome.status === 'failed') {
111
123
  state.completed = false;
112
124
  state.failure = {
113
- turn: state.turn,
114
- code: typeof error.code === 'string' ? error.code : null,
115
- message: typeof error.message === 'string' ? error.message : null,
116
- at: typeof end.time === 'number' ? end.time : Date.now(),
125
+ turn: outcome.turn,
126
+ code: outcome.code,
127
+ message: outcome.message,
128
+ at: outcome.endedAt,
117
129
  };
118
130
  return;
119
131
  }
120
132
  // max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
121
133
  // 所以只清完成标记,不产生红点。
122
- if (kind !== undefined && kind !== 'completed') {
134
+ if (outcome.status === 'incomplete') {
123
135
  state.completed = false;
124
136
  state.failure = undefined;
125
137
  return;
126
138
  }
127
- // The assistant message must be the settled message for this turn, not an
128
- // interrupted partial and not merely a preceding turn's message.
129
- if (assistant && assistant.seq <= end.seq && assistant.data.interrupted !== true) {
130
- state.completed = true;
131
- // 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
132
- state.failure = undefined;
133
- }
139
+ state.completed = true;
140
+ // 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
141
+ state.failure = undefined;
134
142
  }
135
143
 
136
144
  module.exports = { observeCompletionEvent, updateCompletionStates, confirmCompletionFromHistory };
@@ -89,7 +89,7 @@ async function buildMonitorState(ctx, deviceKey) {
89
89
  // 读到这些历史的这一刻,别处可能刚发起一次写后刷新(它会 bump 代次);
90
90
  // 用旧快照去确认结局等于把「还在跑的一轮」写成已完成。
91
91
  if (completionGenerationOf(ctx, deviceKey) !== generation) return;
92
- confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false);
92
+ confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false, ctx, summary.sessionId);
93
93
  } catch {
94
94
  // 这一轮没读到就原样留着:判定条件(没完成、没失败)下一轮仍然成立,会再试。
95
95
  }
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端「插件更新」:APP 的「应用更新」页直接给这台电脑上的插件升级。
5
+ *
6
+ * ## 为什么手机端要有这条路
7
+ *
8
+ * 自动更新(外壳里那个 10 分钟轮询)有一道闸:**有对话在跑就不换文件**,等下一轮。用户在
9
+ * 手机上聊天时,这道闸几乎永远关着,而更新入口此前只在桌面控制面(`localOnly`)—— 手机
10
+ * 根本打不到。于是「插件有新版」这件事对手机用户等于不存在。这三条接口就是把它开给已配对
11
+ * 的手机。
12
+ *
13
+ * 能力面是**有意放开**的:手机本来就握着这台宿主的凭据、能发消息,而「换掉宿主上跑的插件
14
+ * 代码」比发消息重。所以入口只做一件事、且沿用已有的两道既有判据,不新增任何能力:
15
+ *
16
+ * · **来源判据不放开**:`update-install.cjs` 的 `installAllowedFor` 只放行注册表来源
17
+ * (`npm`)。本地目录 / `file:` tgz / `workspace:` 装的机器(开发机就是)即便带
18
+ * `force` 也会被拒 —— force 只跳过「有对话在跑」和「新发布先晾一会儿」,跳不过来源。
19
+ * · **换代仍然就地做**:装完由外壳 `runtime.reload()` 换实现代,不重启 DSH(见 lib/index.js)。
20
+ *
21
+ * ## 为什么 apply 只回一句「开始了」
22
+ *
23
+ * 装一次要跑包管理器,几十秒到几分钟;装完换代会把**当前这一代实现连同这条路由一起卸掉**,
24
+ * 旧代码手里的响应发不出去。所以结果不能靠这条请求的返回值,只能靠前端轮询 status。
25
+ * 顺带一个好处:apply 期间没有在飞请求,换代时 `runtime.drain()` 不用等谁。
26
+ *
27
+ * 状态里给的是**身份**而不是时间戳:前端用「运行中版本变了」或「appliedAt 变了」判完成,
28
+ * 手机与电脑的钟不一定对得上,比较时间会误判。
29
+ *
30
+ * ## 三条接口
31
+ *
32
+ * · `GET /api/mobile/v1/plugin/update/status` 运行中/盘上版本、安装来源、上一轮更新结果
33
+ * · `POST /api/mobile/v1/plugin/update/check` 问 npm 注册表有没有新版(与桌面面板同一个实现)
34
+ * · `POST /api/mobile/v1/plugin/update/apply` 开始一次强制更新,立即返回
35
+ */
36
+
37
+ const path = require('node:path');
38
+ const { sendJson, readJsonBody } = require('../shared/http-util');
39
+ const { checkForUpdate } = require('../update-registry.cjs');
40
+ const { declaredSource } = require('../update-install.cjs');
41
+ const { version: generationVersion } = require('../../package.json');
42
+
43
+ /** apply 的请求体只有 `force` 一个布尔,给 4KB 都很宽。 */
44
+ const APPLY_BODY_MAX = 4 * 1024;
45
+
46
+ /**
47
+ * 响应写出去之后再开装。装一开始就会有人读盘,先让这一发把 202 吐完、inFlight 归零,
48
+ * 免得换代时 drain 还要等这条请求自己。
49
+ */
50
+ const APPLY_DEFER_MS = 150;
51
+
52
+ /**
53
+ * 同一次安装多久算「还在跑」。装的过程中 `status.swapping` 还是 false(那时还没到换代),
54
+ * 所以只能自己记一个开始时刻;装的上限是 10 分钟(update-install 的 INSTALL_TIMEOUT_MS),
55
+ * 这里留两分钟余量。换代完成后这一代模块被整体丢弃,这个变量自然归零。
56
+ */
57
+ const APPLY_BUSY_MS = 12 * 60 * 1000;
58
+
59
+ let applyingSince = 0;
60
+
61
+ /** 外壳服务;拿不到(无头部署 / 实现被直接挂载)就是没有换代能力。 */
62
+ function runtimeOf(ctx) {
63
+ try {
64
+ return typeof ctx?.get === 'function' ? ctx.get('dshMobilePluginRuntime') || null : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** 上一轮更新的结果:只挑前端要用的几格,`check` 里那一坨注册表原始响应不往手机上搬。 */
71
+ function lastApplyOf(value) {
72
+ if (!value || typeof value !== 'object') return null;
73
+ const failure = value.failure && typeof value.failure === 'object'
74
+ ? { code: typeof value.failure.code === 'string' ? value.failure.code : null, message: String(value.failure.message || '') }
75
+ : null;
76
+ return {
77
+ installed: value.installed === true,
78
+ skipped: value.skipped === true,
79
+ state: typeof value.state === 'string' ? value.state : null,
80
+ beforeVersion: typeof value.beforeVersion === 'string' ? value.beforeVersion : null,
81
+ installedVersion: typeof value.installedVersion === 'string' ? value.installedVersion : null,
82
+ swapped: value.swapped === true,
83
+ retryable: value.retryable === true,
84
+ failure,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * 现场快照。`declaredSource` 读的是 profile 的依赖声明(同一个判据 installAllowedFor 用的
90
+ * 就是它),所以「界面说能更新」与「真去装不会被拒」是同一件事。
91
+ */
92
+ function snapshot(ctx) {
93
+ const runtime = runtimeOf(ctx);
94
+ if (!runtime) {
95
+ return {
96
+ supported: false,
97
+ runningVersion: generationVersion,
98
+ installedVersion: null,
99
+ source: null,
100
+ appliedAt: null,
101
+ lastApply: null,
102
+ shellChanged: false,
103
+ };
104
+ }
105
+ const s = runtime.status();
106
+ const profileNodeModules = typeof s.profileNodeModules === 'string' ? s.profileNodeModules : null;
107
+ return {
108
+ supported: typeof runtime.apply === 'function',
109
+ runningVersion: s.version ?? null,
110
+ installedVersion: s.installedVersion ?? null,
111
+ // 来源读不到(profile 里没这条依赖)按「不知道」报,前端不拿它当拒绝理由 ——
112
+ // 那种现场多半是插件压根不是从 profile 装的,装一次反而是修复。
113
+ source: declaredSource(profileNodeModules ? path.dirname(profileNodeModules) : null) ?? s.marketResult?.source ?? null,
114
+ appliedAt: typeof s.appliedAt === 'string' ? s.appliedAt : null,
115
+ lastApply: lastApplyOf(s.lastApply),
116
+ // 新版本连外壳一起改了:换得掉实现,换不掉外壳,如实报出来(那部分等下次启动)。
117
+ shellChanged: s.shellChanged === true,
118
+ };
119
+ }
120
+
121
+ /** `GET /plugin/update/status` */
122
+ async function handlePluginUpdateStatus(ctx, _req, res) {
123
+ sendJson(res, 200, { ok: true, status: snapshot(ctx) });
124
+ }
125
+
126
+ /**
127
+ * `POST /plugin/update/check`:问 npm 注册表。
128
+ *
129
+ * 不要求外壳在场(查不需要换代能力),盘上版本优先取外壳那份,拿不到退回这一代自己的
130
+ * 版本号 —— 与桌面控制面 `/api/relay/update/check` 同一套口径。
131
+ */
132
+ async function handlePluginUpdateCheck(ctx, _req, res) {
133
+ const status = snapshot(ctx);
134
+ try {
135
+ const update = await checkForUpdate({
136
+ installedVersion: status.installedVersion ?? status.runningVersion ?? generationVersion,
137
+ source: status.source,
138
+ });
139
+ sendJson(res, 200, { ok: true, update, status });
140
+ } catch (error) {
141
+ sendJson(res, 500, {
142
+ ok: false,
143
+ error: { code: 'plugin_update_check_failed', message: String(error?.message || error) },
144
+ update: null,
145
+ status,
146
+ });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * `POST /plugin/update/apply`:开始一次更新。
152
+ *
153
+ * 手机点的是「我现在就要更新」,所以固定带 `force`(跳过「有对话在跑」与发布晾置期)。
154
+ * 没有可换代的外壳时回 409:盘上换了版本、运行中的实现换不掉,等于白装。
155
+ */
156
+ async function handlePluginUpdateApply(ctx, req, res) {
157
+ const runtime = runtimeOf(ctx);
158
+ if (!runtime || typeof runtime.apply !== 'function') {
159
+ return sendJson(res, 409, {
160
+ ok: false,
161
+ error: {
162
+ code: 'plugin_update_unavailable',
163
+ message: '这台宿主没有可就地换代的插件外壳,装完没法立即生效;请在电脑上更新后重启 DSH',
164
+ },
165
+ });
166
+ }
167
+ let body = {};
168
+ try {
169
+ body = (await readJsonBody(req, APPLY_BODY_MAX)) || {};
170
+ } catch {
171
+ body = {};
172
+ }
173
+ if (Date.now() - applyingSince < APPLY_BUSY_MS) {
174
+ return sendJson(res, 409, {
175
+ ok: false,
176
+ error: { code: 'plugin_update_busy', message: '上一次更新还在进行,请等它结束' },
177
+ });
178
+ }
179
+ const before = snapshot(ctx);
180
+ const force = body.force === true;
181
+ applyingSince = Date.now();
182
+ sendJson(res, 200, {
183
+ ok: true,
184
+ started: true,
185
+ force,
186
+ before: { runningVersion: before.runningVersion, installedVersion: before.installedVersion, appliedAt: before.appliedAt },
187
+ });
188
+ setTimeout(() => {
189
+ // 结果不在这里回给谁:它不落账、也不改 applyingSince 的成功分支 —— 换代之后这一代
190
+ // 模块整体被丢弃,前端读的是新一代表现出来的 status。
191
+ runtime.apply({ force }).catch(() => { applyingSince = 0; });
192
+ }, APPLY_DEFER_MS);
193
+ }
194
+
195
+ module.exports = {
196
+ handlePluginUpdateStatus,
197
+ handlePluginUpdateCheck,
198
+ handlePluginUpdateApply,
199
+ };
@@ -32,6 +32,10 @@ const sessionPreviewClock = new WeakMap();
32
32
  // 完成提醒属于宿主进程状态:按 ctx 隔离,避免不同宿主/测试上下文互相污染。
33
33
  const completionStates = new WeakMap();
34
34
  const completionGenerations = new WeakMap();
35
+ // 「上一轮什么时候结束、成没成」——会话自己的事实,**不分设备**。
36
+ // 与 completionStates 的分工见 completion.js 的文件头:那个是「哪台手机还没看过」,
37
+ // 这个只回答「上一轮发生了什么」,所以它必须设备无关:镜像快照是全局一份、给所有设备读。
38
+ const lastTurnFacts = new WeakMap();
35
39
  // Prevent duplicate prompt calls when a mobile request is retried after a timeout.
36
40
  const sendIdempotency = new WeakMap();
37
41
  // 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
@@ -156,6 +160,19 @@ function completionStateMapsOf(ctx) {
156
160
  return [...(completionStates.get(ctx)?.values() || [])];
157
161
  }
158
162
 
163
+ /**
164
+ * 会话「上一轮结局」的设备无关台账:`sessionId → { turn, endedAt, kind, error }`。
165
+ *
166
+ * 它存在的理由是**镜像快照要给所有设备读**:快照是全局一份,没法按「现在是谁在问」
167
+ * 去算「你看过没有」。所以列表只带这一份事实(这一轮什么时候结束的、成没成),
168
+ * 「读过没有」由 App 自己按设备存的水位去比。详见 completion.js 的文件头。
169
+ */
170
+ function lastTurnFactsOf(ctx) {
171
+ let facts = lastTurnFacts.get(ctx);
172
+ if (!facts) { facts = new Map(); lastTurnFacts.set(ctx, facts); }
173
+ return facts;
174
+ }
175
+
159
176
  /**
160
177
  * 标记「这条会话读过了」:记一个水位,把当前的完成 / 失败提醒清掉,返回清掉的条数。
161
178
  *
@@ -297,6 +314,7 @@ module.exports = {
297
314
  mirrorHistoryStateOf,
298
315
  completionStateOf,
299
316
  completionStateMapsOf,
317
+ lastTurnFactsOf,
300
318
  markCompletionRead,
301
319
  clearCompletionForAllDevices,
302
320
  completionGenerationOf,
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「上一轮怎么结束的」——会话自己的**设备无关事实**。
5
+ *
6
+ * 与 completion.js 的分工:那边是「哪台手机还没看过」的提醒账(按设备),这里是「这一轮
7
+ * 什么时候结束、成没成」。两者曾是一笔账,于是列表行只能带「未读」这个含义 —— 而列表还要
8
+ * 喂镜像快照,快照是全局一份、给所有设备读,按设备算的结论在里面永远是同一台假设备的,
9
+ * 手机上点开看过也不灭。现在 App 拿这份事实 + 本机已读水位自己算点(见
10
+ * `app/src/services/viewmodels/session-status.ts`)。
11
+ *
12
+ * 抽成独立模块的理由与 completion.js 当初一样:它是**纯计算 + 一张表**,不碰 ctx 的其它
13
+ * 状态,守卫要能单独驱动它(`test/session-last-turn.test.cjs`)。
14
+ */
15
+
16
+ const { lastTurnFactsOf } = require('./session-cache.js');
17
+
18
+ /**
19
+ * 一轮的结局:纯计算,不碰水位、不碰设备。
20
+ *
21
+ * 两条既有铁律照旧:error 收尾不算完成(并带失败信息);成功那句 assistant 必须是本轮
22
+ * 已落定的那条(`seq <= end.seq` 且没被打断),不能拿上一轮的冒充。
23
+ */
24
+ function settleOutcome({ turn, end, assistant }) {
25
+ const endedAt = typeof end.time === 'number' ? end.time : 0;
26
+ const reason = end.data ? end.data.reason : undefined;
27
+ const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
28
+ // 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 「assistant
29
+ // 早于 turn/end 就算完成」那条规则会把这种轮次判成「已完成待读」,侧栏于是把一次失败
30
+ // 显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
31
+ if (kind === 'error') {
32
+ const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
33
+ return {
34
+ turn,
35
+ endedAt,
36
+ status: 'failed',
37
+ code: typeof error.code === 'string' ? error.code : null,
38
+ message: typeof error.message === 'string' ? error.message : null,
39
+ };
40
+ }
41
+ // max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
42
+ // 所以只清完成标记,不产生红点。
43
+ if (kind !== undefined && kind !== 'completed') return { turn, endedAt, status: 'incomplete' };
44
+ const settled = Boolean(assistant) && assistant.seq <= end.seq && assistant.data.interrupted !== true;
45
+ return { turn, endedAt, status: settled ? 'completed' : 'incomplete' };
46
+ }
47
+
48
+ /**
49
+ * 记下「上一轮事实」:只前进不后退。
50
+ *
51
+ * 同一轮会被折叠多次(列表缓存命中、多台设备各自构建清单、事件流与 history 两条路),
52
+ * 结束时刻往回拨会让 App 侧刚清掉的点重新亮起来,所以更旧的一律丢弃。
53
+ */
54
+ function recordLastTurn(ctx, sessionId, outcome) {
55
+ if (!ctx || !sessionId || !outcome) return;
56
+ const facts = lastTurnFactsOf(ctx);
57
+ const previous = facts.get(sessionId);
58
+ if (previous && typeof previous.turn === 'number') {
59
+ if (outcome.turn < previous.turn) return;
60
+ if (outcome.turn === previous.turn && outcome.endedAt <= previous.endedAt) return;
61
+ }
62
+ facts.set(sessionId, {
63
+ turn: outcome.turn,
64
+ endedAt: outcome.endedAt,
65
+ status: outcome.status,
66
+ ...(outcome.status === 'failed' ? { code: outcome.code, message: outcome.message } : {}),
67
+ // 已读水位是**另一件事**的产物(任何一端读过都会推进),换新的事实时必须原样带上,
68
+ // 否则刚读过的一轮会因为「又折了一次 history」重新亮起来。
69
+ ...(previous && typeof previous.readAt === 'number' ? { readAt: previous.readAt } : {}),
70
+ });
71
+ }
72
+
73
+ /**
74
+ * 记下「这条会话在某一刻被读过了」——**不分设备**。
75
+ *
76
+ * 两端的读都要走到这里:手机点开(`/session/read`,那条本来就只清本设备的提醒)与
77
+ * 桌面点开(`/api/relay/desktop-read`,清所有设备的提醒)。列表要喂镜像快照,快照是
78
+ * 全局一份,没法按设备算「你看过没有」,所以只有这个与设备无关的水位能进下行清单。
79
+ *
80
+ * 只前进不后退:两端各自报各自的时刻,回拨会让已经看过的点重新亮。返回值是「水位真的
81
+ * 前进了没有」——调用方据此决定要不要作废列表缓存(重复上报不许每次都抬)。
82
+ */
83
+ function recordSessionRead(ctx, sessionId, at = Date.now()) {
84
+ if (!ctx || typeof sessionId !== 'string' || !sessionId) return false;
85
+ const value = typeof at === 'number' && Number.isFinite(at) && at > 0 ? at : Date.now();
86
+ const facts = lastTurnFactsOf(ctx);
87
+ const previous = facts.get(sessionId) || {};
88
+ const current = typeof previous.readAt === 'number' ? previous.readAt : 0;
89
+ if (current >= value) return false;
90
+ facts.set(sessionId, { ...previous, readAt: value });
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * 某个会话的上一轮事实(没有就返回 undefined)。
96
+ *
97
+ * 只回带 `turn` 的那些:表里也可能只存着一个已读水位(读过、但插件重启后还没折过
98
+ * 那一轮的结局),那不是「上一轮发生了什么」,不该下发给 App。
99
+ */
100
+ function lastTurnOf(ctx, sessionId) {
101
+ const fact = lastTurnFactsOf(ctx).get(sessionId);
102
+ if (!fact || typeof fact.turn !== 'number') return undefined;
103
+ return fact;
104
+ }
105
+
106
+ module.exports = { settleOutcome, recordLastTurn, recordSessionRead, lastTurnOf };
@@ -23,6 +23,7 @@ const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive
23
23
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
24
24
  const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
25
25
  const readReceipt = require('../read-receipt.cjs');
26
+ const { recordSessionRead } = require('./session-last-turn.js');
26
27
 
27
28
  const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
28
29
 
@@ -209,6 +210,9 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
209
210
  const deviceKey = canonicalDeviceKey(req, fallbackDeviceKey);
210
211
  // 只清这台设备自己的提醒;水位留在状态里,读过的轮次就不会再亮回来。
211
212
  markCompletionRead(ctx, deviceKey, body.sessionId);
213
+ // 「这一轮被读过了」还要记一笔**设备无关**的水位:列表要喂镜像快照(全局一份),
214
+ // 上面那份按设备的提醒进不了下行清单,App 那边根本看不到这次已读(见 session-last-turn.js)。
215
+ recordSessionRead(ctx, body.sessionId);
212
216
  // Invalidate the cache and any build already reading history. Its generation
213
217
  // check prevents a stale build from restoring `completed` after this ack.
214
218
  invalidateSessionList(ctx, deviceKey);
@@ -44,6 +44,7 @@ const {
44
44
  } = require('./session-cache.js');
45
45
  const { resolveEventAttachments } = require('./attachments.js');
46
46
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
47
+ const { lastTurnOf } = require('./session-last-turn.js');
47
48
  const { eventSeqOf, buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
48
49
  const { buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
49
50
  const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
@@ -192,12 +193,14 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
192
193
  && (archivedOnly === archived.has(item.sessionId)));
193
194
  // 状态跟踪覆盖完整列表,不能受预览补全上限影响。
194
195
  const completionStatesForContext = updateCompletionStates(ctx, allSummaries, deviceKey);
196
+ // 设备无关的「上一轮事实」台账:预览读回 history 时顺手记进去(见下面的折叠)。
195
197
  // 列表完整返回:侧边栏分组依赖全部会话(工作区 sessionIds 排序 + 未分组兜底),
196
198
  // 截断会让工作区/项目下的会话凭空消失。标题直接取 list 摘要的 title 投影
197
199
  // (与桌面端侧栏同一来源,正式 session/title 事件折叠,绝不用对话正文冒充标题),
198
200
  // 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
199
201
  const rows = allSummaries.map((s) => {
200
202
  const completionState = completionStatesForContext.get(s.sessionId);
203
+ const fact = lastTurnOf(ctx, s.sessionId);
201
204
  // 待处理标记:判据与后台监控共用一份(见 session-flags.js),别再在这里写第二份。
202
205
  const pendingInteraction = pendingInteractionOf(s.sessionId, s);
203
206
  const base = {
@@ -210,6 +213,11 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
210
213
  // 侧栏据此画红点:completed 只说「跑完了、待阅读」,说不了「跑挂了」。字段只在
211
214
  // 真有失败时出现,旧 App 忽略未知字段、行为不变。
212
215
  ...(completionState?.failure ? { lastTurnError: completionState.failure } : {}),
216
+ // 上一轮的**设备无关事实**:这一轮什么时候结束、成没成。上面那两个(completed /
217
+ // lastTurnError)是「这台设备还没看过」的提醒,而列表同时还要喂镜像快照 ——
218
+ // 快照全局一份、给所有设备读,按设备算的提醒在里面永远是同一台假设备的结论。
219
+ // App 因此改成拿这一格 + 本机已读水位自己算点,见文件头与 completion.js。
220
+ ...(fact ? { lastTurn: fact } : {}),
213
221
  blank: !!s.blank,
214
222
  cwd: s.cwd || null,
215
223
  // 会话定档的 Agent 预设(宿主 session.list 摘要里带,用来显示会话当前预设)。
@@ -241,7 +249,7 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
241
249
  const events = preview.events;
242
250
  const completionState = completionStatesForContext.get(s.sessionId);
243
251
  if (completionGenerationOf(ctx, deviceKey) === generation) {
244
- confirmCompletionFromHistory(completionState, events, !!s.running);
252
+ confirmCompletionFromHistory(completionState, events, !!s.running, ctx, s.sessionId);
245
253
  }
246
254
  // Only expose completion after the matching turn/end and final
247
255
  // assistant/message are both present in the same history snapshot.
@@ -252,6 +260,10 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
252
260
  // 也没有红点),要等下一轮轮询才亮 —— 那正是这个补丁要消灭的「静默」。
253
261
  if (completionState?.failure) base.lastTurnError = completionState.failure;
254
262
  else delete base.lastTurnError;
263
+ // 事实那一格同理:它通常在这一步才第一次算出来。
264
+ const foldedFact = lastTurnOf(ctx, s.sessionId);
265
+ if (foldedFact) base.lastTurn = foldedFact;
266
+ else delete base.lastTurn;
255
267
  base.lastMessage = preview.lastMessage;
256
268
  } catch {
257
269
  // 预览失败不影响标题与行数据。
@@ -48,6 +48,7 @@ const { createSelfCheck } = require('../mobile/self-check.cjs');
48
48
  const { checkForUpdate } = require('../update-registry.cjs');
49
49
  const { declaredSource } = require('../update-install.cjs');
50
50
  const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
51
+ const { recordSessionRead } = require('../mobile/session-last-turn.js');
51
52
 
52
53
  /** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
53
54
  const MAX_DESKTOP_READ_IDS = 200;
@@ -423,11 +424,21 @@ function registerControlPlaneRoutes(deps) {
423
424
  if (!ids.length) return sendJson(res, 200, { ok: true, cleared: 0 })
424
425
  if (!ctx) return sendJson(res, 200, { ok: true, cleared: 0 })
425
426
  let cleared = 0
426
- for (const id of ids) cleared += clearCompletionForAllDevices(ctx, id)
427
+ let advanced = 0
428
+ const at = Date.now()
429
+ for (const id of ids) {
430
+ cleared += clearCompletionForAllDevices(ctx, id, at)
431
+ // 「这一轮被读过了」还要记一笔**设备无关**的水位:手机侧的点现在由它自己按这份
432
+ // 水位算(见 mobile/session-last-turn.js)。只清按设备的提醒的话,桌面点开之后
433
+ // 手机上那个点永远不会灭 —— 那条提醒本来就不在手机读取的路径上。
434
+ if (recordSessionRead(ctx, id, at)) advanced += 1
435
+ }
427
436
  desktopHalf.noteDesktopRead({ ids: ids.length, cleared })
428
- // 有东西真被清掉才作废列表缓存:invalidateSessionList 会抬 generation,
429
- // 让镜像推送器重建清单并广播 device/list-changed(见 A3-14)。
430
- if (cleared > 0) invalidateSessionList(ctx)
437
+ // 作废列表缓存要抬 generation,让镜像推送器重建清单并广播 device/list-changed
438
+ // (见 A3-14)—— 只在**真有东西变了**时才调:已读水位进了下行清单,水位前进同样算
439
+ // 内容变了;但重复上报(客户端半边每 2s 报一批,多数都是旧的)绝不能每次都抬,
440
+ // 否则 App 的会话列表缓存会被永久压住。
441
+ if (cleared > 0 || advanced > 0) invalidateSessionList(ctx)
431
442
  return sendJson(res, 200, { ok: true, cleared })
432
443
  }) })
433
444
  // The process-level error ledger (see lib/host-guard.cjs). "Stop an unknown
@@ -39,6 +39,7 @@ const subagents = require('../mobile/subagents.js');
39
39
  const monitor = require('../mobile/monitor.js');
40
40
  const references = require('../mobile/references.js');
41
41
  const plugins = require('../mobile/plugins.js');
42
+ const pluginUpdate = require('../mobile/plugin-update.js');
42
43
  const uploads = require('../mobile/uploads.js');
43
44
  const pairing = require('../mobile/pairing.js');
44
45
  const { credentialsEntry } = require('../shared/credentials-entry.js');
@@ -271,6 +272,14 @@ function registerMobileRoutes(deps) {
271
272
  // 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
272
273
  route('/api/mobile/v1/plugins', plugins.handlePlugins)
273
274
 
275
+ // 插件自更新:APP 的「应用更新」页给这台宿主升级插件版本(见 mobile/plugin-update.js)。
276
+ // 与桌面控制面 `/api/relay/update/*` 同一套实现,差别只在入口:那三条 localOnly,手机
277
+ // 永远打不到,而手机上聊天时「有对话在跑」那道闸几乎总关着 —— 自动更新等于不跑。
278
+ // 能力面有意放开给已配对设备,来源判据(只放行 npm 装的)不放开,理由写在那个文件头。
279
+ route('/api/mobile/v1/plugin/update/status', pluginUpdate.handlePluginUpdateStatus)
280
+ route('/api/mobile/v1/plugin/update/check', pluginUpdate.handlePluginUpdateCheck)
281
+ route('/api/mobile/v1/plugin/update/apply', pluginUpdate.handlePluginUpdateApply)
282
+
274
283
  // 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
275
284
  // 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
276
285
  // 一律进不去。四条端点只做「收字节 → 落盘到 ~/.dsh/mobile-inbox/<日期>/<文件名>」,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.94",
3
+ "version": "0.1.95",
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",