dsh-mobile-plugin 0.1.92 → 0.1.94

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 (38) hide show
  1. package/README.md +3 -1
  2. package/config/agent-presets/standard/agent.cordis.yml +7 -4
  3. package/lib/desktop-view.cjs +20 -0
  4. package/lib/impl.js +7 -2
  5. package/lib/mirror/pusher.cjs +40 -0
  6. package/lib/mobile/archive-clean.js +17 -18
  7. package/lib/mobile/attachments.js +23 -6
  8. package/lib/mobile/events.js +28 -3
  9. package/lib/mobile/history-derive.js +53 -7
  10. package/lib/mobile/host-bridge/endpoints.js +0 -1
  11. package/lib/mobile/host-bridge/event-synthesis.js +33 -3
  12. package/lib/mobile/host-bridge/shims.js +28 -4
  13. package/lib/mobile/monitor.js +135 -0
  14. package/lib/mobile/session-flags.js +63 -0
  15. package/lib/mobile/session-models.js +6 -0
  16. package/lib/mobile/session-preview.js +70 -0
  17. package/lib/mobile/session-send.js +8 -2
  18. package/lib/mobile/session-snapshot.js +13 -19
  19. package/lib/mobile/sessions.js +48 -91
  20. package/lib/mobile/stream.js +113 -16
  21. package/lib/mobile/uploads.js +14 -7
  22. package/lib/mobile/workspaces.js +17 -3
  23. package/lib/panel/index.html +7 -7
  24. package/lib/reconnect-scheduler.cjs +31 -4
  25. package/lib/relay/lan-network.cjs +156 -12
  26. package/lib/relay/lan-proxy.js +85 -7
  27. package/lib/relay/protocol.js +3 -3
  28. package/lib/relay/tunnel.js +17 -4
  29. package/lib/routes/control-plane.js +11 -5
  30. package/lib/routes/mobile-api.js +22 -1
  31. package/lib/session-cleanup.cjs +160 -27
  32. package/lib/shared/desktop-view-signal.js +66 -0
  33. package/lib/shared/http-util.js +50 -4
  34. package/lib/shared/list-revision.js +20 -1
  35. package/lib/shared/sse-write.js +52 -2
  36. package/lib/sidecar-facade-worker.cjs +16 -2
  37. package/lib/update-install.cjs +71 -13
  38. package/package.json +2 -2
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * GET /api/mobile/v1/monitor —— 安卓后台提醒服务专用的「状态位」视图(只读)。
5
+ *
6
+ * 为什么单开一条:那条前台服务活在 JS 之外(App 退到后台、进程被系统回收,只有它还在),
7
+ * 所以「这一轮跑完了 / 跑挂了 / 停下来等你回话」只能由它自己定时去问。它此前问的是完整
8
+ * 会话清单(`?fresh=1`),而那份清单为了画侧栏还捎着**最后一条消息预览** —— 预览要为最近
9
+ * 20 个会话各读一次 history。后台每 8 秒白读 20 次日志,只为判断「completed 翻没翻」。
10
+ *
11
+ * 这条只回监控需要的那几格:在不在跑、刚完成没有、在等谁回话、上一轮是不是跑挂的、标题。
12
+ * 并支持 `?rev=` 条件刷新:状态位与上次一模一样时回一句 `unchanged`,连 items 都不带
13
+ * (稳态下这一发是几十字节)。
14
+ *
15
+ * 完成态仍以 completion 状态机为准,与 `/sessions` 用同一本账:常态下这里**不读 history**,
16
+ * 只有「跑过、现在不在跑、结局还没定」的那几条才读一次 —— 那是唯一能把 failedTurn 折出来
17
+ * 的地方(事件流漏帧时全靠它,见 completion.js 的坑)。
18
+ *
19
+ * 刻意不进 `/sessions`:那边一旦按调用方分叉视图,镜像推送与 App 的写后刷新也会跟着
20
+ * 受影响,而这两条都要求「一份完整清单」。分路由比加参数安全。
21
+ */
22
+
23
+ const { sendJson } = require('../shared/http-util');
24
+ const { stableRevision, unchangedListBody } = require('../shared/list-revision.js');
25
+ const { callSessions } = require('./rpc.js');
26
+ const { unwrap } = require('./envelope.js');
27
+ const { listTitle } = require('./history-derive.js');
28
+ const {
29
+ canonicalDeviceKey, deletedSessionIdsOf, completionGenerationOf,
30
+ } = require('./session-cache.js');
31
+ const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
32
+ const { sessionPreviewFor } = require('./session-preview.js');
33
+ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
34
+
35
+ /**
36
+ * 一条会话在监控视图里的那几格。
37
+ *
38
+ * 字段名与完整清单**逐字一致**(sessionId / title / running / completed / pendingInteraction /
39
+ * lastTurnError),因为原生那边解析的是同一套字段:换名字就得同步改 Kotlin,而那段代码
40
+ * 在 App 之外,改错一次是「后台永远不提醒」这种看不见的故障。
41
+ */
42
+ function monitorItem(summary, state) {
43
+ const item = {
44
+ sessionId: summary.sessionId,
45
+ title: listTitle(summary),
46
+ running: summary.running === true,
47
+ completed: state ? state.completed === true : false,
48
+ };
49
+ const pending = pendingInteractionOf(summary.sessionId, summary);
50
+ if (pending) item.pendingInteraction = pending;
51
+ if (state && state.failure) item.lastTurnError = state.failure;
52
+ return item;
53
+ }
54
+
55
+ /**
56
+ * 状态位视图:一次宿主 list + 归档集合,**零次 history 读取**(除非有刚跑完还没定结局的)。
57
+ *
58
+ * 返回 `{ items, revision }`;宿主列表形状不对时返回 `{ degraded: true }`,宿主报错时返回
59
+ * `{ error }` —— 两种都由调用方照 `/sessions` 的口径回,别在这里编一个空清单:空清单会被
60
+ * 原生当成「所有会话都没了」,基线一清,下一轮就为所有正在跑的会话重新报一次完成。
61
+ */
62
+ async function buildMonitorState(ctx, deviceKey) {
63
+ const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile'));
64
+ if (error) return { error };
65
+ const summaries = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
66
+ if (summaries === null) return { degraded: true };
67
+
68
+ const generation = completionGenerationOf(ctx, deviceKey);
69
+ const deleted = deletedSessionIdsOf(ctx);
70
+ const { archived } = await workspaceSessionFlags(ctx);
71
+ const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
72
+ && !deleted.has(s.sessionId) && !archived.has(s.sessionId));
73
+
74
+ // 状态机跟着这份清单走:归档 / 删除的会话在这里被踢掉,与完整列表同一口径。
75
+ const states = updateCompletionStates(ctx, live, deviceKey);
76
+
77
+ // 结局还没定的那几条:跑过、现在不在跑、既没完成也没失败。常态下这个集合是空的。
78
+ const unsettled = live.filter((s) => {
79
+ if (s.running) return false;
80
+ const state = states.get(s.sessionId);
81
+ return state && state.wasRunning === true && state.running !== true
82
+ && state.completed !== true && !state.failure;
83
+ });
84
+ if (unsettled.length > 0) {
85
+ await Promise.all(unsettled.map(async (summary) => {
86
+ try {
87
+ const preview = await sessionPreviewFor(ctx, summary);
88
+ if (!preview.events.length) return;
89
+ // 读到这些历史的这一刻,别处可能刚发起一次写后刷新(它会 bump 代次);
90
+ // 用旧快照去确认结局等于把「还在跑的一轮」写成已完成。
91
+ if (completionGenerationOf(ctx, deviceKey) !== generation) return;
92
+ confirmCompletionFromHistory(states.get(summary.sessionId), preview.events, false);
93
+ } catch {
94
+ // 这一轮没读到就原样留着:判定条件(没完成、没失败)下一轮仍然成立,会再试。
95
+ }
96
+ }));
97
+ }
98
+
99
+ const items = live.map((summary) => monitorItem(summary, states.get(summary.sessionId)));
100
+ // 按 id 排序后再算指纹:宿主是按 updatedAt 排的,聊一句话顺序就换,指纹会跟着每轮都变,
101
+ // 条件刷新等于白做。监控视图不关心顺序。
102
+ items.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
103
+ return { items, revision: stableRevision(items) };
104
+ }
105
+
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();
111
+
112
+ let built;
113
+ try {
114
+ built = await buildMonitorState(ctx, deviceKey);
115
+ } catch (error) {
116
+ return sendJson(res, 500, {
117
+ ok: false,
118
+ error: { code: 'monitor-failed', message: (error && error.message) || '监控视图构建失败' },
119
+ });
120
+ }
121
+ if (built.error) return sendJson(res, 400, { ok: false, error: built.error });
122
+ if (built.degraded) {
123
+ // 与 /sessions 同一个码:中转见到 5xx 会退回上一份好副本,手机那侧因此是「没收到新东西」
124
+ // 而不是「一台会话都没有」。
125
+ return sendJson(res, 500, {
126
+ ok: false,
127
+ error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' },
128
+ });
129
+ }
130
+
131
+ const body = { ok: true, generatedAt: Date.now(), revision: built.revision, items: built.items };
132
+ sendJson(res, 200, unchangedListBody(body, rev) || body);
133
+ }
134
+
135
+ module.exports = { handleMonitor, buildMonitorState, monitorItem };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话行的两条共享判定:**哪些会话算这条设备的会话**、**它是不是在等人回话**。
5
+ *
6
+ * 抽出来的理由:`/sessions`(完整列表)与 `/monitor`(后台提醒的状态位视图)都要这两条,
7
+ * 而两份各写一遍的坏处不是多写几行,是判据会分叉 —— 表现成「侧栏里根本没有这条会话,
8
+ * 手机却为它响了一声」,或者「归档掉的会话还在后台提醒你」,从现象看不出是同一条判据
9
+ * 分家。所以让两条路由问同一个函数。
10
+ *
11
+ * 依赖方向:本文件只依赖 envelope / approvals / questions,**不** require sessions.js
12
+ * (否则 sessions.js 引它就成了环,见 module-split.test.cjs 的查环)。
13
+ */
14
+
15
+ const { unwrap } = require('./envelope.js');
16
+ const approvals = require('./approvals.js');
17
+ const questions = require('./questions.js');
18
+
19
+ /**
20
+ * 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
21
+ *
22
+ * - `archivedSessionIds`:已归档的会话。桌面端侧边栏据此把它们藏起来,而
23
+ * `sessions.list` 自身**不过滤**归档,所以移动端必须自己读这个集合:此前完全没读,
24
+ * App 的「归档会话」写是写成功了(宿主确实把 id 记进了 archivedSessionIds),列表却
25
+ * 照旧把那一行发回手机,用户看到的就是一次毫无反馈的空操作。
26
+ * - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
27
+ * 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
28
+ * 否则 App 既画不出置顶标记、也排不到工作区最前。
29
+ *
30
+ * 两个集合合并成一次 RPC:它们同源同一次快照,而会话列表每 10 秒就会重建一次,
31
+ * 分两次 `workspace.list` 等于平白给手机多塞一轮往返。
32
+ *
33
+ * 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
34
+ * RPC 抖动让整个会话列表凭空变空。
35
+ */
36
+ async function workspaceSessionFlags(ctx) {
37
+ try {
38
+ const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
39
+ const value = unwrap(resp).value;
40
+ const idSetOf = (field) => new Set(
41
+ value && Array.isArray(value[field]) ? value[field].filter(id => typeof id === 'string') : [],
42
+ );
43
+ return { archived: idSetOf('archivedSessionIds'), pinned: idSetOf('pinnedSessionIds') };
44
+ } catch {
45
+ return { archived: new Set(), pinned: new Set() };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 「这条会话是不是在等人回话」以及等的是什么。
51
+ *
52
+ * 宿主摘要里带就用它的(0.1.x / 桌面口径);0.2.x 的宿主摘要**不带**这个字段(桌面上
53
+ * 那个标记是桌面客户端自己算的),这时用两道桥的账 —— 少了这一条,手机在列表页连黄点
54
+ * 都不会亮,后台也不会为「等你审批」响一声。审批优先:一次 turn 里审批总是挡在提问前面。
55
+ */
56
+ function pendingInteractionOf(sessionId, summary) {
57
+ const reported = summary && summary.pendingInteraction;
58
+ if (reported === 'approval' || reported === 'plan-review' || reported === 'question') return reported;
59
+ if (approvals.hasPending(sessionId)) return 'approval';
60
+ return questions.pendingKind(sessionId);
61
+ }
62
+
63
+ module.exports = { workspaceSessionFlags, pendingInteractionOf };
@@ -33,6 +33,12 @@ function safeModelsValue(value) {
33
33
  }
34
34
  }
35
35
  if (typeof value.routable === 'boolean') result.routable = value.routable;
36
+ if (Array.isArray(value.routableProviders)) {
37
+ result.routableProviders = [...new Set(value.routableProviders
38
+ .filter((provider) => typeof provider === 'string' && provider.length > 0)
39
+ .slice(0, 128)
40
+ .map((provider) => provider.slice(0, 256)))];
41
+ }
36
42
  if (Array.isArray(value.groups)) {
37
43
  result.groups = value.groups
38
44
  .filter((group) => group && typeof group.id === 'string' && typeof group.name === 'string' && Array.isArray(group.models))
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 会话列表的「最后一条消息」预览:为最近 / 运行中的会话补一次 history 折叠。
5
+ *
6
+ * 会话列表的标题与行数据都来自宿主 `sessions.list` 摘要,只有 `lastMessage` 要读日志,
7
+ * 属于重活,所以只对预览子集做(见 sessions.js 的 SESSIONS_PREVIEW_LIMIT)。
8
+ *
9
+ * 同一份预览按会话指纹复用:运行中的会话 `updatedAt` 几乎每轮都在变,指纹于是每轮都变,
10
+ * 10 秒的列表缓存一到期就要为每个运行中会话重读一次 history;一轮生成里手机侧 15s
11
+ * 轮询会反复撞上「指纹变了但预览内容其实没变」的中间态,所以另加一个更短的窗口。
12
+ * 非运行中的会话仍走指纹判定(它们的 updatedAt 不变,指纹不变,本来就不会重读)。
13
+ */
14
+
15
+ const { callSessions } = require('./rpc.js');
16
+ const { unwrap } = require('./envelope.js');
17
+ const { deriveMessages, lastMessageOf } = require('./history-derive.js');
18
+ const {
19
+ sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
20
+ } = require('./session-cache.js');
21
+
22
+ /** 补预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)。 */
23
+ const HISTORY_FOR_ENRICH = 10;
24
+ /** 运行中会话的预览重读下限。 */
25
+ const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
26
+
27
+ function sessionPreviewFingerprint(summary) {
28
+ return `${summary.updatedAt || 0}:${summary.running ? 'running' : 'idle'}:${summary.blank ? 'blank' : 'content'}`;
29
+ }
30
+
31
+ /** Read one preview at most once per unchanged session revision. */
32
+ async function sessionPreviewFor(ctx, summary) {
33
+ const sessionId = summary.sessionId;
34
+ const fingerprint = sessionPreviewFingerprint(summary);
35
+ const cached = sessionPreviewStateOf(ctx).get(sessionId);
36
+ if (cached?.fingerprint === fingerprint) return { events: [], lastMessage: cached.lastMessage };
37
+
38
+ const inFlight = sessionPreviewInFlightStateOf(ctx);
39
+ const existing = inFlight.get(sessionId);
40
+ if (existing?.fingerprint === fingerprint) return existing.promise;
41
+
42
+ // 运行中会话的节流(见 RUNNING_PREVIEW_MIN_INTERVAL_MS):窗口内复用上一次预览,
43
+ // 不为「updatedAt 又动了一下」再读一遍日志。返回空 events 与 cache-hit 分支一致
44
+ // ——完成态确认只在真正读回历史时进行,本轮不复用它的边沿判定。
45
+ const clock = sessionPreviewClockOf(ctx);
46
+ if (cached && summary.running && Date.now() - (clock.get(sessionId) || 0) < RUNNING_PREVIEW_MIN_INTERVAL_MS) {
47
+ return { events: [], lastMessage: cached.lastMessage };
48
+ }
49
+ clock.set(sessionId, Date.now());
50
+
51
+ const promise = Promise.resolve(callSessions(ctx, 'history', {
52
+ sessionId,
53
+ maxMessages: HISTORY_FOR_ENRICH,
54
+ })).then((response) => {
55
+ const h = unwrap(response);
56
+ if (h.error) return { events: [], lastMessage: null };
57
+ const events = (h.value && h.value.events) || [];
58
+ const messages = deriveMessages(events);
59
+ const last = lastMessageOf(messages);
60
+ const lastMessage = last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null;
61
+ sessionPreviewStateOf(ctx).set(sessionId, { fingerprint, lastMessage });
62
+ return { events, lastMessage };
63
+ }).finally(() => {
64
+ if (inFlight.get(sessionId)?.promise === promise) inFlight.delete(sessionId);
65
+ });
66
+ inFlight.set(sessionId, { fingerprint, promise });
67
+ return promise;
68
+ }
69
+
70
+ module.exports = { HISTORY_FOR_ENRICH, RUNNING_PREVIEW_MIN_INTERVAL_MS, sessionPreviewFingerprint, sessionPreviewFor };
@@ -71,7 +71,13 @@ async function executeCommand(ctx, sessionId, line) {
71
71
  */
72
72
  function promptOnce(ctx, clientMessageId, payload) {
73
73
  const rpcId = mobilePromptRpcId(clientMessageId);
74
- if (!clientMessageId) return callSessions(ctx, 'prompt', payload, rpcId);
74
+ // 把这一发认回去的身份一起送进宿主的 prompt 请求(`request.requestId`,见
75
+ // host-bridge/endpoints.js)。宿主要是不认识它,user 消息的 `source.rpcId` 就是那个
76
+ // 端点自己生成的 `mobile-prompt-<uuid>` —— App 那边 `clientMessageId` 永远对不上,
77
+ // 只能靠「同文本 + 同图数」猜着认领乐观行(2026-10-20 真机:自己发的图一直「发送中」,
78
+ // 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
79
+ const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
80
+ if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
75
81
  const state = sendIdempotencyStateOf(ctx);
76
82
  const key = `${payload.sessionId}:${clientMessageId}`;
77
83
  const now = Date.now();
@@ -79,7 +85,7 @@ function promptOnce(ctx, clientMessageId, payload) {
79
85
  if (existing && now - existing.createdAt < 60_000) return existing.promise;
80
86
  if (existing) state.delete(key);
81
87
 
82
- const promise = Promise.resolve(callSessions(ctx, 'prompt', payload, rpcId));
88
+ const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
83
89
  // Keep the short-lived record so an in-flight/concurrent send with the same
84
90
  // clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
85
91
  // not poison later retries (a transient error should be retryable), so the
@@ -55,8 +55,10 @@ async function buildMessagesSnapshot(ctx, sessionId, limit = MESSAGES_DEFAULT_LI
55
55
  const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: limit }));
56
56
  if (error) return { ok: false, error };
57
57
  const events = (value && value.events) || [];
58
+ // 引用模式:图片只下发 attachmentId,不取字节(见 attachments.js)。正文里本机图片的
59
+ // 内联仍走 budget,与之前一致。
58
60
  const budget = createTextImageBudget();
59
- const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
61
+ const resolvedEvents = await Promise.all(events.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
60
62
  const messages = deriveMessages(resolvedEvents);
61
63
  const lastSeq = events.length ? (eventSeqOf(events[events.length - 1]) ?? 0) : 0;
62
64
  const hasMore = !!(value && value.hasMore);
@@ -142,23 +144,15 @@ async function handleMessages(ctx, req, res) {
142
144
  let value;
143
145
  let error;
144
146
  try {
145
- if (hasSubagentAddress) {
146
- const historyPayload = {
147
- parentSessionId,
148
- childSessionId: sessionId,
149
- mode,
150
- maxMessages: payload.maxMessages,
151
- };
152
- if (payload.beforeSeq !== undefined) historyPayload.beforeSeq = payload.beforeSeq;
153
- const controller = new AbortController();
154
- ({ value, error } = unwrap(await ctx.apiProxy.subagents.history({
155
- rpcId: 'mobile',
156
- method: 'subagent.history',
157
- payload: historyPayload,
158
- }, controller.signal)));
159
- } else {
160
- ({ value, error } = unwrap(await callSessions(ctx, 'history', payload)));
161
- }
147
+ // 子会话走**同一条** session.page(historyViaPage),只是地址换成带父会话的 durable
148
+ // 地址:宿主 0.2.1 起子代理已经没有 `subagents.history` 这个远程方法了(它只剩
149
+ // prompt / interrupt / list),继续调它就是 `host-unavailable` → 手机端一条
150
+ // 「请求失败 (HTTP 503)」。宿主自己的 page/follow 本来就认 `{kind:'subagent'}` 地址
151
+ // (见 validateAddress),传对地址即可,不该另借一条已被删掉的接口。
152
+ const historyPayload = hasSubagentAddress
153
+ ? { ...payload, parentSessionId, mode }
154
+ : payload;
155
+ ({ value, error } = unwrap(await callSessions(ctx, 'history', historyPayload)));
162
156
  } catch (thrown) {
163
157
  error = normalizeHistoryError(thrown);
164
158
  }
@@ -169,7 +163,7 @@ async function handleMessages(ctx, req, res) {
169
163
  // 一页历史共用一个内联图片预算:正文里的本机图片越多,越靠后的那些保持字面 markdown,
170
164
  // 而不是让单次响应无限膨胀(见 local-images.js 的三级预算)。
171
165
  const budget = createTextImageBudget();
172
- const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget }).then((event) => entry.event ? { ...entry, event } : event)));
166
+ const resolvedEvents = await Promise.all(filtered.map((entry) => resolveEventAttachments(ctx, sessionId, entry.event || entry, { budget, referencesOnly: true }).then((event) => entry.event ? { ...entry, event } : event)));
173
167
  const messages = deriveMessages(resolvedEvents);
174
168
  const hasMore = !!(value && value.hasMore);
175
169
  const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
@@ -20,22 +20,25 @@
20
20
  * session-mirror.js 镜像整帧构建
21
21
  * session-goal.js 目标条动作
22
22
  * session-presets.js Agent 预设目录
23
+ * session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
23
24
  *
24
- * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认)、GET /session、
25
- * GET /session/stats、新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
25
+ * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
26
+ * GET /session(单会话摘要)、GET /session/head(正文水位探针)、GET /session/stats、
27
+ * 新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
26
28
  * 所以 lib/mobile 下所有 import 路径一行都不用改。
27
29
  */
28
30
 
29
31
  const { sendJson, readJsonBody } = require('../shared/http-util');
30
32
  const { isFreshRequest } = require('../shared/fresh-request');
31
- const { listRevision } = require('../shared/list-revision.js');
33
+ const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
34
+ // 正文水位信号:session/head 探针直接读它的快照,**不**为了「看起来有值」去现读历史。
35
+ const { sessionSignal } = require('../shared/session-signal.js');
32
36
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
33
37
  const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
34
38
  const { unwrap } = require('./envelope.js');
35
39
  const { callSessions } = require('./rpc.js');
36
40
  const {
37
41
  canonicalDeviceKey, sessionListResponseState, invalidateSessionList, deletedSessionIdsOf,
38
- sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
39
42
  completionGenerationOf, bumpCompletionGeneration,
40
43
  completionStateOf, sessionModelsStateOf, invalidateSessionModels, sendIdempotencyStateOf,
41
44
  } = require('./session-cache.js');
@@ -49,94 +52,39 @@ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSession
49
52
  const { handleSessionSearch } = require('./session-search.js');
50
53
  const { handleAgentPresets } = require('./session-presets.js');
51
54
  const { handleGoalAction } = require('./session-goal.js');
52
- const approvals = require('./approvals.js');
53
- const questions = require('./questions.js');
55
+ const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
56
+ // 预览(lastMessage 补全)与它自己的重读节流:只读日志、不发写,单列一个子模块。
57
+ const { HISTORY_FOR_ENRICH, sessionPreviewFor } = require('./session-preview.js');
54
58
 
55
- const HISTORY_FOR_ENRICH = 10; // 补标题/预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)
56
59
  const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
57
60
  const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机轮询对桌面的冲击)
58
- /**
59
- * 运行中会话的预览重读下限。
60
- *
61
- * 运行中的会话 updatedAt 几乎每轮都在变,`sessionPreviewFingerprint` 于是每轮都变,
62
- * 10 秒的列表缓存一到期就要为每个运行中会话重读一次 history(HISTORY_FOR_ENRICH 条)。
63
- * 一轮生成里手机侧 15s 轮询会反复撞上「指纹变了但预览内容其实没变」的中间态。
64
- * 这里给运行中会话单独加一个更短的窗口:窗口内复用上一次预览,不重读日志。
65
- * 非运行中的会话仍走指纹判定(它们的 updatedAt 不变,指纹不变,本来就不会重读)。
66
- */
67
- const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
68
61
 
69
62
  /**
70
- * 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
63
+ * GET /api/mobile/v1/session/head?sessionId=…&parentSessionId=…&mode=…(token)
71
64
  *
72
- * - `archivedSessionIds`:已归档的会话。桌面端侧边栏据此把它们藏起来,而
73
- * `sessions.list` 自身**不过滤**归档,所以移动端必须自己读这个集合:此前完全没读,
74
- * App 的「归档会话」写是写成功了(宿主确实把 id 记进了 archivedSessionIds),列表却
75
- * 照旧把那一行发回手机,用户看到的就是一次毫无反馈的空操作。
76
- * - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
77
- * 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
78
- * 否则 App 既画不出置顶标记、也排不到工作区最前。
65
+ * 「这条会话的正文到哪个水位了」——App 进对话前先问一句,跟本地缓存水位一比就知道要不要
66
+ * 重新拉全量历史。几十字节换几兆。
79
67
  *
80
- * 两个集合合并成一次 RPC:它们同源同一次快照,而会话列表每 10 秒就会重建一次,
81
- * 分两次 `workspace.list` 等于平白给手机多塞一轮往返。
68
+ * `seq` 取 `sessionSignal.peek(sessionId)`:镜像推送每轮都会 bump,语义正是「快照里真正
69
+ * 装上的消息的最大 seq」,与 App 的缓存水位同一个 seq 空间。
82
70
  *
83
- * 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
84
- * RPC 抖动让整个会话列表凭空变空。
71
+ * `known` = `seq > 0`。**信号里没有时如实回 `known:false`**(插件刚重启、还没轮到推这个
72
+ * 会话),让 App 退回全量拉取;绝不为了「看起来有值」现读一次历史——那样这个探针本身
73
+ * 就变成了它要省掉的那次请求。
74
+ *
75
+ * 子代理(带 parentSessionId)直接回 `{seq:0, known:false}`:镜像里刻意不存子会话正文
76
+ * (见 dev-map A3-14 的「子会话的正文刻意不进镜像」),没有水位可言。
85
77
  */
86
- async function workspaceSessionFlags(ctx) {
87
- try {
88
- const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
89
- const value = unwrap(resp).value;
90
- const idSetOf = (field) => new Set(
91
- value && Array.isArray(value[field]) ? value[field].filter(id => typeof id === 'string') : [],
92
- );
93
- return { archived: idSetOf('archivedSessionIds'), pinned: idSetOf('pinnedSessionIds') };
94
- } catch {
95
- return { archived: new Set(), pinned: new Set() };
96
- }
97
- }
98
-
99
- function sessionPreviewFingerprint(summary) {
100
- return `${summary.updatedAt || 0}:${summary.running ? 'running' : 'idle'}:${summary.blank ? 'blank' : 'content'}`;
101
- }
102
-
103
- /** Read one preview at most once per unchanged session revision. */
104
- async function sessionPreviewFor(ctx, summary) {
105
- const sessionId = summary.sessionId;
106
- const fingerprint = sessionPreviewFingerprint(summary);
107
- const cached = sessionPreviewStateOf(ctx).get(sessionId);
108
- if (cached?.fingerprint === fingerprint) return { events: [], lastMessage: cached.lastMessage };
109
-
110
- const inFlight = sessionPreviewInFlightStateOf(ctx);
111
- const existing = inFlight.get(sessionId);
112
- if (existing?.fingerprint === fingerprint) return existing.promise;
113
-
114
- // 运行中会话的节流(见 RUNNING_PREVIEW_MIN_INTERVAL_MS):窗口内复用上一次预览,
115
- // 不为「updatedAt 又动了一下」再读一遍日志。返回空 events 与 cache-hit 分支一致
116
- // ——完成态确认只在真正读回历史时进行,本轮不复用它的边沿判定。
117
- const clock = sessionPreviewClockOf(ctx);
118
- if (cached && summary.running && Date.now() - (clock.get(sessionId) || 0) < RUNNING_PREVIEW_MIN_INTERVAL_MS) {
119
- return { events: [], lastMessage: cached.lastMessage };
78
+ async function handleSessionHead(_ctx, req, res) {
79
+ const url = new URL(req.url, 'http://x');
80
+ const sessionId = url.searchParams.get('sessionId');
81
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
82
+ const parentSessionId = url.searchParams.get('parentSessionId');
83
+ if (parentSessionId !== null && parentSessionId !== '') {
84
+ return sendJson(res, 200, { ok: true, seq: 0, known: false });
120
85
  }
121
- clock.set(sessionId, Date.now());
122
-
123
- const promise = Promise.resolve(callSessions(ctx, 'history', {
124
- sessionId,
125
- maxMessages: HISTORY_FOR_ENRICH,
126
- })).then((response) => {
127
- const h = unwrap(response);
128
- if (h.error) return { events: [], lastMessage: null };
129
- const events = (h.value && h.value.events) || [];
130
- const messages = deriveMessages(events);
131
- const last = lastMessageOf(messages);
132
- const lastMessage = last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null;
133
- sessionPreviewStateOf(ctx).set(sessionId, { fingerprint, lastMessage });
134
- return { events, lastMessage };
135
- }).finally(() => {
136
- if (inFlight.get(sessionId)?.promise === promise) inFlight.delete(sessionId);
137
- });
138
- inFlight.set(sessionId, { fingerprint, promise });
139
- return promise;
86
+ const seq = sessionSignal.peek(sessionId);
87
+ sendJson(res, 200, { ok: true, seq, known: seq > 0 });
140
88
  }
141
89
 
142
90
  /** GET /api/mobile/v1/sessions(token) */
@@ -153,6 +101,10 @@ async function handleSessions(ctx, req, res) {
153
101
  // 与 fresh 的区别:它只绕开这层缓存,不需要作废在途构建,也不 bump 朝代号(没有写入)。
154
102
  const url = new URL(req.url, 'http://x');
155
103
  const archivedOnly = url.searchParams.get('archived') === '1';
104
+ // 条件刷新(?rev=<上次的 revision>):内容没变就只回一句 unchanged,不带 items。
105
+ // fresh=1(写后刷新必须拿到真数据)与 archived=1(归档子集不是同一份内容)一律不走。
106
+ const rev = (url.searchParams.get('rev') || '').trim();
107
+ const revEligible = !wantFresh && !archivedOnly;
156
108
  // 只有新客户端显式带分页参数时才切片;旧客户端不带参数仍拿到完整归档列表。
157
109
  const hasPaging = archivedOnly && (url.searchParams.has('offset') || url.searchParams.has('limit'));
158
110
  const parsedOffset = Number(url.searchParams.get('offset') ?? 0);
@@ -168,13 +120,21 @@ async function handleSessions(ctx, req, res) {
168
120
  cache.updatedAt = 0;
169
121
  generation = bumpCompletionGeneration(ctx, deviceKey);
170
122
  }
123
+ // 条件刷新的转换点:拿到**最终要回的 body**之后才判,且只在上面那两条短路条件
124
+ // (rev 缺失/为空、fresh=1、archived=1)都不成立时才认。unchanged 响应本身不写缓存、
125
+ // 也不进在途构建的合流判定——它只是同一份内容的一句话说清。
126
+ const respond = (status, body) => {
127
+ const unchanged = status === 200 && revEligible ? unchangedListBody(body, rev) : null;
128
+ return sendJson(res, status, unchanged || body);
129
+ };
130
+
171
131
  if (!bypassCache && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
172
- return sendJson(res, 200, cache.value);
132
+ return respond(200, cache.value);
173
133
  }
174
134
 
175
135
  if (!bypassCache && cache.promise) {
176
136
  const response = await cache.promise;
177
- return sendJson(res, response.status, response.body);
137
+ return respond(response.status, response.body);
178
138
  }
179
139
 
180
140
  // 变更信号(镜像推送调度器先看见清单变了)会调 dropSessionListCache 丢掉这层缓存,
@@ -197,7 +157,7 @@ async function handleSessions(ctx, req, res) {
197
157
  cache.updatedAt = Date.now();
198
158
  }
199
159
  }
200
- return sendJson(res, response.status, response.body);
160
+ return respond(response.status, response.body);
201
161
  } finally {
202
162
  if (cache.promise === promise) cache.promise = null;
203
163
  }
@@ -238,12 +198,8 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
238
198
  // 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
239
199
  const rows = allSummaries.map((s) => {
240
200
  const completionState = completionStatesForContext.get(s.sessionId);
241
- // 待处理标记:宿主摘要里带就用它的(0.1.x / 桌面口径);0.2.x 的宿主摘要**不带**这个
242
- // 字段(桌面上那个标记是桌面客户端自己算的),这时用两道桥的账 —— 少了这一条,手机在
243
- // 列表页连黄点都不会亮。审批优先(一次 turn 里审批总是挡在提问前面)。
244
- const pendingInteraction = (s.pendingInteraction === 'approval' || s.pendingInteraction === 'plan-review' || s.pendingInteraction === 'question')
245
- ? s.pendingInteraction
246
- : (approvals.hasPending(s.sessionId) ? 'approval' : questions.pendingKind(s.sessionId));
201
+ // 待处理标记:判据与后台监控共用一份(见 session-flags.js),别再在这里写第二份。
202
+ const pendingInteraction = pendingInteractionOf(s.sessionId, s);
247
203
  const base = {
248
204
  sessionId: s.sessionId,
249
205
  title: listTitle(s),
@@ -455,6 +411,7 @@ module.exports = {
455
411
  SESSIONS_PREVIEW_LIMIT,
456
412
  handleSessions,
457
413
  handleSession,
414
+ handleSessionHead,
458
415
  handleStats,
459
416
  handleNew,
460
417
  handleQueueUpdate,