dsh-mobile-plugin 0.1.100 → 0.2.0

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.
Files changed (56) hide show
  1. package/README.md +14 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +405 -84
  3. package/lib/client.js +31 -8
  4. package/lib/http/routing.js +165 -9
  5. package/lib/impl.js +79 -5
  6. package/lib/index.js +1 -1
  7. package/lib/mirror/pusher.cjs +35 -4
  8. package/lib/mobile/approvals.js +62 -15
  9. package/lib/mobile/auth.js +46 -26
  10. package/lib/mobile/credential-upgrade.js +129 -0
  11. package/lib/mobile/entry-window.js +32 -0
  12. package/lib/mobile/events.js +14 -4
  13. package/lib/mobile/extensions.js +279 -0
  14. package/lib/mobile/history-derive.js +159 -0
  15. package/lib/mobile/host-bridge/event-synthesis.js +40 -1
  16. package/lib/mobile/pairing.js +150 -64
  17. package/lib/mobile/plugin-self.js +187 -0
  18. package/lib/mobile/push-notify-route.js +37 -0
  19. package/lib/mobile/push-notify.js +84 -0
  20. package/lib/mobile/questions.js +75 -10
  21. package/lib/mobile/relay-control.js +534 -0
  22. package/lib/mobile/session-cache.js +19 -1
  23. package/lib/mobile/session-file.js +78 -4
  24. package/lib/mobile/session-list-shortcut.js +38 -0
  25. package/lib/mobile/session-model-options.js +3 -1
  26. package/lib/mobile/session-models.js +15 -3
  27. package/lib/mobile/session-send.js +21 -3
  28. package/lib/mobile/session-snapshot.js +12 -11
  29. package/lib/mobile/sessions.js +20 -0
  30. package/lib/mobile/stats-collect.cjs +55 -12
  31. package/lib/mobile/stats-ledger.cjs +140 -21
  32. package/lib/mobile/stream-registry.js +73 -0
  33. package/lib/mobile/stream.js +103 -18
  34. package/lib/mobile/uploads.js +79 -3
  35. package/lib/mobile/workspaces.js +39 -8
  36. package/lib/panel/index.html +11 -11
  37. package/lib/relay/core.js +25 -2
  38. package/lib/relay/lan-proxy.js +283 -122
  39. package/lib/relay/protocol.js +4 -2
  40. package/lib/relay/tunnel.js +20 -14
  41. package/lib/routes/control-plane.js +135 -9
  42. package/lib/routes/mobile-api.js +87 -7
  43. package/lib/shared/e2ee-pair.js +91 -0
  44. package/lib/shared/e2ee-plain.js +79 -0
  45. package/lib/shared/e2ee-request.js +331 -0
  46. package/lib/shared/e2ee.js +173 -0
  47. package/lib/shared/http-util.js +27 -4
  48. package/lib/shared/list-signal.js +16 -0
  49. package/lib/shared/pair-throttle.js +150 -0
  50. package/lib/shared/sse-write.js +16 -5
  51. package/lib/shared/via-header.js +65 -0
  52. package/lib/shell/runtime.js +147 -42
  53. package/lib/shell/self-check-after-swap.js +93 -0
  54. package/lib/shell/updater.js +36 -2
  55. package/lib/sidecar-facade-worker.cjs +70 -6
  56. package/package.json +3 -2
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 会话侧的按 ctx 隔离状态:十一张表,一个入口。
4
+ * 会话侧的按 ctx 隔离状态:十三张表,一个入口。
5
5
  *
6
6
  * 这些表此前全是 sessions.js 的模块级 WeakMap。它们之所以必须按 ctx 隔离,是因为
7
7
  * 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
@@ -42,6 +42,10 @@ const sendIdempotency = new WeakMap();
42
42
  // 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,60s)。
43
43
  const writeIdempotency = new WeakMap();
44
44
 
45
+ // 工作区列表的条件刷新短路:上次构建留下的 revision / 生成时刻 / 当时的信号代号。
46
+ // 与 sessionListResponseCache 同一类东西(列表刚构建完的那份是谁),只是按工作区算。
47
+ const workspaceShortcutStates = new WeakMap();
48
+
45
49
  /** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
46
50
  function canonicalDeviceKey(req, fallback = 'legacy') {
47
51
  const value = req?.headers?.['x-dsh-device'];
@@ -224,6 +228,19 @@ function clearCompletionForAllDevices(ctx, sessionId, at = Date.now()) {
224
228
  return cleared;
225
229
  }
226
230
 
231
+ /**
232
+ * 工作区列表短路的记忆体:客户端 rev、上次生成时刻、当时的信号 revision、上次构建时刻。
233
+ * 与其它表同样按 ctx 隔离 —— 不同宿主的工作区快照互不相干。
234
+ */
235
+ function workspaceShortcutStateOf(ctx) {
236
+ let state = workspaceShortcutStates.get(ctx);
237
+ if (!state) {
238
+ state = { lastRevision: '', lastGeneratedAt: 0, lastSignalRevision: '', lastBuiltAt: 0 };
239
+ workspaceShortcutStates.set(ctx, state);
240
+ }
241
+ return state;
242
+ }
243
+
227
244
  function completionGenerationOf(ctx, deviceKey) {
228
245
  let generations = completionGenerations.get(ctx);
229
246
  if (!generations) {
@@ -336,6 +353,7 @@ module.exports = {
336
353
  markCompletionRead,
337
354
  clearCompletionForAllDevices,
338
355
  completionGenerationOf,
356
+ workspaceShortcutStateOf,
339
357
  bumpCompletionGeneration,
340
358
  sendIdempotencyStateOf,
341
359
  writeIdempotencyStateOf,
@@ -171,12 +171,33 @@ async function handleSessionFile(ctx, req, res) {
171
171
  }
172
172
 
173
173
  const name = path.basename(target);
174
- res.writeHead(200, {
174
+ const header = {
175
175
  'content-type': mediaTypeOf(target),
176
- 'content-length': String(info.size),
177
176
  'content-disposition': dispositionOf(name),
178
177
  'cache-control': 'no-store',
179
- });
178
+ };
179
+
180
+ // 协议 v2:分块信封(§8.3(6))。
181
+ //
182
+ // 套一次性 AEAD 意味着宿主得先把整份读进内存 —— 单文件上限 512MB,那条路不能走。
183
+ // 所以改成固定分块流式写:宿主仍然边读边发,内存占用还是一块的大小;App 那边今天本来
184
+ // 就是整份读进 arrayBuffer(上限 200MB),按长度字段切块、逐块解即可,不需要流式解密栈。
185
+ const opened = req && req.__dshE2ee;
186
+ if (opened && opened.keys && opened.req) {
187
+ res.writeHead(200, {
188
+ ...header,
189
+ 'content-type': 'application/x-dsh-e2ee-chunks',
190
+ // 这份字节不是原文,`content-length` 得等写完才知道,改用 chunked。
191
+ 'transfer-encoding': 'chunked',
192
+ 'x-dsh-proto': '2',
193
+ // 真实类型单独给:App 解密之后要拿它决定怎么预览。
194
+ 'x-dsh-content-type': header['content-type'],
195
+ });
196
+ await writeEncryptedFile(req, res, target, opened);
197
+ return;
198
+ }
199
+
200
+ res.writeHead(200, { ...header, 'content-length': String(info.size) });
180
201
  if (info.size === 0) return res.end();
181
202
 
182
203
  const stream = fs.createReadStream(target);
@@ -189,4 +210,57 @@ async function handleSessionFile(ctx, req, res) {
189
210
  }
190
211
  }
191
212
 
192
- module.exports = { handleSessionFile, SESSION_FILE_MAX_BYTES };
213
+ /**
214
+ * 按块加密写出去(§8.3(6))。
215
+ *
216
+ * 每块形状:`[4 字节大端长度][12 字节 nonce][密文 + 16 字节 tag]`,长度字段写的是
217
+ * **「密文 + tag」的长度**(不含 nonce 与长度字段本身)。块大小 64KB,最后一块可以更短。
218
+ *
219
+ * AAD 绑这条请求的路径、请求 nonce 与块序号 —— 块序号让中转不能把别的文件(或这一份的
220
+ * 别的块)的字节搬进来,也不能重排或截断而让 App 察觉不到。
221
+ */
222
+ async function writeEncryptedFile(req, res, target, opened) {
223
+ const e2ee = require('../shared/e2ee');
224
+ const path0 = e2ee.signedPath(req);
225
+ const requestNonce = opened.req.nonce;
226
+ let seq = 0;
227
+ let pending = Buffer.alloc(0);
228
+ let closed = false;
229
+ res.on('close', () => { closed = true; });
230
+ const stream = fs.createReadStream(target, { highWaterMark: FILE_CHUNK_BYTES });
231
+ try {
232
+ for await (const chunk of stream) {
233
+ if (closed) break;
234
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
235
+ while (pending.length >= FILE_CHUNK_BYTES) {
236
+ const block = pending.subarray(0, FILE_CHUNK_BYTES);
237
+ pending = pending.subarray(FILE_CHUNK_BYTES);
238
+ seq += 1;
239
+ writeBlock(res, e2ee, opened.keys.down, block, e2ee.fileAad(path0, requestNonce, seq));
240
+ }
241
+ }
242
+ // 尾块(可能不足一块);空文件也给一块,否则 App 拿不到「这是个空文件」的确认。
243
+ if (!closed) {
244
+ seq += 1;
245
+ writeBlock(res, e2ee, opened.keys.down, pending, e2ee.fileAad(path0, requestNonce, seq));
246
+ }
247
+ if (!closed) res.end();
248
+ } catch {
249
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
250
+ }
251
+ }
252
+
253
+ /** 写一块:`[4 字节长度][nonce][密文+tag]`,长度写的是后两段之外的密文长度。 */
254
+ function writeBlock(res, e2ee, key, block, aad) {
255
+ const sealed = e2ee.seal(key, block, aad);
256
+ const buf = Buffer.from(sealed);
257
+ const cipherLen = buf.length - e2ee.NONCE_BYTES;
258
+ const head = Buffer.alloc(4);
259
+ head.writeUInt32BE(cipherLen, 0);
260
+ res.write(Buffer.concat([head, buf]));
261
+ }
262
+
263
+ /** 交付文件的分块大小。64KB:一块一次 AEAD,太小会让每块的 16+12 字节固定开销占比变高。 */
264
+ const FILE_CHUNK_BYTES = 64 * 1024;
265
+
266
+ module.exports = { handleSessionFile, SESSION_FILE_MAX_BYTES, FILE_CHUNK_BYTES, writeEncryptedFile };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `GET /sessions?rev=` 的条件刷新:上一次构建留下的「这份清单是谁」。
5
+ *
6
+ * 只服务短路判断,不拿它当数据源 —— `cache.value` 那条路仍然按原来的规则走。四样
7
+ * 一起对:客户端手上的 revision、这台设备的完成态代号、清单变更信号、上次构建时刻。
8
+ * 单拎出来是因为 sessions.js 只剩列表 handler 的骨架,这类「记东西 / 对东西」的纯
9
+ * 逻辑摆在 handler 中间会让状态和流程混在一起。
10
+ *
11
+ * 5 分钟上限只是兜底:`listSignal.watching()` 为假时根本不走短路,正常路径上信号
12
+ * 一涨就会被判失效,用不到这个上限。
13
+ */
14
+
15
+ const { listSignal } = require('../shared/list-signal.js');
16
+
17
+ const LIST_SHORTCUT_MAX_AGE_MS = 5 * 60 * 1000;
18
+
19
+ /** 命中就回一份 `unchanged` 身体,否则 null(交回原路重建)。 */
20
+ function listShortcut(cache, rev, generation) {
21
+ if (!rev || typeof cache.lastRevision !== 'string' || cache.lastRevision !== rev) return null;
22
+ if (cache.lastGeneration !== generation) return null;
23
+ if (cache.lastSignalRevision !== listSignal.snapshot().revision) return null;
24
+ if (!(Date.now() - (cache.lastBuiltAt || 0) <= LIST_SHORTCUT_MAX_AGE_MS)) return null;
25
+ return { ok: true, revision: rev, generatedAt: cache.lastGeneratedAt };
26
+ }
27
+
28
+ /** 构建成功之后记下这一份:revision、生成时刻、当时的信号与完成态代号。 */
29
+ function rememberListBuild(cache, body, generation) {
30
+ if (!body || typeof body.revision !== 'string' || !body.revision) return;
31
+ cache.lastRevision = body.revision;
32
+ cache.lastGeneratedAt = body.generatedAt;
33
+ cache.lastGeneration = generation;
34
+ cache.lastSignalRevision = listSignal.snapshot().revision;
35
+ cache.lastBuiltAt = Date.now();
36
+ }
37
+
38
+ module.exports = { listShortcut, rememberListBuild };
@@ -50,6 +50,8 @@ const OPTION_SOURCES = [
50
50
 
51
51
  const MAX_ROWS = 12;
52
52
  const MAX_CHOICES = 32;
53
+ /** 选项说明和模型描述一样,选择器里只画一行;正文没必要按 4KB 下发。 */
54
+ const MAX_CHOICE_DESCRIPTION = 200;
53
55
 
54
56
  function sourceFor(provider) {
55
57
  return OPTION_SOURCES.find((source) => source.provider === provider);
@@ -124,7 +126,7 @@ function safeModelOptions(value) {
124
126
  .map((choice) => ({
125
127
  id: choice.id.slice(0, 256),
126
128
  name: choice.name.slice(0, 512),
127
- ...(typeof choice.description === 'string' ? { description: choice.description.slice(0, 4096) } : {}),
129
+ ...(typeof choice.description === 'string' ? { description: choice.description.slice(0, MAX_CHOICE_DESCRIPTION) } : {}),
128
130
  })),
129
131
  }));
130
132
  }
@@ -22,6 +22,18 @@ function modelText(value, max) {
22
22
  return typeof value === 'string' ? value.slice(0, max) : '';
23
23
  }
24
24
 
25
+ /**
26
+ * 模型 / 推理档位描述的下发上限。
27
+ *
28
+ * 选择器里这两处都是 `numberOfLines={1}`,按屏宽也就显示二三十个字;宿主那边的描述
29
+ * 却有写到几千字的(能力说明、价格表都有)。整份目录每次启动、每次进对话都会传一遍,
30
+ * 留着 8KB 的上限等于给每个模型背一段永远读不到的正文。
31
+ */
32
+ const MODEL_DESCRIPTION_MAX = 200;
33
+ const EFFORT_DESCRIPTION_MAX = 200;
34
+ /** 失败原因只在诊断里看,同样不需要整篇。 */
35
+ const MODEL_FAILURE_MESSAGE_MAX = 512;
36
+
25
37
  function safeModelsValue(value) {
26
38
  if (!value || typeof value !== 'object') return {};
27
39
  const result = {};
@@ -63,7 +75,7 @@ function safeModelsValue(value) {
63
75
  .map((model) => ({
64
76
  id: modelText(model.id, 512),
65
77
  name: modelText(model.name, 512),
66
- ...(typeof model.description === 'string' ? { description: model.description.slice(0, 8192) } : {}),
78
+ ...(typeof model.description === 'string' ? { description: model.description.slice(0, MODEL_DESCRIPTION_MAX) } : {}),
67
79
  ...(model.reasoning && typeof model.reasoning === 'object' && Array.isArray(model.reasoning.efforts)
68
80
  ? { reasoning: {
69
81
  efforts: model.reasoning.efforts
@@ -72,7 +84,7 @@ function safeModelsValue(value) {
72
84
  .map((effort) => ({
73
85
  id: modelText(effort.id, 256),
74
86
  name: modelText(effort.name, 512),
75
- ...(typeof effort.description === 'string' ? { description: effort.description.slice(0, 4096) } : {}),
87
+ ...(typeof effort.description === 'string' ? { description: effort.description.slice(0, EFFORT_DESCRIPTION_MAX) } : {}),
76
88
  })),
77
89
  ...(typeof model.reasoning.defaultEffort === 'string' ? { defaultEffort: modelText(model.reasoning.defaultEffort, 256) } : {}),
78
90
  } }
@@ -89,7 +101,7 @@ function safeModelsValue(value) {
89
101
  if (Array.isArray(value.failures)) result.failures = value.failures.slice(0, 64).map((failure) => ({
90
102
  ...(typeof failure?.id === 'string' ? { id: modelText(failure.id, 256) } : {}),
91
103
  ...(typeof failure?.name === 'string' ? { name: modelText(failure.name, 512) } : {}),
92
- ...(typeof failure?.message === 'string' ? { message: modelText(failure.message, 8192) } : {}),
104
+ ...(typeof failure?.message === 'string' ? { message: modelText(failure.message, MODEL_FAILURE_MESSAGE_MAX) } : {}),
93
105
  }));
94
106
  if (Number.isSafeInteger(value.revision)) result.revision = value.revision;
95
107
  return result;
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const { sendJson, readJsonBody } = require('../shared/http-util');
16
+ const { inlineUploadedImageBlocks } = require('./uploads.js');
16
17
  const { unwrap } = require('./envelope.js');
17
18
  const { callSessions, mobilePromptRpcId } = require('./rpc.js');
18
19
  const {
@@ -25,7 +26,13 @@ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/tran
25
26
  const readReceipt = require('../read-receipt.cjs');
26
27
  const { recordSessionRead } = require('./session-last-turn.js');
27
28
 
28
- const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
29
+ /**
30
+ * `/send` 的请求体上限(**明文**字节,图片 base64)。
31
+ *
32
+ * 原来是 8MB。协议 v2 的信封让线上变成 4/3,按线上长度直接卡等于砍掉四分之一额度,
33
+ * 所以按 §8.3(9) 抬到 10.7MB;线上那一侧另有 `MAX_SEALED_BODY` 兜着。
34
+ */
35
+ const SEND_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
29
36
 
30
37
  /** 仅当 content 恰好为一个文本块时返回其文本,否则 null(带图片/多块不回退命令路径)。 */
31
38
  function singleTextOf(content) {
@@ -130,13 +137,24 @@ async function handleSend(ctx, req, res) {
130
137
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'text 块内容为空' } });
131
138
  }
132
139
  } else if (block.type === 'image') {
133
- if (!IMAGE_TYPES.has(block.mediaType) || typeof block.data !== 'string' || !block.data) {
134
- return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'image 块需合法 mediaType 与 base64 data' } });
140
+ // 两种形状都认:老客户端的 `data`(内联 base64),新客户端的 `uploadId`
141
+ // (先走分片上传,再由宿主自己读盘)。后者是为了绕开 `session/send` 那 8 秒预算 ——
142
+ // 4MB 照片内联上传线上接近 7MB,弱网必超时。
143
+ const inline = typeof block.data === 'string' && block.data.length > 0;
144
+ const uploaded = typeof block.uploadId === 'string' && block.uploadId.length > 0;
145
+ if (!IMAGE_TYPES.has(block.mediaType) || (!inline && !uploaded)) {
146
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'image 块需合法 mediaType 与 base64 data 或 uploadId' } });
135
147
  }
136
148
  } else {
137
149
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `不支持的 content 块类型: ${block.type}` } });
138
150
  }
139
151
  }
152
+ // ── uploadId → 内联字节(实现在 uploads.js:字节本来就是那边落的盘)──
153
+ const uploadExpired = await inlineUploadedImageBlocks(content);
154
+ if (uploadExpired) {
155
+ return sendJson(res, 400, { ok: false, error: { code: 'upload-not-found', message: uploadExpired } });
156
+ }
157
+
140
158
  // ── Slash 命令派发 ─────────────────────────────────────────────
141
159
  // 已知命令:注册表执行(command/run + command/done 随事件流回传),响应标注 handledAs 供
142
160
  // 客户端跳过「乐观 pending 气泡 + running 态」;未知命令/语法不符(execute 返回 undefined)
@@ -15,9 +15,10 @@
15
15
  const { sendJson } = require('../shared/http-util');
16
16
  const { unwrap } = require('./envelope.js');
17
17
  const { callSessions } = require('./rpc.js');
18
- const { deriveMessages, buildToolResultMap } = require('./history-derive.js');
18
+ const { deriveMessages, thinEntryMessages, toolResultPatchesFor } = require('./history-derive.js');
19
19
  const { resolveEventAttachments } = require('./attachments.js');
20
20
  const { createTextImageBudget } = require('./local-images.js');
21
+ const { entryWindowPayload } = require('./entry-window.js');
21
22
 
22
23
  const MESSAGES_DEFAULT_LIMIT = 50; // /messages 默认条数
23
24
 
@@ -140,6 +141,9 @@ async function handleMessages(ctx, req, res) {
140
141
  // 上游历史接口都不支持 afterSeq(只认 beforeSeq/maxMessages),故在插件内用较大的窗口
141
142
  // 拉最近若干事件后再过滤 seq > afterSeq,覆盖常见轮询缺口;缺口超过窗口时要求客户端全量补齐。
142
143
  if (afterSeq !== null) payload.maxMessages = Math.max(limit, 200);
144
+ // 入口那一发(不带游标)可以要求按内容裁:宿主按轮取整,过程块见 thinEntryMessages。
145
+ const wantThin = url.searchParams.get('thin') === '1' && beforeSeq === null && afterSeq === null;
146
+ if (wantThin) entryWindowPayload(payload, limit);
143
147
 
144
148
  let value;
145
149
  let error;
@@ -165,17 +169,14 @@ async function handleMessages(ctx, req, res) {
165
169
  const budget = createTextImageBudget();
166
170
  const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
167
171
  const messages = deriveMessages(resolvedEvents);
172
+ const hasMore = !!(value && value.hasMore);
173
+ const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
168
174
  // 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁必须
169
175
  // 取自整个窗口(不只是 seq > afterSeq 的那一段):调用方游标可能已经越过结果 seq,
170
- // 而工具卡还停在 running;把窗口内所有落定结果都回传,客户端按 callId 幂等打补丁。
171
- const toolResults = [...buildToolResultMap(events).entries()].map(([callId, result]) => ({
172
- callId,
173
- state: result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok',
174
- ...(result.output ? { output: result.output } : {}),
175
- ...(result.images && result.images.length ? { images: result.images } : {}),
176
- ...(result.diffs && result.diffs.length ? { diffs: result.diffs } : {}),
177
- }));
178
- const hasMore = !!(value && value.hasMore);
176
+ // 而工具卡还停在 running;客户端按 callId 幂等打补丁。已经随消息块下发的那些只回状态,
177
+ // 不重复带正文(规则与理由见 history-derive 的 toolResultPatchesFor)。
178
+ // 入口裁剪那一发不带补丁:过程块已摘掉、补丁无处可打,跟着走等于把工具输出又塞回顶层。
179
+ const toolResults = wantThin ? [] : toolResultPatchesFor(events, items);
179
180
  const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
180
181
  const oldestSeq = seqs.length ? Math.min(...seqs) : null;
181
182
  // A truncated history window is not a complete incremental answer. Keep the
@@ -186,7 +187,7 @@ async function handleMessages(ctx, req, res) {
186
187
  : (seqs.length ? Math.max(...seqs) : 0);
187
188
  sendJson(res, 200, {
188
189
  ok: true,
189
- items: messages,
190
+ items,
190
191
  hasMore,
191
192
  lastSeq,
192
193
  ...(oldestSeq !== null ? { oldestSeq } : {}),
@@ -21,6 +21,7 @@
21
21
  * session-goal.js 目标条动作
22
22
  * session-presets.js Agent 预设目录
23
23
  * session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
24
+ * session-list-shortcut.js `?rev=` 条件刷新记住的那份清单是谁
24
25
  *
25
26
  * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
26
27
  * GET /session(单会话摘要)、GET /session/head(正文水位探针)、GET /session/stats、
@@ -31,8 +32,10 @@
31
32
  const { sendJson, readJsonBody } = require('../shared/http-util');
32
33
  const { isFreshRequest } = require('../shared/fresh-request');
33
34
  const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
35
+ const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
34
36
  // 正文水位信号:session/head 探针直接读它的快照,**不**为了「看起来有值」去现读历史。
35
37
  const { sessionSignal } = require('../shared/session-signal.js');
38
+ const { listShortcut, rememberListBuild } = require('./session-list-shortcut.js');
36
39
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
37
40
  const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
38
41
  const { unwrap } = require('./envelope.js');
@@ -131,6 +134,21 @@ async function handleSessions(ctx, req, res) {
131
134
  return sendJson(res, status, unchanged || body);
132
135
  };
133
136
 
137
+ // ── 构建前短路(`rev` 命中且清单确实没变)──
138
+ //
139
+ // 手机 15 秒问一次,而「构建这份列表」要几百毫秒(读宿主列表 + 折预览 + 算 revision)。
140
+ // 大多数轮次清单根本没变,答案就是几十字节的 `unchanged` —— 那就没必要先把它建出来。
141
+ // 判据三样一起看,缺一不可:
142
+ // ① 客户端手上的 revision 与上次构建出来的完全相同;
143
+ // ② 清单变更信号没有涨过(镜像推送调度器每 500ms 比一次内容哈希,见 list-signal);
144
+ // ③ 这台设备的完成态代号没变(completed / 失败态按设备算,信号里看不出来)。
145
+ // 只在**真有观众**(设备级 SSE 或轮询订阅着)时才信任信号:没有观众时推送调度器会
146
+ // 停止空转构建,信号不再前进,那时宁可老实重建。
147
+ if (!bypassCache && rev && sharedListSignal.watching()) {
148
+ const short = listShortcut(cache, rev, generation);
149
+ if (short) return respond(200, short);
150
+ }
151
+
134
152
  if (!bypassCache && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
135
153
  return respond(200, cache.value);
136
154
  }
@@ -160,6 +178,7 @@ async function handleSessions(ctx, req, res) {
160
178
  cache.updatedAt = Date.now();
161
179
  }
162
180
  }
181
+ if (response.status === 200) rememberListBuild(cache, response.body, completionGenerationOf(ctx, deviceKey));
163
182
  return respond(response.status, response.body);
164
183
  } finally {
165
184
  if (cache.promise === promise) cache.promise = null;
@@ -425,6 +444,7 @@ async function handleQueueUpdate(ctx, req, res) {
425
444
 
426
445
  // 下面这一组从各子模块转出:它们此前全是 sessions.js 的实现,而 sessions.js 是
427
446
  // mobile/ 下被引用最多的模块(其它 handler 与测试都从这里取)。换实现不改路径。
447
+
428
448
  module.exports = {
429
449
  // 本文件自己的
430
450
  buildSessionsResponse,
@@ -36,6 +36,17 @@ const COLLECT_INTERVAL_MS = 60_000;
36
36
  /** 并发读投影的条数:宿主那侧是内存里读缓存,四条并行不会把它压住。 */
37
37
  const CONCURRENCY = 4;
38
38
 
39
+ /**
40
+ * 这一轮先扫「最近还在动」的会话,再按「最久没采过」补老账。
41
+ *
42
+ * 之前每一轮都只按最后活动时间倒序扫、1.5 秒到点就停,于是最老的那批永远排在队尾、
43
+ * 永远采不到(实测七十多条会话从没进过账本,其中大多是子代理)。现在把队列分成两段:
44
+ * 近 7 天有动作的照旧优先(它们才是数字在变的那批),其余的按 `collectedAt` 升序 ——
45
+ * 采过的会顺手把时间推到当下、自动排到队尾,于是每一轮的预算都接着上一轮的地方往下走,
46
+ * 不需要额外持久化一个游标。
47
+ */
48
+ const RECENT_WINDOW_MS = 7 * 86400_000;
49
+
39
50
  /**
40
51
  * 一次采集的时间预算。
41
52
  *
@@ -134,6 +145,25 @@ function hasDelta(usage) {
134
145
  return Object.values(usage).some((value) => value > 0);
135
146
  }
136
147
 
148
+ /**
149
+ * 采集串行化:同一时刻只跑一轮。
150
+ *
151
+ * 水位法在**两个请求同时进来**时会重复记账(先把同一份增量各记一遍,之后水位才对上,
152
+ * 多出来的量就永久留在按天账上)—— 打开统计页又立刻下拉刷新、或者清理已归档和打开页面
153
+ * 撞在一起,都能走到这条路上。串行而不是合并:清理前那次定向补采要的是它自己那几条,
154
+ * 直接复用统计页那一轮等于那几条根本没采。
155
+ */
156
+ let tail = Promise.resolve();
157
+
158
+ function collectStats(ctx, options = {}) {
159
+ const run = tail.then(
160
+ () => collectOnce(ctx, options),
161
+ () => collectOnce(ctx, options),
162
+ );
163
+ tail = run.then(() => undefined, () => undefined);
164
+ return run;
165
+ }
166
+
137
167
  /**
138
168
  * 采一轮。
139
169
  *
@@ -141,7 +171,7 @@ function hasDelta(usage) {
141
171
  * `force` 跳过 60 秒的最小间隔(用户手动刷新时要真读一次);
142
172
  * `skipPreview` 不读「最后一句」摘要(清理前补采用,见文件头)。
143
173
  */
144
- async function collectStats(ctx, options = {}) {
174
+ async function collectOnce(ctx, options = {}) {
145
175
  const now = Date.now();
146
176
  const budgetMs = Number.isFinite(options.budgetMs) && options.budgetMs > 0 ? options.budgetMs : DEFAULT_BUDGET_MS;
147
177
  const deadline = now + budgetMs;
@@ -161,10 +191,20 @@ async function collectStats(ctx, options = {}) {
161
191
  }
162
192
  }
163
193
 
164
- // 最近有动作的排前面:预算不够采完全部时,先把用户真正在用的那几条账补上。
194
+ // 队列分两段:近 7 天有动作的优先(数字在变的就是它们),其余的按「最久没采过」排 ——
195
+ // 采完一轮会把 `collectedAt` 推到当下,自动沉到队尾,于是预算每次都接着上一轮往下走。
196
+ const recentSince = now - RECENT_WINDOW_MS;
165
197
  const targets = items
166
198
  .filter((item) => !wanted || wanted.has(item.sessionId))
167
- .sort((a, b) => num(b.updatedAt) - num(a.updatedAt));
199
+ .sort((a, b) => {
200
+ const recentA = num(a.updatedAt) >= recentSince;
201
+ const recentB = num(b.updatedAt) >= recentSince;
202
+ if (recentA !== recentB) return recentA ? -1 : 1;
203
+ if (recentA) return num(b.updatedAt) - num(a.updatedAt);
204
+ const collectedA = book.sessions[a.sessionId] ? num(book.sessions[a.sessionId].collectedAt) : 0;
205
+ const collectedB = book.sessions[b.sessionId] ? num(book.sessions[b.sessionId].collectedAt) : 0;
206
+ return collectedA - collectedB;
207
+ });
168
208
  let cursor = 0;
169
209
 
170
210
  const worker = async () => {
@@ -228,15 +268,18 @@ async function collectStats(ctx, options = {}) {
228
268
  saved.watermark = host;
229
269
  saved.collectedAt = now;
230
270
  saved.gone = false;
231
- }
232
-
233
- const breakdown = projections.contextBreakdown;
234
- if (breakdown && typeof breakdown === 'object') {
235
- book.composition = {
236
- systemTokens: num(breakdown.systemTokens),
237
- toolsTokens: num(breakdown.toolsTokens),
238
- messageTokens: num(breakdown.messageTokens),
239
- };
271
+ // 上下文构成是**这条会话**的快照:跟着记录走,页面才能说清「这是哪条会话的构成」。
272
+ // 以前全局只留一份、每轮被最后读完的那条覆盖(四条并发,谁后回是谁),
273
+ // 于是卡片上的构成跟用户此刻在用的那条毫无关系。
274
+ const breakdown = projections.contextBreakdown;
275
+ if (breakdown && typeof breakdown === 'object') {
276
+ saved.composition = {
277
+ systemTokens: num(breakdown.systemTokens),
278
+ toolsTokens: num(breakdown.toolsTokens),
279
+ messageTokens: num(breakdown.messageTokens),
280
+ at: now,
281
+ };
282
+ }
240
283
  }
241
284
  }
242
285
  };