dsh-mobile-plugin 0.0.0-stage → 0.1.57

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 (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 发消息、slash 命令、会话的读/停/改名/分叉。
5
+ *
6
+ * promptOnce 的幂等窗口是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点
7
+ * 会产生同一个 clientMessageId 的多次请求,60 秒内它们共享同一个 prompt,而不是
8
+ * 让模型跑两遍。**只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成 60 秒的
9
+ * 静默无响应。
10
+ *
11
+ * 文本块恰好一个且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
12
+ * 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
13
+ */
14
+
15
+ const { sendJson, readJsonBody } = require('../shared/http-util');
16
+ const { unwrap } = require('./envelope.js');
17
+ const { callSessions, mobilePromptRpcId } = require('./rpc.js');
18
+ const {
19
+ canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
20
+ completionStateOf, sendIdempotencyStateOf,
21
+ } = require('./session-cache.js');
22
+ const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
23
+ const { withIdempotency, requestIdOf } = require('./idempotency.js');
24
+ const readReceipt = require('../read-receipt.cjs');
25
+
26
+ const SEND_MAX_BODY = 8 * 1024 * 1024; // /send 请求体上限(图片 base64)
27
+
28
+ /** 仅当 content 恰好为一个文本块时返回其文本,否则 null(带图片/多块不回退命令路径)。 */
29
+ function singleTextOf(content) {
30
+ if (!Array.isArray(content) || content.length !== 1) return null;
31
+ const block = content[0];
32
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') return null;
33
+ return block.text;
34
+ }
35
+
36
+ /**
37
+ * 经宿主 typertGateway 执行 slash 命令(commands.execute——桌面 Web 端同一条派发路径:
38
+ * 命令注册表命中才执行,绝不经 session.prompt 发给模型;agent 由 agentId 经同一
39
+ * agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
40
+ *
41
+ * ⚠️ args 的键名必须**恰好**是宿主 descriptor 的 wire 名(typertGateway 的
42
+ * assertExactArguments 只认这组名字,多一个少一个都是 gateway/arguments-invalid)。
43
+ * descriptor:`@deepseek-ai/dsh-commands#commands/execute`,scope wire = agentId,
44
+ * 参数 = line + submittedAttachments(encoded images 与 staged file receipts,
45
+ * 按提交顺序排列,纯命令调用传空数组即可)。
46
+ * 这里曾经写成 `images: []`——少了 submittedAttachments 又多了 images,导致
47
+ * /permission、/plan 等**所有** slash 命令 400;形状守卫见
48
+ * test/commands-execute-shape.test.cjs(改这里必须同步那张表)。
49
+ *
50
+ * @returns {execution} 派发成功(CommandExecution|undefined,undefined=未知命令/语法不符)
51
+ * | {error} 查找或派发失败 | {missing} 网关服务未组合(调用方回退普通消息)
52
+ */
53
+ async function executeCommand(ctx, sessionId, line) {
54
+ const gateway = ctx.get && ctx.get('typertGateway');
55
+ if (!gateway) return { missing: true };
56
+ try {
57
+ const execution = await gateway.invoke({
58
+ namespace: 'commands',
59
+ method: 'execute',
60
+ args: { agentId: sessionId, line, submittedAttachments: [] },
61
+ });
62
+ return { execution };
63
+ } catch (error) {
64
+ return { error };
65
+ }
66
+ }
67
+
68
+ /**
69
+ * POST /api/mobile/v1/session/send(token)
70
+ * 兼容两种格式:
71
+ * 旧:{sessionId, text}
72
+ * 新:{sessionId, content:[{type:'text',text} | {type:'image',mediaType,data(base64),name?}], mode?:'queue'|'steer'}
73
+ * 恰为单个文本块且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
74
+ * 其余原样透传给 harness 的 session.prompt(图片块需模型支持,不支持时错误原样返回)。
75
+ */
76
+ function promptOnce(ctx, clientMessageId, payload) {
77
+ const rpcId = mobilePromptRpcId(clientMessageId);
78
+ if (!clientMessageId) return callSessions(ctx, 'prompt', payload, rpcId);
79
+ const state = sendIdempotencyStateOf(ctx);
80
+ const key = `${payload.sessionId}:${clientMessageId}`;
81
+ const now = Date.now();
82
+ const existing = state.get(key);
83
+ if (existing && now - existing.createdAt < 60_000) return existing.promise;
84
+ if (existing) state.delete(key);
85
+
86
+ const promise = Promise.resolve(callSessions(ctx, 'prompt', payload, rpcId));
87
+ // Keep the short-lived record so an in-flight/concurrent send with the same
88
+ // clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
89
+ // not poison later retries (a transient error should be retryable), so the
90
+ // record is released on failure.
91
+ const record = { createdAt: now, promise };
92
+ state.set(key, record);
93
+ promise.then(
94
+ () => { /* resolved stays cached until the 60s window expires */ },
95
+ () => { if (state.get(key) === record) state.delete(key); },
96
+ );
97
+ return promise;
98
+ }
99
+
100
+ async function handleSend(ctx, req, res) {
101
+ const body = await readJsonBody(req, SEND_MAX_BODY);
102
+ if (!body || typeof body.sessionId !== 'string' || !body.sessionId) {
103
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, content | text }' } });
104
+ }
105
+ const mode = body.mode === undefined ? 'queue' : body.mode;
106
+ if (mode !== 'queue' && mode !== 'steer') {
107
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: "mode 必须为 'queue' 或 'steer'" } });
108
+ }
109
+ let content;
110
+ if (Array.isArray(body.content)) {
111
+ content = body.content;
112
+ } else if (typeof body.text === 'string' && body.text.trim()) {
113
+ content = [{ type: 'text', text: body.text }];
114
+ } else {
115
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 content 数组或 text' } });
116
+ }
117
+ if (content.length === 0) {
118
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'content 不能为空' } });
119
+ }
120
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
121
+ for (const block of content) {
122
+ if (!block || typeof block.type !== 'string') {
123
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'content 块缺少 type' } });
124
+ }
125
+ if (block.type === 'text') {
126
+ if (typeof block.text !== 'string' || !block.text.trim()) {
127
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'text 块内容为空' } });
128
+ }
129
+ } else if (block.type === 'image') {
130
+ if (!IMAGE_TYPES.has(block.mediaType) || typeof block.data !== 'string' || !block.data) {
131
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'image 块需合法 mediaType 与 base64 data' } });
132
+ }
133
+ } else {
134
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: `不支持的 content 块类型: ${block.type}` } });
135
+ }
136
+ }
137
+ // ── Slash 命令派发 ─────────────────────────────────────────────
138
+ // 已知命令:注册表执行(command/run + command/done 随事件流回传),响应标注 handledAs 供
139
+ // 客户端跳过「乐观 pending 气泡 + running 态」;未知命令/语法不符(execute 返回 undefined)
140
+ // 与网关未组合时回退普通消息(对齐桌面 miss 落入默认 sink,`/foo` 仍按文本发送)。
141
+ const singleText = singleTextOf(content);
142
+ if (singleText !== null && singleText.trim().startsWith('/')) {
143
+ const outcome = await executeCommand(ctx, body.sessionId, singleText.trim());
144
+ if (outcome.error) {
145
+ const failure = outcome.error.failure
146
+ || { code: 'command-error', message: outcome.error.message || String(outcome.error), details: {} };
147
+ return sendJson(res, 400, { ok: false, error: failure });
148
+ }
149
+ if (outcome.execution !== undefined) {
150
+ return sendJson(res, 200, { ok: true, accepted: true, handledAs: 'command' });
151
+ }
152
+ }
153
+ let promptResponse;
154
+ try {
155
+ promptResponse = await promptOnce(ctx, typeof body.clientMessageId === 'string' ? body.clientMessageId : '', {
156
+ sessionId: body.sessionId,
157
+ mode,
158
+ content,
159
+ });
160
+ } catch (error) {
161
+ const normalized = promptErrorObject(error);
162
+ if (normalized.code === MODEL_UNAVAILABLE_ERROR.code) invalidateSessionModels(ctx, req, body.sessionId);
163
+ return sendJson(res, 400, { ok: false, error: normalized });
164
+ }
165
+ const { error } = unwrap(promptResponse);
166
+ if (error) {
167
+ const normalized = promptErrorObject(error);
168
+ if (normalized.code === MODEL_UNAVAILABLE_ERROR.code) invalidateSessionModels(ctx, req, body.sessionId);
169
+ return sendJson(res, 400, { ok: false, error: normalized });
170
+ }
171
+ sendJson(res, 200, { ok: true, accepted: true });
172
+ }
173
+
174
+ /** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(与桌面 Web 端命令面板
175
+ * 同源——typertGateway commands.list,即服务端命令注册表对该 agent 生效的完整清单)。
176
+ * 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
177
+ * 客户端菜单据此只展示真实可执行的命令(不再硬编码)。 */
178
+ async function handleCommands(ctx, req, res) {
179
+ const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
180
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
181
+ const gateway = ctx.get && ctx.get('typertGateway');
182
+ if (!gateway) return sendJson(res, 200, { ok: true, items: [] });
183
+ try {
184
+ const descriptors = await gateway.invoke({
185
+ namespace: 'commands',
186
+ method: 'list',
187
+ args: { agentId: sessionId },
188
+ });
189
+ const items = Array.isArray(descriptors)
190
+ ? descriptors
191
+ .filter((d) => d && typeof d.name === 'string' && d.name && typeof d.description === 'string')
192
+ .map((d) => ({
193
+ name: d.name,
194
+ description: d.description,
195
+ ...(d.input && typeof d.input === 'object' && typeof d.input.hint === 'string' && d.input.hint
196
+ ? { input: { hint: d.input.hint } }
197
+ : {}),
198
+ }))
199
+ : [];
200
+ sendJson(res, 200, { ok: true, items });
201
+ } catch (error) {
202
+ // 目录拉取失败不阻塞输入区:降级为空清单(等价桌面网关缺失)。
203
+ sendJson(res, 200, { ok: true, items: [] });
204
+ }
205
+ }
206
+
207
+ /** POST /api/mobile/v1/session/read(token){sessionId}:清除完成提醒。 */
208
+ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
209
+ const body = await readJsonBody(req);
210
+ if (!body || typeof body.sessionId !== 'string' || !body.sessionId) {
211
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId }' } });
212
+ }
213
+ const deviceKey = canonicalDeviceKey(req, fallbackDeviceKey);
214
+ completionStateOf(ctx, deviceKey).delete(body.sessionId);
215
+ // Invalidate the cache and any build already reading history. Its generation
216
+ // check prevents a stale build from restoring `completed` after this ack.
217
+ invalidateSessionList(ctx, deviceKey);
218
+ // 桌面端的未读绿点不在宿主里(它在浏览器渲染进程的内存里),这里只记一条水位,
219
+ // 由插件的客户端半边拉走、在桌面页面里就地清掉。写盘失败不该让「已读」这个动作失败。
220
+ try {
221
+ readReceipt.recordRead(body.sessionId);
222
+ } catch {
223
+ // 回执台账只是尽力而为的旁路,读不到/写不动都不影响本次已读本身。
224
+ }
225
+ sendJson(res, 200, { ok: true, accepted: true });
226
+ }
227
+
228
+ /** POST /api/mobile/v1/session/stop(token){sessionId} */
229
+ async function handleStop(ctx, req, res) {
230
+ const body = await readJsonBody(req);
231
+ if (!body || typeof body.sessionId !== 'string') {
232
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId }' } });
233
+ }
234
+ const { error } = unwrap(await callSessions(ctx, 'cancel', { sessionId: body.sessionId }));
235
+ if (error) return sendJson(res, 400, { ok: false, error });
236
+ sendJson(res, 200, { ok: true, accepted: true });
237
+ }
238
+
239
+ /** POST /api/mobile/v1/session/rename(token){sessionId, title} */
240
+ async function handleRename(ctx, req, res) {
241
+ const body = await readJsonBody(req);
242
+ if (!body || typeof body.sessionId !== 'string' || typeof body.title !== 'string' || !body.title.trim()) {
243
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, title }' } });
244
+ }
245
+ // 改名结果本身是收敛的(同标题重放得到同一状态),但仍然按 requestId 重放:
246
+ // 重投不该再打一次宿主 RPC,也不该让 App 多等一次往返。
247
+ const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
248
+ const { value, error } = unwrap(await callSessions(ctx, 'rename', { sessionId: body.sessionId, title: body.title }));
249
+ if (error) return { status: 400, body: { ok: false, error } };
250
+ // 改完名字立刻丢掉列表缓存:App 保存后会马上 load(true),那一次拉到的如果还是
251
+ // 10 秒窗口内的旧行,用户看到的就是「保存了但侧栏没变」。
252
+ invalidateSessionList(ctx);
253
+ return { status: 200, body: { ok: true, ...value } };
254
+ });
255
+ sendJson(res, outcome.status, outcome.body);
256
+ }
257
+
258
+ /** POST /api/mobile/v1/session/fork(token){sessionId} → {ok, sessionId(新)} */
259
+ async function handleFork(ctx, req, res) {
260
+ const body = await readJsonBody(req);
261
+ if (!body || typeof body.sessionId !== 'string' || !body.sessionId
262
+ || (body.atSeq !== undefined && (!Number.isSafeInteger(body.atSeq) || body.atSeq < 0))) {
263
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, atSeq? }' } });
264
+ }
265
+ const payload = { sessionId: body.sessionId,
266
+ ...(body.atSeq === undefined ? {} : { atSeq: body.atSeq, increaseTitle: body.increaseTitle !== false }) };
267
+ // 分叉是**非幂等**写:重投一次就多一个子会话。带 requestId 时重投拿回同一次分叉的 id。
268
+ const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
269
+ const { value, error } = unwrap(await callSessions(ctx, 'fork', payload));
270
+ if (error) return { status: 400, body: { ok: false, error } };
271
+ // 新分叉出来的会话要立刻出现在列表里。
272
+ invalidateSessionList(ctx);
273
+ return { status: 200, body: { ok: true, ...value } };
274
+ });
275
+ sendJson(res, outcome.status, outcome.body);
276
+ }
277
+
278
+ module.exports = { singleTextOf, executeCommand, promptOnce, handleSend, handleCommands, handleSessionRead, handleStop, handleRename, handleFork };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 消息与统计快照:镜像推送与 /messages 分页共用的两条折叠路径。
5
+ *
6
+ * buildMessagesSnapshot(不解析 query)与 handleMessages(解析 beforeSeq/afterSeq/limit)
7
+ * 是同一套折叠逻辑的两种调用方式,放在一起是为了让「增量水位」的算法改动只发生在
8
+ * 一处——它们此前隔着一百多行,两边各写一遍归一化,迟早会漂移。
9
+ *
10
+ * 上游 session.history 不认 afterSeq(只认 beforeSeq/maxMessages),所以增量轮询在
11
+ * 插件内用较大的窗口(Math.max(limit, 200))拉最近若干事件后再按 seq 过滤;缺口
12
+ * 超过窗口时会漏,此时用 resyncRequired 让客户端回落到权威快照,而不是安静地少消息。
13
+ */
14
+
15
+ const { sendJson } = require('../shared/http-util');
16
+ const { unwrap } = require('./envelope.js');
17
+ const { callSessions } = require('./rpc.js');
18
+ const { deriveMessages } = require('./history-derive.js');
19
+ const { resolveEventAttachments } = require('./attachments.js');
20
+ const { createTextImageBudget } = require('./local-images.js');
21
+
22
+ const MESSAGES_DEFAULT_LIMIT = 50; // /messages 默认条数
23
+
24
+ /** 从 history 事件条目取事件序号(entry.event.seq,兼容裸事件 entry.seq)。 */
25
+ function eventSeqOf(entry) {
26
+ if (!entry) return undefined;
27
+ return entry.event && entry.event.seq !== undefined ? entry.event.seq : entry.seq;
28
+ }
29
+
30
+ /**
31
+ * 镜像快照用的消息折叠(不解析 query,无 afterSeq/beforeSeq/limit 分页):
32
+ * 与 handleMessages 的核心折叠逻辑保持一致,供 pushMirror 为活跃会话生成消息快照。
33
+ */
34
+ async function buildMessagesSnapshot(ctx, sessionId, limit = MESSAGES_DEFAULT_LIMIT) {
35
+ const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: limit }));
36
+ if (error) return { ok: false, error };
37
+ const events = (value && value.events) || [];
38
+ const budget = createTextImageBudget();
39
+ const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
40
+ const messages = deriveMessages(resolvedEvents);
41
+ const lastSeq = events.length ? (eventSeqOf(events[events.length - 1]) ?? 0) : 0;
42
+ const hasMore = !!(value && value.hasMore);
43
+ return { ok: true, items: messages, lastSeq, hasMore };
44
+ }
45
+
46
+ /**
47
+ * 镜像快照用的统计投影(与 handleStats 尾部投影读取保持一致):取整帧 projections,
48
+ * 并把 plan 归一化为 planMode,供 pushMirror 为活跃会话生成统计快照。
49
+ */
50
+ async function buildStatsSnapshot(ctx, sessionId) {
51
+ return statsProjections(ctx, sessionId);
52
+ }
53
+
54
+ /**
55
+ * 统计投影快照(handleStats 与 buildStatsSnapshot 共用)。
56
+ *
57
+ * 0.2.0 把整帧投影从 session.history/page 的返回值里拆走了(page 只回
58
+ * events/hasMore/cursor),所以「拉 1 条历史顺带取投影」这条路已经断了;改走
59
+ * host-bridge 翻译出来的 `apiProxy.sessions.projections({ request: { sessionId } })`,
60
+ * 返回值是 `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(plan.active 加可选的
61
+ * pending),排队条与计划条读的是 planMode。历史轮询是每 5 秒一次的热路径,多背一次
62
+ * 投影 RPC 划不来,所以宁可让统计单独走投影端点。
63
+ */
64
+ async function statsProjections(ctx, sessionId) {
65
+ // 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
66
+ const controller = new AbortController();
67
+ let value;
68
+ let error;
69
+ try {
70
+ ({ value, error } = unwrap(await callSessions(ctx, 'projections', { sessionId }, 'mobile', controller.signal)));
71
+ } catch (thrown) {
72
+ // 投影读不出来不该变成一个从路由层逃出去的 rejection:handleStats 要回 400,
73
+ // pushMirror 那边更是一条都不能抛(抛了整帧快照就丢了)。收敛成正常错误态。
74
+ error = thrown && typeof thrown === 'object' && typeof thrown.code === 'string'
75
+ ? { code: thrown.code, message: typeof thrown.message === 'string' ? thrown.message : '读会话投影失败' }
76
+ : { code: 'host-call-failed', message: thrown && thrown.message ? String(thrown.message) : '读会话投影失败' };
77
+ }
78
+ if (error) return { ok: false, error };
79
+ const projections = value && value.values && typeof value.values === 'object' ? value.values : {};
80
+ const plan = projections.plan;
81
+ return {
82
+ ok: true,
83
+ projections: plan && typeof plan === 'object' && typeof plan.active === 'boolean'
84
+ ? { ...projections, planMode: { active: plan.active, ...(typeof plan.pending === 'boolean' ? { pending: plan.pending } : {}) } }
85
+ : projections,
86
+ };
87
+ }
88
+
89
+ /** GET /api/mobile/v1/session/messages?sessionId=…&beforeSeq=…&afterSeq=…&limit=…(token) */
90
+ async function handleMessages(ctx, req, res) {
91
+ const url = new URL(req.url, 'http://x');
92
+ const sessionId = url.searchParams.get('sessionId');
93
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
94
+ const beforeSeqRaw = url.searchParams.get('beforeSeq');
95
+ const beforeSeq = beforeSeqRaw !== null && /^\d+$/.test(beforeSeqRaw) && Number.isSafeInteger(Number(beforeSeqRaw))
96
+ ? Number(beforeSeqRaw) : null;
97
+ const requestedLimit = Number(url.searchParams.get('limit'));
98
+ const limit = Number.isSafeInteger(requestedLimit) && requestedLimit > 0
99
+ ? Math.min(requestedLimit, 200) : MESSAGES_DEFAULT_LIMIT;
100
+ // 增量轮询水位:仅当为安全非负整数时使用(空/负数/小数/超界字符串一律忽略,退化为全量或历史分页)。
101
+ const afterSeqRaw = url.searchParams.get('afterSeq');
102
+ let afterSeq = null;
103
+ if (afterSeqRaw !== null && afterSeqRaw !== '' && /^\d+$/.test(afterSeqRaw)) {
104
+ const parsed = Number(afterSeqRaw);
105
+ if (Number.isSafeInteger(parsed)) afterSeq = parsed;
106
+ }
107
+ const payload = { sessionId, maxMessages: limit };
108
+ if (beforeSeq !== null) payload.beforeSeq = beforeSeq;
109
+ // 上游 session.history 不支持 afterSeq(只认 beforeSeq/maxMessages),故在插件内
110
+ // 用较大的窗口(Math.max(limit, 200))拉最近若干事件后再过滤 seq > afterSeq,覆盖常见轮询缺口;
111
+ // 若缺口超过窗口才会漏(简单且安全的折中,不动上游分页协议)。
112
+ if (afterSeq !== null) payload.maxMessages = Math.max(limit, 200);
113
+ const { value, error } = unwrap(await callSessions(ctx, 'history', payload));
114
+ if (error) return sendJson(res, 400, { ok: false, error });
115
+ const events = (value && value.events) || [];
116
+ // 增量:只保留水位之后的事件;非 afterSeq 调用时 events 原样保留(行为与之前完全一致)。
117
+ const filtered = afterSeq !== null ? events.filter((entry) => (eventSeqOf(entry) ?? -1) > afterSeq) : events;
118
+ // 一页历史共用一个内联图片预算:正文里的本机图片越多,越靠后的那些保持字面 markdown,
119
+ // 而不是让单次响应无限膨胀(见 local-images.js 的三级预算)。
120
+ const budget = createTextImageBudget();
121
+ const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
122
+ const messages = deriveMessages(resolvedEvents);
123
+ const hasMore = !!(value && value.hasMore);
124
+ const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
125
+ const oldestSeq = seqs.length ? Math.min(...seqs) : null;
126
+ // A truncated history window is not a complete incremental answer. Keep the
127
+ // cursor unchanged and tell capable clients to resync from an authoritative snapshot.
128
+ const resyncRequired = afterSeq !== null && hasMore && oldestSeq !== null && oldestSeq > afterSeq;
129
+ const lastSeq = afterSeq !== null
130
+ ? (resyncRequired ? afterSeq : (seqs.length ? Math.max(...seqs.filter((seq) => seq > afterSeq), afterSeq) : afterSeq))
131
+ : (seqs.length ? Math.max(...seqs) : 0);
132
+ sendJson(res, 200, {
133
+ ok: true,
134
+ items: messages,
135
+ hasMore,
136
+ lastSeq,
137
+ ...(oldestSeq !== null ? { oldestSeq } : {}),
138
+ ...(resyncRequired ? { resyncRequired: true } : {}),
139
+ });
140
+ }
141
+
142
+ module.exports = { eventSeqOf, buildMessagesSnapshot, statsProjections, buildStatsSnapshot, handleMessages };