dsh-mobile-plugin 0.1.93 → 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.
package/README.md CHANGED
@@ -56,7 +56,7 @@ curl http://127.0.0.1:3080/api/relay/pairing
56
56
 
57
57
  移动 API 需要 Bearer token。设备事件流 `GET /api/mobile/v1/events/stream` 使用 SSE 转发宿主事件。创建会话时可传 `workspaceId` 或 `cwd`;前者优先,并将会话归入指定工作区。移动端排序也会同步到宿主工作区。
58
58
 
59
- 配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。
59
+ 配对策略默认为 `code`(凭配对码放行);设为 `deny` 时拒绝所有新配对。自动归档只隐藏符合条件的闲置会话,不删除日志。`session_cleanup` 工具可归档会话,也可永久删除已归档会话;删除不可恢复,需要显式确认。删除是「排进待删队列」:会话立刻从列表里消失,日志与投影缓存等宿主下次启动时才从磁盘上删掉 —— 宿主没有「删除会话」的接口,进程活着的时候从它脚底下删文件会让它读到不存在的日志,那是未捕获异常,会带走整个宿主进程。
60
60
 
61
61
  ## 无头部署
62
62
 
@@ -19,6 +19,7 @@
19
19
  const fs = require('node:fs');
20
20
  const path = require('node:path');
21
21
  const paths = require('./shared/paths');
22
+ const { desktopViewSignal } = require('./shared/desktop-view-signal.js');
22
23
 
23
24
  /** 单个键(workspaceId)长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
24
25
  const MAX_KEY_LENGTH = 200;
@@ -36,6 +37,17 @@ function stateFile() {
36
37
  let cached = null;
37
38
  let loaded = false;
38
39
 
40
+ /**
41
+ * 折叠态内容指纹(键排序后序列化)。
42
+ *
43
+ * 只用来回答「这次上报真的改了折叠态吗」:两次 JSON.stringify 的键序不保证一致,
44
+ * 直接比会让同一份状态被认成两次变更。内容没变就既不重写文件、也不给手机推帧。
45
+ */
46
+ function expansionFingerprint(groupExpansion) {
47
+ const keys = Object.keys(groupExpansion).sort();
48
+ return JSON.stringify(keys.map((key) => [key, groupExpansion[key]]));
49
+ }
50
+
39
51
  /** 归一化上报的 groupExpansion:只保留布尔值、限长限量;形状不对返回 null。 */
40
52
  function normalizeGroupExpansion(input) {
41
53
  if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
@@ -91,11 +103,16 @@ function update(input, now = Date.now()) {
91
103
  const groupExpansion = normalizeGroupExpansion(input && input.groupExpansion);
92
104
  if (!groupExpansion) throw new Error('groupExpansion 必须是对象');
93
105
  const rawSource = input && (typeof input.source === 'string' ? input.source : input.storageKey);
106
+ const previous = load();
94
107
  const next = {
95
108
  groupExpansion,
96
109
  receivedAt: now,
97
110
  source: typeof rawSource === 'string' ? rawSource.slice(0, MAX_SOURCE_LENGTH) : '',
98
111
  };
112
+ // 折叠态没变就不落盘、不广播:客户端半边在页面重载 / 切窗口时会重发同一份。
113
+ // 什么都没变却写一次文件、给每台手机推一帧,等于把白问的轮询换成白推的帧。
114
+ const changed = !previous || expansionFingerprint(previous.groupExpansion) !== expansionFingerprint(groupExpansion);
115
+ if (!changed) return snapshot();
99
116
  const file = stateFile();
100
117
  fs.mkdirSync(path.dirname(file), { recursive: true });
101
118
  const tmp = `${file}.tmp`;
@@ -103,6 +120,9 @@ function update(input, now = Date.now()) {
103
120
  fs.renameSync(tmp, file);
104
121
  cached = next;
105
122
  loaded = true;
123
+ // 落盘之后再广播:订阅方收到帧会立刻回来读这条路(GET /desktop-view 读的是这份
124
+ // cached),先通知后落盘会把它引到上一份内容上。
125
+ desktopViewSignal.bump(next.receivedAt);
106
126
  return snapshot();
107
127
  }
108
128
 
package/lib/impl.js CHANGED
@@ -295,7 +295,7 @@ function apply(ctx) {
295
295
  const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
296
296
 
297
297
  // 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
298
- const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar })
298
+ const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot })
299
299
  // 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
300
300
  sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
301
301
 
@@ -39,6 +39,25 @@ const { observeCompletionEvent } = require('../mobile/completion.js');
39
39
  /** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
40
40
  const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
41
41
  const BG_TICK_INTERVAL_MS = 500;
42
+ /**
43
+ * 没人看的时候,后台 tick 降到这个节拍。
44
+ *
45
+ * 500ms 那条节奏是给「手机正在用」准备的:清单或工作区一变,手机半秒内就该看到。
46
+ * 但宿主一天里大部分时间没人看 —— 那时每 500ms 整轮重建一次清单/工作区,走中转的
47
+ * 还要把快照推过一条隧道,纯属空转。
48
+ *
49
+ * 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
50
+ * 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
51
+ */
52
+ const IDLE_TICK_INTERVAL_MS = 5_000;
53
+ /**
54
+ * 多久没有手机请求就算「没人看」。
55
+ *
56
+ * 走中转的手机每个请求都会记一次 `mobile/activity.cjs` 的 `note()`;局域网直连的手机
57
+ * 不走那道守卫所以记不到,那种情况另有 `listSignal.watching()` 兜着(有人订阅清单变化时
58
+ * 它为真)。两个判据都不成立时才降频。
59
+ */
60
+ const AUDIENCE_IDLE_MS = 30_000;
42
61
  /**
43
62
  * 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
44
63
  *
@@ -64,6 +83,19 @@ const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
64
83
 
65
84
  function createMirrorPusher(deps) {
66
85
  const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
86
+ /**
87
+ * 「手机最近来过没有」的取值器(`mobile/activity.cjs` 的 `snapshot` 形态)。
88
+ *
89
+ * 不传就按「有人在看」处理、维持原来 500ms 的节拍:降频是个优化,不是契约,
90
+ * 别让一处漏接线的装配把镜像悄悄降成 5s 一份。
91
+ */
92
+ const lastMobileAt = typeof deps.lastMobileAt === 'function' ? deps.lastMobileAt : null;
93
+ const hasAudience = () => {
94
+ if (listSignal.watching()) return true;
95
+ if (!lastMobileAt) return true;
96
+ const at = Number(lastMobileAt()?.lastMobileAt) || 0;
97
+ return at > 0 && Date.now() - at <= AUDIENCE_IDLE_MS;
98
+ };
67
99
 
68
100
  // 移动镜像推送状态(供诊断/状态面板观测:最近推送时间 + 结果)
69
101
  let mirrorPushAt = 0
@@ -565,9 +597,17 @@ function createMirrorPusher(deps) {
565
597
  let bgQueueBusy = false;
566
598
  // 上一次真的跑过冷会话轮转的时刻(见 BG_COLD_ROTATE_MIN_INTERVAL_MS)。
567
599
  let lastColdRotateAt = 0;
600
+ // 上一轮后台扫描真正跑起来的时刻(见 IDLE_TICK_INTERVAL_MS 的降频)。
601
+ let lastBgScanAt = 0;
568
602
 
569
603
  const processBgQueue = async () => {
570
604
  if (bgQueueBusy) return;
605
+ // 降频:没人看的时候不必每 500ms 重建一轮清单。判据用「手机最近有没有请求」,
606
+ // 而不是「隧道连没连上」—— 隧道连着但人没在用,一样是空转。
607
+ // 有人在看时不加额外下限,节奏仍由 500ms 的定时器自己决定。
608
+ // 事件驱动那条路径(pushSessionImmediately)不受这里影响,会话真的变了仍是立即推。
609
+ if (!hasAudience() && lastBgScanAt && Date.now() - lastBgScanAt < IDLE_TICK_INTERVAL_MS) return;
610
+ lastBgScanAt = Date.now();
571
611
  const online = tunnelConnected();
572
612
  // 隧道未连接时这个 500ms 的 tick 只在**有人听**的时候才有意义:构建出来的快照没有
573
613
  // 任何去处(pushMirrorPayload 会短路),但「清单变了」这件事仍然要说给手机听 ——
@@ -1,30 +1,33 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并删掉它们的日志。
4
+ * 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,把它们排进待删队列。
5
5
  *
6
6
  * 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
7
7
  * 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
8
8
  *
9
- * 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存只在 session-cleanup.cjs 里存在
10
- * (见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
9
+ * **这里不删磁盘**:只把 id 排进 session-cleanup.cjs 的待删队列,日志留到宿主下次启动时删
10
+ * (见那个文件的 deleteQueuedArchivedSessions)。宿主没有「删除会话」接口,进程活着的时候
11
+ * 它随时可能去打开那些文件 —— 文件不在就是一次未捕获的 ENOENT,宿主整个退出、桌面端闪退
12
+ * (2026-10-20 真机现场)。本进程只把 id 压进删除集,让手机列表立刻看不到它们。
11
13
  *
12
- * **这里不擦宿主的归档集合**(只把删掉的 id 记进本进程的删除集,挡住手机端列表里可能
13
- * 还没失效的旧快照)。进程里擦集合会把桌面端那份会话列表快照里、其实已经删掉的行放回
14
- * 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
15
- * 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
14
+ * 也**不擦宿主的归档集合**:进程里擦会把桌面端快照里那些其实已经没有日志的行放回正常列表。
15
+ * 归档集合的收尾同样在宿主启动时做,见 pruneOrphanedArchivedSessionsOnce。
16
16
  */
17
17
 
18
18
  const { sendJson, readJsonBody } = require('../shared/http-util');
19
19
  const { withIdempotency, requestIdOf } = require('./idempotency.js');
20
- const { canonicalDeviceKey, rememberDeletedSessionIds, deletedSessionIdsOf } = require('./session-cache.js');
20
+ const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
21
21
  const { invalidateSessionList } = require('./sessions.js');
22
22
  const { unwrap } = require('./envelope.js');
23
- const { deleteSessionData, sessionIdsWithoutAnyData } = require('../session-cleanup.cjs');
23
+ const { queueArchivedClearIds } = require('../session-cleanup.cjs');
24
24
 
25
25
  /**
26
26
  * POST /api/mobile/v1/workspace/clear-archived(token)
27
27
  * body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
28
+ *
29
+ * deleted = 这次被排进队列、手机上立刻消失的条数(含盘上早就没有数据的那些);日志要到
30
+ * 宿主下次启动才真正从磁盘上消失。
28
31
  */
29
32
  async function handleClearArchived(ctx, req, res) {
30
33
  const body = await readJsonBody(req, 64 * 1024);
@@ -41,17 +44,13 @@ async function handleClearArchived(ctx, req, res) {
41
44
  const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
42
45
  const selected = archivedIds.filter((id) => typeof id === 'string' && id);
43
46
  if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
44
- const removed = await deleteSessionData(selected);
45
- // 删的是列表里可见的行:手机端正开着列表,宿主那份快照可能还带着它们,先在本进程压住。
46
- // 盘上已经没有数据的归档 id 一并压住:上一次删到一半抛错时,已删的那批没记进删除集,
47
- // 宿主的进程内清单却还挂着它们 —— 不补记的话再点清理只回 0,手机上永远清不掉。
48
- const already = deletedSessionIdsOf(ctx);
49
- const cleared = new Set(removed);
50
- for (const id of sessionIdsWithoutAnyData(selected)) if (!already.has(id)) cleared.add(id);
51
- rememberDeletedSessionIds(ctx, cleared);
47
+ // 排不进队列就整次失败(路由回 500):回「已清理」却没排上,重启之后它们原样回来。
48
+ queueArchivedClearIds(selected);
49
+ // 宿主那份快照可能还带着它们,先在本进程压住:手机列表与归档列表立刻看不到。
50
+ rememberDeletedSessionIds(ctx, selected);
52
51
  // 不立刻失效的话,最长 10 秒还能点到已删的行。
53
52
  invalidateSessionList(ctx);
54
- return { status: 200, body: { ok: true, deleted: cleared.size } };
53
+ return { status: 200, body: { ok: true, deleted: selected.length } };
55
54
  });
56
55
  sendJson(res, outcome.status, outcome.body);
57
56
  }
@@ -6,9 +6,10 @@
6
6
  * 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
7
7
  * 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
8
8
  * 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
9
- * device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)
10
- * 与 device/heartbeat(心跳)三类自产帧,App 侧靠它们判断「这条流还活着」「该重拉列表」
11
- * 「该给某个会话补正文」并据此决定要不要把兜底轮询降频。
9
+ * device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
10
+ * device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
11
+ * 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
12
+ * 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
12
13
  *
13
14
  * 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
14
15
  * 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
@@ -19,6 +20,7 @@
19
20
 
20
21
  const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
21
22
  const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
23
+ const { desktopViewSignal: sharedDesktopViewSignal } = require('../shared/desktop-view-signal.js');
22
24
  const { writeSse: sharedWriteSse, stableEventId: sharedEventId } = require('../shared/sse-write.js');
23
25
 
24
26
  const DEDUP_WINDOW = 2048;
@@ -194,6 +196,22 @@ async function handleDeviceStream(ctx, req, res) {
194
196
  if (ac.signal.aborted) return;
195
197
  if (!writeFrame({ type: 'device/session-activity', sessionId: event.sessionId, seq: event.seq, at: event.at })) ac.abort();
196
198
  });
199
+ /**
200
+ * 自产的「桌面侧栏折叠态变了」帧。
201
+ *
202
+ * 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
203
+ * 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
204
+ * 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
205
+ * 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
206
+ *
207
+ * 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
208
+ * 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
209
+ * 同样必须直订 + 收尾注销,理由与上面两条一致。
210
+ */
211
+ const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
212
+ if (ac.signal.aborted) return;
213
+ if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: event.updatedAt, at: event.at })) ac.abort();
214
+ });
197
215
  /**
198
216
  * 连上就先补一帧「当前状态」。
199
217
  *
@@ -213,6 +231,12 @@ async function handleDeviceStream(ctx, req, res) {
213
231
  if (list && list.revision) {
214
232
  if (!writeFrame({ type: 'device/list-changed', revision: list.revision, at: list.at || Date.now() })) return false;
215
233
  }
234
+ // 桌面侧栏折叠态:手机从此只在变化时拉一次。老 App 不认识这一帧(走默认分支忽略),
235
+ // 所以补发它不会改变老客户端的任何行为。
236
+ const view = sharedDesktopViewSignal.snapshot();
237
+ if (view && view.updatedAt > 0) {
238
+ if (!writeFrame({ type: 'device/desktop-view-changed', updatedAt: view.updatedAt, at: view.at || Date.now() })) return false;
239
+ }
216
240
  for (const [sessionId, seq] of Object.entries(sharedSessionSignal.snapshot())) {
217
241
  if (!(seq > 0)) continue;
218
242
  if (!writeFrame({ type: 'device/session-activity', sessionId, seq, at: Date.now() })) return false;
@@ -279,6 +303,7 @@ async function handleDeviceStream(ctx, req, res) {
279
303
  clearInterval(keepAlive);
280
304
  unsubscribeSignal();
281
305
  unsubscribeActivity();
306
+ unsubscribeDesktopView();
282
307
  releaseSignal();
283
308
  try {
284
309
  if (!res.writableEnded) {
@@ -110,7 +110,6 @@ const ENDPOINTS = {
110
110
 
111
111
  // skills / subagents
112
112
  'skills.list': inv('skills', 'list'),
113
- 'subagents.history': inv('subagents', 'history'),
114
113
  'subagents.prompt': inv('subagents', 'prompt', (p) => ({
115
114
  request: {
116
115
  ...pick(p, ['parentSessionId', 'childSessionId', 'mode', 'content', 'clientTimeZone']),
@@ -21,6 +21,28 @@ function sessionHint(request) {
21
21
  return typeof sessionId === 'string' && sessionId ? sessionId : null;
22
22
  }
23
23
 
24
+ /**
25
+ * 这条单会话流该按哪种地址订阅。
26
+ *
27
+ * 子会话必须换成 `{kind:'subagent'}` 的 durable 地址:宿主 `validateAddress` 对
28
+ * `origin === 'subagent'` 的会话拿普通地址一律回 agent-busy,subscribe 直接失败 ——
29
+ * 表现就是「子代理页面打得开,但内容永远不自己更新」。请求体里带上 `parentSessionId`
30
+ * 就是「这是个子会话」的信号(与历史那条同源,见 stream.js 的 handleStream)。
31
+ */
32
+ function sessionAddress(payload) {
33
+ const sessionId = payload && payload.sessionId;
34
+ const parentSessionId = payload && payload.parentSessionId;
35
+ if (typeof parentSessionId === 'string' && parentSessionId) {
36
+ return {
37
+ kind: 'subagent',
38
+ parentSessionId,
39
+ childSessionId: sessionId,
40
+ mode: (payload && payload.mode) || 'continuable',
41
+ };
42
+ }
43
+ return { kind: 'session', sessionId };
44
+ }
45
+
24
46
  /** 一条流跑不通不该拖垮其余流:这里把异常吃掉,只丢这一条源的帧。 */
25
47
  async function* tolerate(frames, what, ctx) {
26
48
  try {
@@ -207,20 +229,28 @@ async function* deviceFrames(ctx, signal) {
207
229
  /** 旧 `events.mux`:设备级帧流,带 sessionId 时收窄成单会话视角。 */
208
230
  function muxFrames(ctx, request, signal) {
209
231
  const sessionId = sessionHint(request);
232
+ const payload = request && request.payload;
210
233
  return (async function* run() {
211
234
  if (!sessionId) {
212
235
  yield* deviceFrames(ctx, signal);
213
236
  return;
214
237
  }
238
+ const address = sessionAddress(payload);
239
+ // 子会话没有 job 面板(任务归属父会话),也就不订阅那一条:拿子会话 id 去问 job.list
240
+ // 只会白打一发,而且它的失败会被 tolerate 吞成一条警告。
215
241
  const control = openStream(ctx, 'session', 'control', {}, signal).then((frames) => tolerate(frames, 'session/control', ctx));
216
- const jobs = openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx));
242
+ const jobs = address.kind === 'session'
243
+ ? openStream(ctx, 'job', 'list', { request: { sessionId } }, signal).then((frames) => tolerate(frames, `job/${sessionId}`, ctx))
244
+ : null;
217
245
  // assistantStream: 逐块增量只走这条流的 assistant-stream 通知(见 followFramePayloads)。
218
246
  const follow = openStream(ctx, 'session', 'follow', {
219
- request: { address: { kind: 'session', sessionId }, maxMessages: 1, assistantStream: true },
247
+ request: { address, maxMessages: 1, assistantStream: true },
220
248
  }, signal).then((frames) => tolerate(frames, `session/${sessionId}`, ctx));
221
249
  const projections = projectionState();
222
250
  const followState = { turn: null, step: null, streaming: false };
223
- const sources = await Promise.all([['control', control], ['jobs', jobs], ['follow', follow]].map(([source, promise]) => promise.then(async function* wrap(iterable) {
251
+ const opening = [['control', control], ['follow', follow]];
252
+ if (jobs !== null) opening.push(['jobs', jobs]);
253
+ const sources = await Promise.all(opening.map(([source, promise]) => promise.then(async function* wrap(iterable) {
224
254
  for await (const frame of iterable) {
225
255
  if (source === 'follow') {
226
256
  // follow 一帧可能产出 0..n 个 mux 帧(事件原样 + 增量通知合成 chunk 事件)。
@@ -13,13 +13,36 @@ const { invoke, invokeEnvelope, ok, fail, pick, warn, deadline, openStream, SYNT
13
13
  * follow 拿游标(读完首帧立刻 break,网关会取消这条流),再翻一页历史。
14
14
  * 返回值把 records 拆回旧形状的 events(history-derive.js 只认 events/hasMore)。
15
15
  */
16
+ /**
17
+ * 这次 history 要读的是「谁」的地址。
18
+ *
19
+ * 普通会话是 `{kind:'session'}`;子会话必须换成**带父会话的 durable 地址**
20
+ * `{kind:'subagent'}` —— 宿主对 `origin === 'subagent'` 的会话拿普通地址一律回
21
+ * agent-busy(见 dsh-api-session-controller 的 validateAddress),这跟续话那条
22
+ * 是同一个门。`mode` 也要带:宿主会拿它跟子会话自己的 descriptor 对一遍。
23
+ */
24
+ function historyAddress(payload) {
25
+ const sessionId = payload && payload.sessionId;
26
+ const parentSessionId = payload && payload.parentSessionId;
27
+ if (typeof parentSessionId === 'string' && parentSessionId) {
28
+ return {
29
+ kind: 'subagent',
30
+ parentSessionId,
31
+ childSessionId: sessionId,
32
+ mode: (payload && payload.mode) || 'continuable',
33
+ };
34
+ }
35
+ return { kind: 'session', sessionId };
36
+ }
37
+
16
38
  async function historyViaPage(ctx, payload, signal) {
17
39
  const sessionId = payload && payload.sessionId;
18
40
  if (typeof sessionId !== 'string' || !sessionId) return fail({ code: 'bad-request', message: '缺少 sessionId' }, 'bad-request');
41
+ const address = historyAddress(payload);
19
42
  let throughSeq = -1;
20
43
  try {
21
44
  const frames = await openStream(ctx, 'session', 'follow', {
22
- request: { address: { kind: 'session', sessionId }, maxMessages: 1 },
45
+ request: { address, maxMessages: 1 },
23
46
  }, deadline(signal, SYNTHETIC_DEADLINE_MS));
24
47
  for await (const frame of frames) {
25
48
  if (frame && frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
@@ -31,7 +54,7 @@ async function historyViaPage(ctx, payload, signal) {
31
54
  return fail(error);
32
55
  }
33
56
  const request = {
34
- address: { kind: 'session', sessionId },
57
+ address,
35
58
  throughSeq,
36
59
  ...pick(payload, ['maxMessages', 'beforeSeq', 'turnWindow']),
37
60
  };
@@ -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 };
@@ -144,23 +144,15 @@ async function handleMessages(ctx, req, res) {
144
144
  let value;
145
145
  let error;
146
146
  try {
147
- if (hasSubagentAddress) {
148
- const historyPayload = {
149
- parentSessionId,
150
- childSessionId: sessionId,
151
- mode,
152
- maxMessages: payload.maxMessages,
153
- };
154
- if (payload.beforeSeq !== undefined) historyPayload.beforeSeq = payload.beforeSeq;
155
- const controller = new AbortController();
156
- ({ value, error } = unwrap(await ctx.apiProxy.subagents.history({
157
- rpcId: 'mobile',
158
- method: 'subagent.history',
159
- payload: historyPayload,
160
- }, controller.signal)));
161
- } else {
162
- ({ value, error } = unwrap(await callSessions(ctx, 'history', payload)));
163
- }
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)));
164
156
  } catch (thrown) {
165
157
  error = normalizeHistoryError(thrown);
166
158
  }