dsh-mobile-plugin 0.2.1 → 0.3.1

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.
@@ -269,8 +269,21 @@ function touch(record, info = {}) {
269
269
  record.lastSeenAt = now;
270
270
  const channel = typeof info.channel === 'string' ? info.channel : '';
271
271
  if (channel) record.lastChannel = channel.slice(0, FIELD_MAX);
272
+ // 身份那几格(2026-10-26):名字 / 平台 / 系统版本 / App 版本由**每一次请求**带上来,
273
+ // 顺手刷新。它们以前只在配对那一刻写一次,于是「手机升到新版了、面板还报配对时的旧版本」,
274
+ // 而旧会话升级写进来的记录干脆什么身份都没有(面板只能画「未知设备」)。
275
+ // **只写这次真带了值的格子**:老版本 App 不带头,绝不能把已经有的名字冲成空。
276
+ const meta = deviceMeta(info.meta);
277
+ let metaChanged = false;
278
+ for (const key of Object.keys(meta)) {
279
+ if (record[key] !== meta[key]) {
280
+ record[key] = meta[key];
281
+ metaChanged = true;
282
+ }
283
+ }
272
284
  if (readOnly) return true;
273
- if (now - lastTouchFlushAt < TOUCH_FLUSH_MS) return true;
285
+ // 身份变了必须落盘(那是面板唯一能看到的来源);只刷新「刚连上过」才走老的一分钟节流。
286
+ if (!metaChanged && now - lastTouchFlushAt < TOUCH_FLUSH_MS) return true;
274
287
  lastTouchFlushAt = now;
275
288
  save();
276
289
  return true;
package/lib/impl.js CHANGED
@@ -24,6 +24,7 @@ const { authorized, authOutcome, presentedCredential } = require('./mobile/auth.
24
24
  const { readRawBody } = require('./shared/e2ee-request')
25
25
  const streamRegistry = require('./mobile/stream-registry.js')
26
26
  const { viaChannel } = require('./shared/via-header')
27
+ const { deviceMetaFromHeaders } = require('./shared/device-headers.js')
27
28
  const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
28
29
  const { createMirrorPusher } = require('./mirror/pusher.cjs')
29
30
  const { randomBytes } = require('node:crypto')
@@ -424,12 +425,11 @@ function apply(ctx) {
424
425
  readBody: (req) => readRawBody(req),
425
426
  }
426
427
  const guarded = createGuarded(ctx, authOutcome, activity.note, {
427
- // 鉴权通过后立刻记「最近连接 + 走的是哪条路」:两条代理都注入了 `x-dsh-via`。
428
428
  onAuthorized: (req) => {
429
429
  const presented = presentedCredential(req)
430
430
  if (!presented) return
431
431
  const record = credentials.lookup(presented, state.instanceId)
432
- if (record) credentials.touch(record, { channel: viaChannel(req) })
432
+ if (record) credentials.touch(record, { channel: viaChannel(req), meta: deviceMetaFromHeaders(req) })
433
433
  },
434
434
  envelope,
435
435
  })
@@ -113,6 +113,30 @@ async function handleCredentialUpgrade(req, res, deps) {
113
113
  });
114
114
  }
115
115
 
116
+ // 同一个 keyId 已经登记过一枚(上一次的响应可能在路上丢了,手机又发了一遍):
117
+ // **不再签发第二条** —— 手机留着手上这枚凭据、按 v2 签名,宿主按 keyId 验签,链路照样通。
118
+ // 没有这一条时每次重试都往库里堆一条记录(实测同一台设备 54 分钟里堆了 39 条,
119
+ // 面板上于是出现一堆同名的行)。
120
+ const known = presented && typeof store.findByKeyId === 'function'
121
+ ? store.findByKeyId(instanceId, secret.keyId)
122
+ : null;
123
+ if (known) {
124
+ store.touch(known, { channel: 'upgrade', meta: { ...meta, deviceId: known.deviceId } });
125
+ return sendJson(res, 200, {
126
+ ok: true,
127
+ upgraded: true,
128
+ proto: 2,
129
+ instanceId,
130
+ token: presented,
131
+ cookie: `dsh_lan=${presented}`,
132
+ deviceCredential: presented,
133
+ keyId: known.keyId,
134
+ deviceId: known.deviceId,
135
+ credentialExpiresAt: known.expiresAt || null,
136
+ serverTime: Date.now(),
137
+ });
138
+ }
139
+
116
140
  const issued = store.issue(instanceId, { ...meta, deviceId }, '', undefined, {
117
141
  deviceKey: secret.deviceKey,
118
142
  keyId: secret.keyId,
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机端右栏「文件浏览器」的目录列表:**文件与文件夹都列**,每条带类型、大小、修改时间。
5
+ *
6
+ * 与 `/directory/list` 的分工:那条是「为设备挑一个工作区目录」用的,走宿主 directoryPicker,
7
+ * 只列**能进去的目录**、每条只有 `{name,path,hidden}`(见 lib/mobile/workspaces.js)。右栏
8
+ * 要的是「这个目录里有什么」——文件也是内容,而且要知道类型、大小、修改时间,所以那条路
9
+ * 一个字段都对不上。老那条**一个字都不改**:选目录那条流程还挂在它上面。
10
+ *
11
+ * 这条直接读宿主自己的文件系统,**不经 directoryPicker 网关**。能力面没有因此变大:宿主
12
+ * 那份 browse 后端与本地兜底原语(lib/mobile/directory-browse.js)本来都是全文件系统范围、
13
+ * 没有白名单,`workspace.create` 也接受任意已存在目录;而「桌面 profile 宿主 + 手机远程连」
14
+ * 这一格宿主只服务 native,经网关只会拿到那句 `needs the browse capability`,右栏一个条目
15
+ * 都没有。策略原语(隐藏项标记、上限、面包屑)直接 import 那份副本,不抄第二份。
16
+ *
17
+ * 契约(App 侧 app/src/services/viewmodels/apiPaths.ts 的 directoryListing 同时按这份改):
18
+ * - `path` 可选、绝对路径;缺省 / 空串 / 全空白 = 被控端主目录(os.homedir())。相对路径
19
+ * 直接 400 —— 不拿宿主进程的当前目录去猜,那是另一个语义;
20
+ * - entries 名字升序(**大小写不敏感**),文件与文件夹混在一份里,分组由客户端做;
21
+ * - hidden = 名字以 '.' 开头,照旧标记但返回,过滤交给客户端;
22
+ * - kind 由 stat 得出(**跟随符号链接**):目录是 'folder',其余是 'file',指向目录的
23
+ * 链接算 'folder';
24
+ * - size(字节)/ modified(epoch 毫秒)**只对文件给**;单条 stat 失败(断链、权限)按
25
+ * 'file' 返回且不带这两格 —— 一个坏条目不许把整条列表带走;
26
+ * - 上限复用 directory-browse.js 的 DEFAULT_MAX_ENTRIES,超了截断并置 truncated = true;
27
+ * - crumbs 复用 directory-browse.js 的 ancestryCrumbs(根 → 当前),形状与选目录那条一致。
28
+ * 失败一律 400 + `{ ok:false, error:{ code, message } }`,code 取 bad-request / not-found /
29
+ * not-directory / permission,message 是简短英文,人话由 App 侧自己的字典翻。
30
+ */
31
+
32
+ const { opendir, stat } = require('node:fs/promises');
33
+ const { join, resolve } = require('node:path');
34
+
35
+ const { sendJson } = require('../shared/http-util');
36
+ const { DEFAULT_MAX_ENTRIES, ancestryCrumbs, fullyQualified, homeDirectory } = require('./directory-browse.js');
37
+
38
+ /** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
39
+ function fail(res, code, message) {
40
+ return sendJson(res, 400, { ok: false, error: { code, message } });
41
+ }
42
+
43
+ /** 任意抛出值 → 文案(abort reason 之类可以不是 Error)。 */
44
+ function messageOf(error) {
45
+ return error instanceof Error ? error.message : String(error);
46
+ }
47
+
48
+ /** 一次失败的 syscall → 契约里那四个码之一。 */
49
+ function codeOf(error) {
50
+ const raw = error && typeof error === 'object' ? error.code : undefined;
51
+ if (raw === 'EACCES' || raw === 'EPERM') return 'permission';
52
+ if (raw === 'ENOENT' || raw === 'ENOTDIR') return 'not-found';
53
+ return 'bad-request';
54
+ }
55
+
56
+ /**
57
+ * 名字升序、大小写不敏感(契约)。折成同一个主权重后仍视为相同的(`README.md` 与
58
+ * `readme.md`)用码位序兜底:否则比较器回 0,顺序就跟 readdir 的返回顺序走,同一个目录
59
+ * 两次请求可能给出不同顺序。
60
+ */
61
+ function compareNames(a, b) {
62
+ const folded = a.localeCompare(b, undefined, { sensitivity: 'base' });
63
+ if (folded !== 0) return folded;
64
+ return a < b ? -1 : a > b ? 1 : 0;
65
+ }
66
+
67
+ /** 一条列表行:stat 跟到最终目标上,目录为 folder,其余为 file(断了也照样是 file)。 */
68
+ async function listingRow(directory, name) {
69
+ const path = join(directory, name);
70
+ const row = { name, path, hidden: name.startsWith('.') };
71
+ let stats = null;
72
+ try {
73
+ stats = await stat(path);
74
+ } catch {
75
+ // 断链 / 权限:这一条按 file 返回、不带 size 与 modified,整条列表继续。
76
+ }
77
+ if (stats && stats.isDirectory()) {
78
+ row.kind = 'folder';
79
+ return row;
80
+ }
81
+ row.kind = 'file';
82
+ if (stats) {
83
+ row.size = stats.size;
84
+ row.modified = Math.round(stats.mtimeMs);
85
+ }
86
+ return row;
87
+ }
88
+
89
+ /**
90
+ * GET /api/mobile/v1/directory/listing?path=…(token,path 缺省 = 被控端主目录)
91
+ *
92
+ * 200:`{ ok:true, listing:{ path, home, crumbs, entries, truncated } }`。
93
+ * 名字必须整体排序(大小写不敏感)才谈得上「升序」,所以这里读全量名字再截断:上限约束的
94
+ * 是随响应下发的行数与后面逐条 stat 的次数,那两样才是这条路的成本。
95
+ */
96
+ async function handleDirectoryListing(_ctx, req, res) {
97
+ const url = new URL(req.url, 'http://x');
98
+ const asked = (url.searchParams.get('path') || '').trim();
99
+ if (asked && !fullyQualified(asked)) {
100
+ return fail(res, 'bad-request', `cannot list "${asked}": not a fully qualified path`);
101
+ }
102
+ const home = homeDirectory();
103
+ const target = resolve(asked || home);
104
+ let info;
105
+ try {
106
+ // 先 stat 一次:它把「不存在」「不是目录」「权限不够」三件事分开,也跟随符号链接
107
+ //(指向目录的链接按目录列)。opendir 在非目录上的报错各平台不一致,分不出这三种。
108
+ info = await stat(target);
109
+ } catch (error) {
110
+ return fail(res, codeOf(error), `cannot list ${target}: ${messageOf(error)}`);
111
+ }
112
+ if (!info.isDirectory()) {
113
+ return fail(res, 'not-directory', `cannot list ${target}: not a directory`);
114
+ }
115
+
116
+ const names = [];
117
+ try {
118
+ const level = await opendir(target);
119
+ try {
120
+ for (;;) {
121
+ const dirent = await level.read();
122
+ if (dirent === null) break;
123
+ names.push(dirent.name);
124
+ }
125
+ } finally {
126
+ await level.close();
127
+ }
128
+ } catch (error) {
129
+ return fail(res, codeOf(error), `cannot list ${target}: ${messageOf(error)}`);
130
+ }
131
+
132
+ names.sort(compareNames);
133
+ const truncated = names.length > DEFAULT_MAX_ENTRIES;
134
+ const kept = truncated ? names.slice(0, DEFAULT_MAX_ENTRIES) : names;
135
+ const entries = [];
136
+ for (const name of kept) entries.push(await listingRow(target, name));
137
+
138
+ sendJson(res, 200, {
139
+ ok: true,
140
+ listing: { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated },
141
+ });
142
+ }
143
+
144
+ module.exports = { handleDirectoryListing };
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * 这条只回监控需要的那几格:在不在跑、刚完成没有、在等谁回话、上一轮是不是跑挂的、标题。
12
12
  * 并支持 `?rev=` 条件刷新:状态位与上次一模一样时回一句 `unchanged`,连 items 都不带
13
- * (稳态下这一发是几十字节)。
13
+ * (稳态下这一发是几十字节)。再带上 `?wait=秒` 时,条件刷新的那一发**先挂住**,等状态位
14
+ * 真的变了才回 —— 安卓后台服务靠它把「定时去问」变成「一有变化立刻送到」。
14
15
  *
15
16
  * 完成态仍以 completion 状态机为准,与 `/sessions` 用同一本账:常态下这里**不读 history**,
16
17
  * 只有「跑过、现在不在跑、结局还没定」的那几条才读一次 —— 那是唯一能把 failedTurn 折出来
@@ -22,6 +23,8 @@
22
23
 
23
24
  const { sendJson } = require('../shared/http-util');
24
25
  const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
26
+ const { listSignal } = require('../shared/list-signal.js');
27
+ const { sessionSignal } = require('../shared/session-signal.js');
25
28
  const { callSessions } = require('./rpc.js');
26
29
  const { unwrap } = require('./envelope.js');
27
30
  const { listTitle } = require('./history-derive.js');
@@ -29,6 +32,7 @@ const {
29
32
  canonicalDeviceKey, deletedSessionIdsOf, completionGenerationOf,
30
33
  } = require('./session-cache.js');
31
34
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
35
+ const { lastTurnOf } = require('./session-last-turn.js');
32
36
  const { sessionPreviewFor } = require('./session-preview.js');
33
37
  const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
34
38
 
@@ -39,16 +43,46 @@ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags
39
43
  * lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
40
44
  * 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
41
45
  */
42
- function monitorItem(summary, state) {
46
+ function monitorItem(summary, state, fact) {
47
+ const running = summary.running === true;
48
+ // 完成/失败优先用**上一轮事实**(`session-last-turn`),不再等 `state.completed`。
49
+ //
50
+ // 为什么:那个按设备记账的完成态要靠「这一次真的读回了 history」才折得出来,而
51
+ // 「刚跑完」那一刻宿主的镜像推送早把同一份预览按新指纹写进缓存了 —— 监控这一侧
52
+ // 拿到的是缓存命中(events 为空)、永远折不出来,`completed` 就一直 false,后台
53
+ // 一条提醒都不会响(2026-10-25 真机:宿主日志里全是「preview cache-hit」)。
54
+ // 事实那本账由 turn/end 事件当场记下,不依赖预览缓存,所以拿它判完成。
55
+ //
56
+ // 已读水位仍然生效:这一轮结束在「这台手机读过」之前就不算提醒。
57
+ const clearedAt = typeof state?.clearedAt === 'number' ? state.clearedAt : null;
58
+ const settledForThisDevice = fact != null && (clearedAt === null || fact.endedAt > clearedAt);
59
+ const completed = fact
60
+ ? (!running && fact.status === 'completed' && settledForThisDevice)
61
+ : (state ? state.completed === true : false);
43
62
  const item = {
44
63
  sessionId: summary.sessionId,
45
64
  title: listTitle(summary),
46
- running: summary.running === true,
47
- completed: state ? state.completed === true : false,
65
+ running,
66
+ completed,
48
67
  };
49
68
  const pending = pendingInteractionOf(summary.sessionId, summary);
50
69
  if (pending) item.pendingInteraction = pending;
51
- if (state && state.failure) item.lastTurnError = state.failure;
70
+ const failure = fact
71
+ ? (fact.status === 'failed' && settledForThisDevice
72
+ ? { turn: fact.turn, code: fact.code ?? null, message: fact.message ?? null }
73
+ : null)
74
+ : (state && state.failure ? state.failure : null);
75
+ if (failure) item.lastTurnError = failure;
76
+ // 上一轮事实整份透给原生:它按「轮次前进」判完成 / 失败,比 completed 翻面可靠
77
+ // (两次轮询之间跑完一整轮时 completed 可能一直是 true)。字段与列表那条同源。
78
+ if (fact) {
79
+ item.lastTurn = {
80
+ turn: fact.turn,
81
+ endedAt: fact.endedAt,
82
+ status: fact.status,
83
+ ...(fact.status === 'failed' ? { code: fact.code ?? null, message: fact.message ?? null } : {}),
84
+ };
85
+ }
52
86
  return item;
53
87
  }
54
88
 
@@ -68,7 +102,10 @@ async function buildMonitorState(ctx, deviceKey) {
68
102
  const generation = completionGenerationOf(ctx, deviceKey);
69
103
  const deleted = deletedSessionIdsOf(ctx);
70
104
  const { archived } = await workspaceSessionFlags(ctx);
105
+ // 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
106
+ // 会在后台弹一堆「子代理跑完了」(2026-10-26 真机日志里四十多个 UUID 会话都是这种)。
71
107
  const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
108
+ && s.origin !== 'subagent'
72
109
  && !deleted.has(s.sessionId) && !archived.has(s.sessionId));
73
110
 
74
111
  // 状态机跟着这份清单走:归档 / 删除的会话在这里被踢掉,与完整列表同一口径。
@@ -85,51 +122,129 @@ async function buildMonitorState(ctx, deviceKey) {
85
122
  await Promise.all(unsettled.map(async (summary) => {
86
123
  try {
87
124
  const preview = await sessionPreviewFor(ctx, summary);
88
- if (!preview.events.length) return;
125
+ if (!preview.events.length) {
126
+ return;
127
+ }
89
128
  // 读到这些历史的这一刻,别处可能刚发起一次写后刷新(它会 bump 代次);
90
129
  // 用旧快照去确认结局等于把「还在跑的一轮」写成已完成。
91
- if (completionGenerationOf(ctx, deviceKey) !== generation) return;
130
+ if (completionGenerationOf(ctx, deviceKey) !== generation) {
131
+ return;
132
+ }
133
+ const before = states.get(summary.sessionId)?.completed;
92
134
  confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false, ctx, summary.sessionId);
93
- } catch {
135
+ } catch (error) {
94
136
  // 这一轮没读到就原样留着:判定条件(没完成、没失败)下一轮仍然成立,会再试。
95
137
  }
96
138
  }));
97
139
  }
98
140
 
99
- const items = live.map((summary) => monitorItem(summary, states.get(summary.sessionId)));
141
+ const items = live.map((summary) => monitorItem(
142
+ summary,
143
+ states.get(summary.sessionId),
144
+ lastTurnOf(ctx, summary.sessionId),
145
+ ));
100
146
  // 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
101
147
  // 条件刷新等于白做。监控视图不关心顺序。
102
148
  items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
103
149
  return { items, revision: stableRevision(items) };
104
150
  }
105
151
 
106
- /** GET /api/mobile/v1/monitor?rev=…(token) */
107
- async function handleMonitor(ctx, req, res) {
108
- const deviceKey = canonicalDeviceKey(req);
109
- const url = new URL(req.url, 'http://x');
110
- const rev = (url.searchParams.get('rev') || '').trim();
152
+ /** 一次挂起的上限。手机侧每次挂 20 秒,再长没有意义:断线重连比占着一条连接便宜。 */
153
+ const WAIT_MAX_MS = 25_000;
154
+
155
+ /**
156
+ * 挂在「有东西变了」的信号上,最多等 timeoutMs。
157
+ *
158
+ * 只有两条总线会唤醒它:清单变了(list-signal)、某个会话的正文水位涨了(session-signal)
159
+ * —— 一轮跑完、跑挂、开始等你回话,都会至少踩中其中一条。两条都是模块级单例,生产端在
160
+ * 镜像推送链上,不需要认识这条请求。
161
+ *
162
+ * 为什么用总线而不是自己轮着算一遍:这里的等待必须是**事件驱动**的,自己定时重算就成了
163
+ * 换了个地方的轮询,而这条接口存在的全部理由就是让手机不必轮询。
164
+ */
165
+ function waitForChange(timeoutMs, res) {
166
+ return new Promise((resolve) => {
167
+ let settled = false;
168
+ let releaseList = () => {};
169
+ let releaseSession = () => {};
170
+ const timer = setTimeout(() => finish(), timeoutMs);
171
+ function finish() {
172
+ if (settled) return;
173
+ settled = true;
174
+ clearTimeout(timer);
175
+ releaseList();
176
+ releaseSession();
177
+ resolve();
178
+ }
179
+ releaseList = listSignal.onBump(finish);
180
+ releaseSession = sessionSignal.onBump(finish);
181
+ // 手机切网 / 进程被杀时连接会断,没必要再替它挂满整个窗口。
182
+ if (res && typeof res.once === 'function') res.once('close', finish);
183
+ });
184
+ }
111
185
 
186
+ /** 算一次监控视图,把「宿主出错 / 形状异常」也收成一个可下发的响应。 */
187
+ async function buildMonitorView(ctx, deviceKey) {
112
188
  let built;
113
189
  try {
114
190
  built = await buildMonitorState(ctx, deviceKey);
115
191
  } catch (error) {
116
- return sendJson(res, 500, {
117
- ok: false,
118
- error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' },
119
- });
192
+ return {
193
+ status: 500,
194
+ body: { ok: false, error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' } },
195
+ };
120
196
  }
121
- if (built.error) return sendJson(res, 400, { ok: false, error: built.error });
197
+ if (built.error) return { status: 400, body: { ok: false, error: built.error } };
122
198
  if (built.degraded) {
123
199
  // 与 /sessions 同一个码:中转见到 5xx 会退回上一份好副本,手机那侧因此是「没收到新东西」
124
200
  // 而不是「一台会话都没有」。
125
- return sendJson(res, 500, {
126
- ok: false,
127
- error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' },
128
- });
201
+ return {
202
+ status: 500,
203
+ body: { ok: false, error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' } },
204
+ };
205
+ }
206
+ return { status: 200, body: { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items } };
207
+ }
208
+
209
+ /**
210
+ * GET /api/mobile/v1/monitor?rev=…[&wait=秒](token)
211
+ *
212
+ * `wait` 是实时那条路:带上它,且状态位与 `rev` 一字不差时,这次请求**先不回**,
213
+ * 挂到有变化(或挂满)才回。安卓那条前台服务在后台就挂着它,于是「一轮跑完」到
214
+ * 「手机响」只差一次本地网络往返,不再等下一拍轮询。
215
+ *
216
+ * 只挂一次:醒来之后不管变没变都如实回(变没变由 revision 说了算),由手机自己决定
217
+ * 要不要再挂。这样这里没有循环、没有重试,断线也不会在宿主侧留下一个转不停的请求。
218
+ */
219
+ async function handleMonitor(ctx, req, res) {
220
+ const deviceKey = canonicalDeviceKey(req);
221
+ const url = new URL(req.url, 'http://x');
222
+ const rev = (url.searchParams.get('rev') || '').trim();
223
+ const waitSeconds = Number(url.searchParams.get('wait') || 0) || 0;
224
+ // 没有 rev 就不挂:那不是「确认一下有没有变」,而是「给我一份完整状态」,立刻回。
225
+ const waitMs = rev ? Math.max(0, Math.min(WAIT_MAX_MS, Math.round(waitSeconds * 1000))) : 0;
226
+
227
+ const first = await buildMonitorView(ctx, deviceKey);
228
+ if (first.status !== 200) {
229
+ return sendJson(res, first.status, first.body);
129
230
  }
231
+ const unchanged = unchangedListBody(first.body, rev);
232
+ if (!unchanged) return sendJson(res, 200, first.body);
233
+ if (waitMs <= 0) return sendJson(res, 200, unchanged);
130
234
 
131
- const body = { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items };
132
- sendJson(res, 200, unchangedListBody(body, rev) || body);
235
+ // 头必须在挂起**之前**发出去。走中转那条路上,「等响应头」只有 7 秒
236
+ // (lib/relay/tunnel.js 的 UPSTREAM_HEADER_TIMEOUT_MS),不先发头,这次挂起会被
237
+ // 当成死请求掐掉,手机收到的是一次网络错误而不是一次「没变化」。发完头再等,
238
+ // 中转那边看的是「响应空转 30 秒」,20 秒的挂起正好在它之内。
239
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', vary: 'accept-encoding' });
240
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
241
+ await waitForChange(waitMs, res);
242
+ // 手机在这段时间里断了(切网 / 进程被杀):别为一个没人收的响应再算一遍。
243
+ if (res.destroyed || res.writableEnded) return;
244
+ const again = await buildMonitorView(ctx, deviceKey);
245
+ const body = again.status === 200 ? (unchangedListBody(again.body, rev) || again.body) : again.body;
246
+ // 头已经出去了,不能再走 sendJson(它会再写一次头):这里只补正文。
247
+ try { res.end(JSON.stringify(body)); } catch { /* 连接已断开 */ }
133
248
  }
134
249
 
135
250
  module.exports = { handleMonitor, buildMonitorState, monitorItem };
@@ -14,6 +14,13 @@
14
14
  */
15
15
 
16
16
  const { lastTurnFactsOf } = require('./session-cache.js');
17
+ const readReceipts = require('../read-receipt.cjs');
18
+
19
+ /**
20
+ * 本进程的起点:只有它**之前**留下的已读水位才需要从盘上补回来(见 `lastTurnOf`)。
21
+ * 本进程内的读一律由 `recordSessionRead` 当场写进内存那张表,不必再查盘。
22
+ */
23
+ const PROCESS_STARTED_AT = Date.now();
17
24
 
18
25
  /**
19
26
  * 一轮的结局:纯计算,不碰水位、不碰设备。
@@ -86,6 +93,9 @@ function recordSessionRead(ctx, sessionId, at = Date.now()) {
86
93
  const facts = lastTurnFactsOf(ctx);
87
94
  const previous = facts.get(sessionId) || {};
88
95
  const current = typeof previous.readAt === 'number' ? previous.readAt : 0;
96
+ // 同时落一份到盘上(lib/read-receipt.cjs):内存这张表随进程消失,而 App 侧栏的绿点
97
+ // 靠的正是这里的水位 —— 桌面读过之后宿主一重启,同一轮又被算成未读(2026-10-26 真机)。
98
+ try { readReceipts.recordRead(sessionId, value); } catch { /* 台账是旁路 */ }
89
99
  if (current >= value) return false;
90
100
  facts.set(sessionId, { ...previous, readAt: value });
91
101
  return true;
@@ -96,11 +106,21 @@ function recordSessionRead(ctx, sessionId, at = Date.now()) {
96
106
  *
97
107
  * 只回带 `turn` 的那些:表里也可能只存着一个已读水位(读过、但插件重启后还没折过
98
108
  * 那一轮的结局),那不是「上一轮发生了什么」,不该下发给 App。
109
+ *
110
+ * 进程刚起来时那张表是空的,「桌面读过」只留在盘上那本台账里 —— 不补回来的话 App 会按
111
+ * 「没人读过」重算,早就读过的一轮又亮成绿点。只补上一次进程留下的那些(本进程里的读
112
+ * 已经在表里了),补到的值写回表里,让后面的折叠沿用。
99
113
  */
100
114
  function lastTurnOf(ctx, sessionId) {
101
- const fact = lastTurnFactsOf(ctx).get(sessionId);
115
+ const facts = lastTurnFactsOf(ctx);
116
+ const fact = facts.get(sessionId);
102
117
  if (!fact || typeof fact.turn !== 'number') return undefined;
103
- return fact;
118
+ const restored = readReceipts.readAtOf(sessionId);
119
+ const current = typeof fact.readAt === 'number' ? fact.readAt : 0;
120
+ if (!(restored > 0 && restored < PROCESS_STARTED_AT) || current >= restored) return fact;
121
+ const next = { ...fact, readAt: restored };
122
+ facts.set(sessionId, next);
123
+ return next;
104
124
  }
105
125
 
106
126
  module.exports = { settleOutcome, recordLastTurn, recordSessionRead, lastTurnOf };
@@ -24,7 +24,6 @@ const {
24
24
  const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
25
25
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
26
26
  const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
27
- const readReceipt = require('../read-receipt.cjs');
28
27
  const { recordSessionRead } = require('./session-last-turn.js');
29
28
 
30
29
  /**
@@ -215,17 +214,11 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
215
214
  markCompletionRead(ctx, deviceKey, body.sessionId);
216
215
  // 「这一轮被读过了」还要记一笔**设备无关**的水位:列表要喂镜像快照(全局一份),
217
216
  // 上面那份按设备的提醒进不了下行清单,App 那边根本看不到这次已读(见 session-last-turn.js)。
217
+ // 这一笔同时会落进盘上的已读台账(桌面端绿点靠它),所以这里不再单独写一遍。
218
218
  recordSessionRead(ctx, body.sessionId);
219
219
  // Invalidate the cache and any build already reading history. Its generation
220
220
  // check prevents a stale build from restoring `completed` after this ack.
221
221
  invalidateSessionList(ctx, deviceKey);
222
- // 桌面端的未读绿点不在宿主里(它在浏览器渲染进程的内存里),这里只记一条水位,
223
- // 由插件的客户端半边拉走、在桌面页面里就地清掉。写盘失败不该让「已读」这个动作失败。
224
- try {
225
- readReceipt.recordRead(body.sessionId);
226
- } catch {
227
- // 回执台账只是尽力而为的旁路,读不到/写不动都不影响本次已读本身。
228
- }
229
222
  sendJson(res, 200, { ok: true, accepted: true });
230
223
  }
231
224