dsh-mobile-plugin 0.1.100 → 0.2.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.
Files changed (59) 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 +88 -9
  6. package/lib/index.js +3 -2
  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 +137 -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 +209 -11
  15. package/lib/mobile/host-bridge/event-synthesis.js +61 -10
  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 +547 -0
  22. package/lib/mobile/session-cache.js +60 -2
  23. package/lib/mobile/session-file.js +133 -34
  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 +30 -27
  28. package/lib/mobile/session-snapshot.js +12 -12
  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 +382 -31
  34. package/lib/mobile/subagents.js +11 -2
  35. package/lib/mobile/uploads.js +91 -5
  36. package/lib/mobile/workspaces.js +39 -8
  37. package/lib/panel/index.html +11 -11
  38. package/lib/relay/core.js +28 -2
  39. package/lib/relay/lan-network.cjs +47 -0
  40. package/lib/relay/lan-proxy.js +283 -122
  41. package/lib/relay/protocol.js +4 -2
  42. package/lib/relay/tunnel.js +20 -14
  43. package/lib/routes/control-plane.js +139 -9
  44. package/lib/routes/mobile-api.js +92 -9
  45. package/lib/shared/e2ee-pair.js +91 -0
  46. package/lib/shared/e2ee-plain.js +79 -0
  47. package/lib/shared/e2ee-request.js +331 -0
  48. package/lib/shared/e2ee.js +173 -0
  49. package/lib/shared/http-util.js +27 -4
  50. package/lib/shared/list-signal.js +16 -0
  51. package/lib/shared/pair-throttle.js +150 -0
  52. package/lib/shared/sse-write.js +16 -5
  53. package/lib/shared/via-header.js +65 -0
  54. package/lib/shell/runtime.js +147 -42
  55. package/lib/shell/self-check-after-swap.js +93 -0
  56. package/lib/shell/updater.js +44 -3
  57. package/lib/sidecar-facade-worker.cjs +70 -6
  58. package/lib/update-install.cjs +13 -5
  59. 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
  * 它们的值属于**宿主进程状态**(某个会话正在跑、某个列表刚构建完、某条发送已经
@@ -39,9 +39,14 @@ const lastTurnFacts = new WeakMap();
39
39
  // Prevent duplicate prompt calls when a mobile request is retried after a timeout.
40
40
  const sendIdempotency = new WeakMap();
41
41
  // 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
42
- // 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,60s)。
42
+ // 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
43
+ // 成功之后留到进程结束;这里是每次写各自的 requestId、只活在重投窗口里)。
43
44
  const writeIdempotency = new WeakMap();
44
45
 
46
+ // 工作区列表的条件刷新短路:上次构建留下的 revision / 生成时刻 / 当时的信号代号。
47
+ // 与 sessionListResponseCache 同一类东西(列表刚构建完的那份是谁),只是按工作区算。
48
+ const workspaceShortcutStates = new WeakMap();
49
+
45
50
  /** 设备维度:同一个 ctx 下多台手机各自一份,缺省用 'legacy'(未带 x-dsh-device 的调用)。 */
46
51
  function canonicalDeviceKey(req, fallback = 'legacy') {
47
52
  const value = req?.headers?.['x-dsh-device'];
@@ -224,6 +229,19 @@ function clearCompletionForAllDevices(ctx, sessionId, at = Date.now()) {
224
229
  return cleared;
225
230
  }
226
231
 
232
+ /**
233
+ * 工作区列表短路的记忆体:客户端 rev、上次生成时刻、当时的信号 revision、上次构建时刻。
234
+ * 与其它表同样按 ctx 隔离 —— 不同宿主的工作区快照互不相干。
235
+ */
236
+ function workspaceShortcutStateOf(ctx) {
237
+ let state = workspaceShortcutStates.get(ctx);
238
+ if (!state) {
239
+ state = { lastRevision: '', lastGeneratedAt: 0, lastSignalRevision: '', lastBuiltAt: 0 };
240
+ workspaceShortcutStates.set(ctx, state);
241
+ }
242
+ return state;
243
+ }
244
+
227
245
  function completionGenerationOf(ctx, deviceKey) {
228
246
  let generations = completionGenerations.get(ctx);
229
247
  if (!generations) {
@@ -248,6 +266,44 @@ function sendIdempotencyStateOf(ctx) {
248
266
  return state;
249
267
  }
250
268
 
269
+ /**
270
+ * 同一个投递 id 在一次会话里只真正跑一次:成功之后记录留到进程结束,失败立刻释放。
271
+ *
272
+ * 要防的是「请求超时、但那一发其实已经交给宿主了」这一档:App 会拿着同一个
273
+ * clientMessageId 再发一次(内核重试或用户手点重试),第二次必须拿到第一次的结果,
274
+ * 而不是让模型 / 子代理把同一句话再说一遍。
275
+ *
276
+ * **失败不占位**:`run` 回的信封不是成功(宿主明确拒绝,如 model-unavailable /
277
+ * agent-busy)就当场删掉记录 —— 一次瞬时错误不能被固化成「这个 id 永远只回这一句错」;
278
+ * 同理 `run` 抛错(传输层失败)也删。两条都必须是「真的再试一次」,否则活的路径只剩
279
+ * 用户重开 App。
280
+ *
281
+ * @param {object} ctx 插件上下文(状态按 ctx 隔离)
282
+ * @param {string} key 投递身份:主会话 `sessionId:clientMessageId`,子代理额外加前缀
283
+ * @param {() => Promise<object>} run 真正发一次,回宿主信封(`{ result: { ok, value | error } }`)
284
+ */
285
+ function promptOnceFor(ctx, key, run) {
286
+ const state = sendIdempotencyStateOf(ctx);
287
+ const existing = state.get(key);
288
+ if (existing) return existing.promise;
289
+
290
+ const promise = Promise.resolve().then(run);
291
+ const record = { promise };
292
+ state.set(key, record);
293
+ const release = () => { if (state.get(key) === record) state.delete(key); };
294
+ promise.then(
295
+ (response) => {
296
+ // 留下的这条记录就是「这一发已经交给宿主了」的回执;宿主明确拒绝(信封 ok 不为真)
297
+ // 就当场释放 —— 一次业务错误不能被固化成「这个 id 永远只回这一句」。这里只看收没
298
+ // 收下,不拆错误信封(拆信封在 envelope.js,本文件是零依赖的叶子)。
299
+ const accepted = Boolean(response && response.result && response.result.ok === true);
300
+ if (!accepted) release();
301
+ },
302
+ release,
303
+ );
304
+ return promise;
305
+ }
306
+
251
307
  /** 写操作幂等表(实现在 mobile/idempotency.js,这里只保证按 ctx 隔离)。 */
252
308
  function writeIdempotencyStateOf(ctx) {
253
309
  let state = writeIdempotency.get(ctx);
@@ -336,7 +392,9 @@ module.exports = {
336
392
  markCompletionRead,
337
393
  clearCompletionForAllDevices,
338
394
  completionGenerationOf,
395
+ workspaceShortcutStateOf,
339
396
  bumpCompletionGeneration,
340
397
  sendIdempotencyStateOf,
398
+ promptOnceFor,
341
399
  writeIdempotencyStateOf,
342
400
  };
@@ -8,17 +8,24 @@
8
8
  *
9
9
  * 三条边界同时成立:
10
10
  *
11
- * 1. **只认本会话 present 声明过的路径**:`deliverables/presented` 是模型显式交出来的
12
- * 那份清单,App 卡片上的每一格都来自它。别把它放宽成「会话工作区里的任意文件」——
13
- * 移动 API 不暴露任意读,与它不暴露 shell / 任意写入是同一条边界。路径按**字符串
14
- * 全等**比对声明,所以不存在穿越。
15
- * 2. **相对路径按会话工作区解析**:声明里既可能是 `src/x.png` 也可能是绝对路径,
16
- * 相对的那一种只能相对 cwd 解析(cwd 取自会话列表摘要,与 /session 同一个来源)。
11
+ * 1. **放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径**(2026-10-25 用户拍板
12
+ * 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
13
+ * 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
14
+ * cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
15
+ * 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
16
+ * cwd 变了的老会话)。
17
+ *
18
+ * **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
19
+ * —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
20
+ * 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
21
+ * 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
22
+ * 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
23
+ * cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
17
24
  * 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
18
25
  * 导出同一个理由。
19
26
  *
20
- * 取不到时两种失败要分得开:不在声明里回 403(`not-presented`),声明里有、盘上没了
21
- * 回 404(`missing`)—— 前者是「这一格不该出现」,后者是「文件被挪走了」,文案不同。
27
+ * 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
28
+ * 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
22
29
  */
23
30
 
24
31
  const fs = require('node:fs');
@@ -26,6 +33,7 @@ const { stat } = require('node:fs/promises');
26
33
  const path = require('node:path');
27
34
  const { pipeline } = require('node:stream/promises');
28
35
  const { sendJson, readJsonBody } = require('../shared/http-util');
36
+ const { signedPath } = require('../shared/e2ee-request');
29
37
  const { unwrap } = require('./envelope.js');
30
38
  const { callSessions } = require('./rpc.js');
31
39
  const { presentedPathsOf } = require('./history-derive.js');
@@ -97,6 +105,19 @@ async function declaredPathsOf(ctx, sessionId) {
97
105
  return presentedPathsOf((value && value.events) || []);
98
106
  }
99
107
 
108
+ /**
109
+ * 落点在不在工作区里。
110
+ *
111
+ * `path.relative` 出来是空的(就是 cwd 本身)或者以 `..` 开头(爬出去了)都算不在,
112
+ * 这样 `../x`、`a/../../x` 这些写法一并挡住 —— 判的是**解析后的真实落点**,不是输入字符串,
113
+ * 所以「看起来没写过 ..」最终指到外面的路径同样过不去。
114
+ */
115
+ function isInsideWorkspace(cwd, target) {
116
+ if (!cwd) return false;
117
+ const rel = path.relative(path.resolve(cwd), target);
118
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
119
+ }
120
+
100
121
  /** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
101
122
  async function cwdOf(ctx, sessionId) {
102
123
  try {
@@ -124,34 +145,35 @@ async function handleSessionFile(ctx, req, res) {
124
145
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, path }' } });
125
146
  }
126
147
 
127
- let declaredPaths;
128
- try {
129
- declaredPaths = await declaredPathsOf(ctx, sessionId);
130
- } catch (error) {
131
- return sendJson(res, 502, {
132
- ok: false,
133
- error: { code: 'history-failed', message: String((error && error.message) || error) },
134
- });
135
- }
136
- if (!declaredPaths.has(declared)) {
137
- return sendJson(res, 403, {
138
- ok: false,
139
- error: { code: 'not-presented', message: '这个文件不在本会话的交付声明里' },
140
- });
141
- }
148
+ // 先把落点算出来:工作区那一侧判的是**解析后的真实路径**,不是输入字符串。
149
+ const cwd = await cwdOf(ctx, sessionId);
150
+ let target = path.isAbsolute(declared) ? path.resolve(declared) : '';
151
+ if (!target && cwd) target = path.resolve(cwd, declared);
142
152
 
143
- let target;
144
- if (path.isAbsolute(declared)) {
145
- target = declared;
146
- } else {
147
- const cwd = await cwdOf(ctx, sessionId);
148
- if (!cwd) {
153
+ // 工作区里的文件直接放行,一次历史都不读 —— 这是正文里点的链接走的那条路。
154
+ if (!target || !isInsideWorkspace(cwd, target)) {
155
+ // 工作区之外只剩交付声明这一条口子(绝对路径、或 cwd 变了的老会话)。
156
+ let declaredPaths;
157
+ try {
158
+ declaredPaths = await declaredPathsOf(ctx, sessionId);
159
+ } catch (error) {
160
+ return sendJson(res, 502, {
161
+ ok: false,
162
+ error: { code: 'history-failed', message: String((error && error.message) || error) },
163
+ });
164
+ }
165
+ if (!declaredPaths.has(declared)) {
166
+ return sendJson(res, 403, {
167
+ ok: false,
168
+ error: { code: 'outside-workspace', message: '这个路径不在本会话的工作区里,手机上打不开' },
169
+ });
170
+ }
171
+ if (!target) {
149
172
  return sendJson(res, 409, {
150
173
  ok: false,
151
174
  error: { code: 'no-cwd', message: '这条会话没有工作区目录,没法解析相对路径' },
152
175
  });
153
176
  }
154
- target = path.join(cwd, declared);
155
177
  }
156
178
 
157
179
  let info;
@@ -171,12 +193,33 @@ async function handleSessionFile(ctx, req, res) {
171
193
  }
172
194
 
173
195
  const name = path.basename(target);
174
- res.writeHead(200, {
196
+ const header = {
175
197
  'content-type': mediaTypeOf(target),
176
- 'content-length': String(info.size),
177
198
  'content-disposition': dispositionOf(name),
178
199
  'cache-control': 'no-store',
179
- });
200
+ };
201
+
202
+ // 协议 v2:分块信封(§8.3(6))。
203
+ //
204
+ // 套一次性 AEAD 意味着宿主得先把整份读进内存 —— 单文件上限 512MB,那条路不能走。
205
+ // 所以改成固定分块流式写:宿主仍然边读边发,内存占用还是一块的大小;App 那边今天本来
206
+ // 就是整份读进 arrayBuffer(上限 200MB),按长度字段切块、逐块解即可,不需要流式解密栈。
207
+ const opened = req && req.__dshE2ee;
208
+ if (opened && opened.keys && opened.req) {
209
+ res.writeHead(200, {
210
+ ...header,
211
+ 'content-type': 'application/x-dsh-e2ee-chunks',
212
+ // 这份字节不是原文,`content-length` 得等写完才知道,改用 chunked。
213
+ 'transfer-encoding': 'chunked',
214
+ 'x-dsh-proto': '2',
215
+ // 真实类型单独给:App 解密之后要拿它决定怎么预览。
216
+ 'x-dsh-content-type': header['content-type'],
217
+ });
218
+ await writeEncryptedFile(req, res, target, opened);
219
+ return;
220
+ }
221
+
222
+ res.writeHead(200, { ...header, 'content-length': String(info.size) });
180
223
  if (info.size === 0) return res.end();
181
224
 
182
225
  const stream = fs.createReadStream(target);
@@ -189,4 +232,60 @@ async function handleSessionFile(ctx, req, res) {
189
232
  }
190
233
  }
191
234
 
192
- module.exports = { handleSessionFile, SESSION_FILE_MAX_BYTES };
235
+ /**
236
+ * 按块加密写出去(§8.3(6))。
237
+ *
238
+ * 每块形状:`[4 字节大端长度][12 字节 nonce][密文 + 16 字节 tag]`,长度字段写的是
239
+ * **「密文 + tag」的长度**(不含 nonce 与长度字段本身)。块大小 64KB,最后一块可以更短。
240
+ *
241
+ * AAD 绑这条请求的路径、请求 nonce 与块序号 —— 块序号让中转不能把别的文件(或这一份的
242
+ * 别的块)的字节搬进来,也不能重排或截断而让 App 察觉不到。
243
+ */
244
+ async function writeEncryptedFile(req, res, target, opened) {
245
+ const e2ee = require('../shared/e2ee');
246
+ const requestNonce = opened.req.nonce;
247
+ let seq = 0;
248
+ let pending = Buffer.alloc(0);
249
+ let closed = false;
250
+ res.on('close', () => { closed = true; });
251
+ // **整段都在 try 里**:调用点已经 `writeHead(200)` 过了,此后任何一步抛错都改不了状态码,
252
+ // 而 `sendJson` 再想写一个 500 只会撞上「头已发出」被静默吞掉 —— 响应就永远不结束,
253
+ // 手机上表现成「下载中」一直转、首字节等到超时。所以只能主动断开,让对端按截断处理。
254
+ try {
255
+ const path0 = signedPath(req);
256
+ const stream = fs.createReadStream(target, { highWaterMark: FILE_CHUNK_BYTES });
257
+ for await (const chunk of stream) {
258
+ if (closed) break;
259
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
260
+ while (pending.length >= FILE_CHUNK_BYTES) {
261
+ const block = pending.subarray(0, FILE_CHUNK_BYTES);
262
+ pending = pending.subarray(FILE_CHUNK_BYTES);
263
+ seq += 1;
264
+ writeBlock(res, e2ee, opened.keys.down, block, e2ee.fileAad(path0, requestNonce, seq));
265
+ }
266
+ }
267
+ // 尾块(可能不足一块);空文件也给一块,否则 App 拿不到「这是个空文件」的确认。
268
+ if (!closed) {
269
+ seq += 1;
270
+ writeBlock(res, e2ee, opened.keys.down, pending, e2ee.fileAad(path0, requestNonce, seq));
271
+ }
272
+ if (!closed) res.end();
273
+ } catch {
274
+ try { res.destroy(); } catch { /* 连接已关闭 */ }
275
+ }
276
+ }
277
+
278
+ /** 写一块:`[4 字节长度][nonce][密文+tag]`,长度写的是后两段之外的密文长度。 */
279
+ function writeBlock(res, e2ee, key, block, aad) {
280
+ const sealed = e2ee.seal(key, block, aad);
281
+ const buf = Buffer.from(sealed);
282
+ const cipherLen = buf.length - e2ee.NONCE_BYTES;
283
+ const head = Buffer.alloc(4);
284
+ head.writeUInt32BE(cipherLen, 0);
285
+ res.write(Buffer.concat([head, buf]));
286
+ }
287
+
288
+ /** 交付文件的分块大小。64KB:一块一次 AEAD,太小会让每块的 16+12 字节固定开销占比变高。 */
289
+ const FILE_CHUNK_BYTES = 64 * 1024;
290
+
291
+ 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;
@@ -3,21 +3,23 @@
3
3
  /**
4
4
  * 发消息、slash 命令、会话的读/停/改名/分叉。
5
5
  *
6
- * promptOnce 的幂等窗口是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点
7
- * 会产生同一个 clientMessageId 的多次请求,60 秒内它们共享同一个 prompt,而不是
8
- * 让模型跑两遍。**只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成 60 秒的
9
- * 静默无响应。
6
+ * promptOnce 的去重是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点会产生
7
+ * 同一个 clientMessageId 的多次请求,同一会话内它们共享同一个 prompt,而不是让模型跑
8
+ * 两遍。成功之后记录一直留着(不按时间窗过期):请求超时的那一发很可能已经交给宿主了,
9
+ * 用户隔几分钟再点一次重试,过期的窗口正好把同一条消息又发一遍。
10
+ * **只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成一条永远发不出去的消息。
10
11
  *
11
12
  * 文本块恰好一个且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
12
13
  * 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
13
14
  */
14
15
 
15
16
  const { sendJson, readJsonBody } = require('../shared/http-util');
17
+ const { inlineUploadedImageBlocks } = require('./uploads.js');
16
18
  const { unwrap } = require('./envelope.js');
17
19
  const { callSessions, mobilePromptRpcId } = require('./rpc.js');
18
20
  const {
19
21
  canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
20
- markCompletionRead, sendIdempotencyStateOf,
22
+ markCompletionRead, promptOnceFor,
21
23
  } = require('./session-cache.js');
22
24
  const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
23
25
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
@@ -25,7 +27,13 @@ const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/tran
25
27
  const readReceipt = require('../read-receipt.cjs');
26
28
  const { recordSessionRead } = require('./session-last-turn.js');
27
29
 
28
- const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
30
+ /**
31
+ * `/send` 的请求体上限(**明文**字节,图片 base64)。
32
+ *
33
+ * 原来是 8MB。协议 v2 的信封让线上变成 4/3,按线上长度直接卡等于砍掉四分之一额度,
34
+ * 所以按 §8.3(9) 抬到 10.7MB;线上那一侧另有 `MAX_SEALED_BODY` 兜着。
35
+ */
36
+ const SEND_MAX_BODY = Math.ceil(8 * 1024 * 1024 * 4 / 3);
29
37
 
30
38
  /** 仅当 content 恰好为一个文本块时返回其文本,否则 null(带图片/多块不回退命令路径)。 */
31
39
  function singleTextOf(content) {
@@ -79,25 +87,9 @@ function promptOnce(ctx, clientMessageId, payload) {
79
87
  // 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
80
88
  const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
81
89
  if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
82
- const state = sendIdempotencyStateOf(ctx);
83
- const key = `${payload.sessionId}:${clientMessageId}`;
84
- const now = Date.now();
85
- const existing = state.get(key);
86
- if (existing && now - existing.createdAt < 60_000) return existing.promise;
87
- if (existing) state.delete(key);
88
-
89
- const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
90
- // Keep the short-lived record so an in-flight/concurrent send with the same
91
- // clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
92
- // not poison later retries (a transient error should be retryable), so the
93
- // record is released on failure.
94
- const record = { createdAt: now, promise };
95
- state.set(key, record);
96
- promise.then(
97
- () => { /* resolved stays cached until the 60s window expires */ },
98
- () => { if (state.get(key) === record) state.delete(key); },
99
- );
100
- return promise;
90
+ // 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
91
+ // 那次,都拿同一份结果(口径见 session-cache 的 `promptOnceFor`)。
92
+ return promptOnceFor(ctx, `${payload.sessionId}:${clientMessageId}`, () => callSessions(ctx, 'prompt', request, rpcId));
101
93
  }
102
94
 
103
95
  async function handleSend(ctx, req, res) {
@@ -130,13 +122,24 @@ async function handleSend(ctx, req, res) {
130
122
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'text 块内容为空' } });
131
123
  }
132
124
  } 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' } });
125
+ // 两种形状都认:老客户端的 `data`(内联 base64),新客户端的 `uploadId`
126
+ // (先走分片上传,再由宿主自己读盘)。后者是为了绕开 `session/send` 那 8 秒预算 ——
127
+ // 4MB 照片内联上传线上接近 7MB,弱网必超时。
128
+ const inline = typeof block.data === 'string' && block.data.length > 0;
129
+ const uploaded = typeof block.uploadId === 'string' && block.uploadId.length > 0;
130
+ if (!IMAGE_TYPES.has(block.mediaType) || (!inline && !uploaded)) {
131
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'image 块需合法 mediaType 与 base64 data 或 uploadId' } });
135
132
  }
136
133
  } else {
137
134
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `不支持的 content 块类型: ${block.type}` } });
138
135
  }
139
136
  }
137
+ // ── uploadId → 内联字节(实现在 uploads.js:字节本来就是那边落的盘)──
138
+ const uploadExpired = await inlineUploadedImageBlocks(content);
139
+ if (uploadExpired) {
140
+ return sendJson(res, 400, { ok: false, error: { code: 'upload-not-found', message: uploadExpired } });
141
+ }
142
+
140
143
  // ── Slash 命令派发 ─────────────────────────────────────────────
141
144
  // 已知命令:注册表执行(command/run + command/done 随事件流回传),响应标注 handledAs 供
142
145
  // 客户端跳过「乐观 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,13 @@ 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);
168
- // 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁必须
169
- // 取自整个窗口(不只是 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
172
  const hasMore = !!(value && value.hasMore);
173
+ const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
174
+ // 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁取自
175
+ // 整个窗口(调用方游标可能已越过结果 seq);已随消息块下发的只回状态(见 toolResultPatchesFor)。
176
+ // 增量那一发(带 afterSeq)一律只回状态:调用方手上已有一份会话,整窗重发等于每 15 秒
177
+ // 把最近两百条的工具输出再传一次;入口裁剪那一发不带补丁(过程块已摘掉、无处可打)。
178
+ const toolResults = wantThin ? [] : toolResultPatchesFor(events, items, { bodies: afterSeq === null });
179
179
  const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
180
180
  const oldestSeq = seqs.length ? Math.min(...seqs) : null;
181
181
  // A truncated history window is not a complete incremental answer. Keep the
@@ -186,7 +186,7 @@ async function handleMessages(ctx, req, res) {
186
186
  : (seqs.length ? Math.max(...seqs) : 0);
187
187
  sendJson(res, 200, {
188
188
  ok: true,
189
- items: messages,
189
+ items,
190
190
  hasMore,
191
191
  lastSeq,
192
192
  ...(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,