dsh-mobile-plugin 0.3.1 → 0.4.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 (96) hide show
  1. package/config/agent-presets/standard/agent.cordis.yml +7 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +8 -8
  3. package/host.js +108 -39
  4. package/lib/client.js +29 -82
  5. package/lib/desktop-view.cjs +5 -10
  6. package/lib/git/read.js +6 -8
  7. package/lib/host-compat.cjs +120 -0
  8. package/lib/host-guard.cjs +9 -39
  9. package/lib/host-patches.cjs +5 -16
  10. package/lib/http/routing.js +7 -27
  11. package/lib/impl.js +6 -8
  12. package/lib/mirror/pusher.cjs +33 -85
  13. package/lib/mirror-snapshot.cjs +6 -24
  14. package/lib/mobile/agent-presets.js +6 -26
  15. package/lib/mobile/approvals.js +18 -44
  16. package/lib/mobile/archive-clean.js +10 -19
  17. package/lib/mobile/attachments.js +11 -36
  18. package/lib/mobile/channels.js +15 -41
  19. package/lib/mobile/credential-upgrade.js +7 -16
  20. package/lib/mobile/credentials.js +5 -21
  21. package/lib/mobile/directory-browse.js +8 -27
  22. package/lib/mobile/directory-listing.js +10 -23
  23. package/lib/mobile/drive-roots.js +59 -0
  24. package/lib/mobile/envelope.js +4 -15
  25. package/lib/mobile/events.js +35 -65
  26. package/lib/mobile/extensions.js +5 -8
  27. package/lib/mobile/git-repos.js +6 -15
  28. package/lib/mobile/git.js +11 -19
  29. package/lib/mobile/history-derive.js +23 -41
  30. package/lib/mobile/host-bridge/endpoints.js +4 -6
  31. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  32. package/lib/mobile/host-bridge/shims.js +12 -22
  33. package/lib/mobile/host-bridge.js +8 -20
  34. package/lib/mobile/host-settings.js +9 -19
  35. package/lib/mobile/inbox-clean.js +7 -24
  36. package/lib/mobile/local-images.js +11 -17
  37. package/lib/mobile/monitor.js +8 -17
  38. package/lib/mobile/pairing.js +7 -19
  39. package/lib/mobile/plugin-self.js +5 -22
  40. package/lib/mobile/plugin-update.js +8 -27
  41. package/lib/mobile/plugins.js +6 -24
  42. package/lib/mobile/questions.js +15 -40
  43. package/lib/mobile/references.js +11 -31
  44. package/lib/mobile/relay-control.js +6 -27
  45. package/lib/mobile/rpc.js +5 -13
  46. package/lib/mobile/schedule.js +8 -33
  47. package/lib/mobile/self-check.cjs +9 -36
  48. package/lib/mobile/session-cache.js +13 -19
  49. package/lib/mobile/session-file.js +8 -21
  50. package/lib/mobile/session-flags.js +7 -13
  51. package/lib/mobile/session-mirror.js +8 -15
  52. package/lib/mobile/session-models.js +3 -3
  53. package/lib/mobile/session-schedule-mark.js +8 -14
  54. package/lib/mobile/session-send.js +13 -21
  55. package/lib/mobile/session-snapshot.js +4 -6
  56. package/lib/mobile/sessions.js +7 -23
  57. package/lib/mobile/stats-collect.cjs +10 -24
  58. package/lib/mobile/stats-ledger.cjs +13 -20
  59. package/lib/mobile/stream.js +12 -23
  60. package/lib/mobile/subagents.js +5 -18
  61. package/lib/mobile/uploads.js +8 -17
  62. package/lib/mobile/workspaces.js +20 -8
  63. package/lib/panel/index.html +7 -7
  64. package/lib/read-receipt.cjs +10 -21
  65. package/lib/relay/lan-network.cjs +14 -28
  66. package/lib/relay/lan-proxy.js +14 -21
  67. package/lib/relay/pairing-policy.js +6 -9
  68. package/lib/relay/preview.js +8 -25
  69. package/lib/relay/protocol.js +6 -13
  70. package/lib/relay/status.js +5 -8
  71. package/lib/relay/tunnel.js +9 -14
  72. package/lib/routes/control-plane.js +28 -45
  73. package/lib/routes/mobile-api.js +19 -32
  74. package/lib/session-cleanup.cjs +16 -27
  75. package/lib/shared/credentials-entry.js +4 -11
  76. package/lib/shared/dshtoken.js +4 -4
  77. package/lib/shared/e2ee-request.js +1 -1
  78. package/lib/shared/e2ee.js +8 -14
  79. package/lib/shared/fresh-request.js +6 -22
  80. package/lib/shared/http-util.js +3 -4
  81. package/lib/shared/list-revision.js +5 -14
  82. package/lib/shared/session-signal.js +7 -16
  83. package/lib/shared/sse-write.js +14 -20
  84. package/lib/shared/workspace-guard.js +7 -15
  85. package/lib/shell/device-ota.js +4 -6
  86. package/lib/shell/manager.js +12 -27
  87. package/lib/shell/ota-package.js +7 -12
  88. package/lib/shell/runtime.js +8 -16
  89. package/lib/shell/self-check-after-swap.js +9 -23
  90. package/lib/shell/updater.js +13 -26
  91. package/lib/sidecar-facade-worker.cjs +20 -33
  92. package/lib/sidecar-facade.cjs +7 -7
  93. package/lib/sidecar-lifecycle.cjs +7 -9
  94. package/lib/update-install.cjs +14 -59
  95. package/lib/update-registry.cjs +6 -18
  96. package/package.json +8 -5
@@ -3,46 +3,19 @@
3
3
  /**
4
4
  * 内存自检台账:内存出问题之后,用来回答「当时到底堆在哪儿」。
5
5
  *
6
- * 起因:宿主进程内存涨到 3.5~5G,排查时手上只有 Activity Monitor 的一个总数,
7
- * 分不出「插件推的镜像在堆」「遗弃的流订阅在堆」还是「宿主自己」。所以这里把三件事
8
- * 一次性报出来:两个**进程**的内存、各路**活着的流数**、以及插件自己那几个
9
- * 只增不减的容器有多大。
6
+ * 一次性报出两个进程的内存、各路活着的流数,以及插件自己那几个只增不减的容器有多大。
10
7
  *
11
- * ## 最重要的一条:口径
8
+ * 口径:插件住在宿主进程里,没有独立内存可测。`hostProcess` 报的是整个宿主进程的
9
+ * `process.memoryUsage()`,不是「插件占了多少」;侧车是另一个进程,记在 `sidecarProcess`
10
+ * 下,两个数不能相加成「插件内存」。字段名与 `caveat` 按这个口径写死。
12
11
  *
13
- * **插件住在宿主进程里,没有独立内存可测。** `hostProcess` 报的是**整个宿主进程**
14
- * (DSH host)的 `process.memoryUsage()`——插件、宿主自身、其它插件、V8 的堆外缓冲
15
- * 全在这个数里。它不是「插件占了多少」。要判断「插件占多少」只能拿它和流数、镜像
16
- * 缓存估算字节一起看趋势(插件侧唯一能量化的是 `mirror.cachedBytes`,那还是估算)。
17
- * 字段名与 `caveat` 都按这个口径写死,别把它改名成 pluginMemory 之类。
12
+ * 数据全走既有通道:本进程 memoryUsage、worker 的 `/status` 快照(没上报就报 null 并记进
13
+ * `unavailable`,不拿 0 顶替)、局域网 `stats()`、镜像推送记账与缓存。
18
14
  *
19
- * 侧车(sidecar facade worker)是**另一个进程**(lib/sidecar-facade.cjs 的 fork),
20
- * 它的内存在 `sidecarProcess` 下,与宿主分列——两个数不能相加成「插件内存」。
15
+ * 做成 `createSelfCheck(deps)` 而不是单例:镜像缓存按 ctx 隔离,拿不到 ctx 就读不到。
21
16
  *
22
- * ## 数据从哪儿来(全走既有通道,没有新开一条)
23
- *
24
- * - 宿主进程内存:本进程 `process.memoryUsage()` / `v8.getHeapStatistics()`。
25
- * - 侧车进程内存、流数:**worker 的 `/status` 快照**(`sidecarLifecycle.getSnapshot()`,
26
- * 与 `tunnel`/`lan` 状态同一个来源)。worker 没上报对应字段时如实报 `null` 并记进
27
- * `unavailable`——**不拿 0 顶替**:0 是「一条流都没有」,与「不知道」是两件事。
28
- * - 局域网直连路的流数:由注入的 `getLanProxyStats()` 提供,约定形状
29
- * `() => ({ upstreamOpen, sseOpen }) | null`(`lan-proxy.js` 的 `stats()`)。
30
- * - 镜像:`mirror.getMirrorPush()` / `getPushFailures()`(不在本模块重复记账),
31
- * 缓存本体走 `session-cache.js` 的 `mirrorHistoryStateOf(ctx)`。
32
- * - 凭据库:`credentials.listActive().length`(凭据包只暴露「活跃」这一档)。
33
- *
34
- * ## 为什么是工厂而不是模块级单例
35
- *
36
- * `desktop-half-stats.cjs` 是单例:它只记进程级的计数。这份不一样——镜像缓存按
37
- * `ctx` 隔离(WeakMap),拿不到 ctx 就读不到。所以做成 `createSelfCheck(deps)`,
38
- * 由 control-plane 用注入的 ctx 建一次,而不是往模块级塞一个 ctx。
39
- *
40
- * ## 字节估算的口径
41
- *
42
- * `mirror.cachedBytes` 是**估算**,不是精确占用:字符串按 `String.length`(会话正文
43
- * 以 ASCII 为主,字符数≈字节)、数字 8 字节、布尔 4、每个对象/数组 24 字节固定开销。
44
- * 走查有节点预算(`MAX_WALK_NODES`),用尽时 `cachedBytesComplete` 为 false,
45
- * 此时那个数是**下限**而不是总量。
17
+ * `mirror.cachedBytes` 是估算(字符串按 length、数字 8 字节、对象固定开销);走查有节点
18
+ * 预算,用尽时 `cachedBytesComplete` 为 false,那个数是下限。
46
19
  */
47
20
 
48
21
  const v8 = require('node:v8');
@@ -198,9 +198,8 @@ function lastTurnFactsOf(ctx) {
198
198
  /**
199
199
  * 标记「这条会话读过了」:记一个水位,把当前的完成 / 失败提醒清掉,返回清掉的条数。
200
200
  *
201
- * **不删条目**(旧实现是 delete)。删掉之后重建的条目会丢掉水位,于是「读过的轮次」
202
- * 会在下一次 history 折叠里重新点亮 —— 2026-10-17 真机:桌面读过之后手机上红点 / 绿点
203
- * 还在或者反过来。水位才是「这个时刻之前的轮次不算数」的唯一凭据。
201
+ * 不删条目:删掉之后重建的条目会丢掉水位,「读过的轮次」会在下一次 history 折叠里重新点亮。
202
+ * 水位才是「这个时刻之前的轮次不算数」的唯一凭据。
204
203
  */
205
204
  function markCompletionRead(ctx, deviceKey, sessionId, at = Date.now()) {
206
205
  const state = completionStates.get(ctx)?.get(deviceKey)?.get(sessionId);
@@ -317,14 +316,11 @@ function writeIdempotencyStateOf(ctx) {
317
316
  /**
318
317
  * 丢弃会话列表缓存(含正在构建的那一次)。
319
318
  *
320
- * `/sessions` 的完整响应会缓存 SESSION_LIST_CACHE_MS(10 秒),本意是压住手机轮询对
321
- * 桌面的冲击。但凡是**改变列表内容**的动作都必须立刻把它丢掉,否则 App 在自己刚做完
322
- * 的动作上看到的是最长 10 秒前的旧数据——真机上「重命名会话」保存后侧栏那一行纹丝不
323
- * 动、「归档会话」按下去那一行照旧留着,看着都像没生效(宿主的标题其实早就改了)。
319
+ * `/sessions` 的完整响应缓存 10 秒,本意是压住手机轮询对桌面的冲击;但凡是改变列表内容的
320
+ * 动作都必须立刻丢掉它,否则 App 在自己刚做完的动作上看到的是最长 10 秒前的旧数据。
324
321
  *
325
- * 只清 `value` 不够:若此刻正有一次构建在飞,它读的是变更前的历史,落地后会把旧数据
326
- * 又写回缓存。所以连 `promise` 一起清,并用 completion generation 兜住(与
327
- * handleSessionRead 同款守卫,见那里的注释)。
322
+ * 只清 `value` 不够:正有一次构建在飞时,它读的是变更前的历史,落地后会把旧数据写回缓存。
323
+ * 所以连 `promise` 一起清,并用 completion generation 兜住。
328
324
  *
329
325
  * @param {object} ctx
330
326
  * @param {string} [deviceKey] 只清这一台设备的缓存;缺省清掉该 ctx 下所有设备。
@@ -344,17 +340,15 @@ function invalidateSessionList(ctx, deviceKey) {
344
340
  }
345
341
 
346
342
  /**
347
- * 丢弃会话列表缓存,但**不抬 completion generation**。
343
+ * 丢弃会话列表缓存,但不抬 completion generation。
348
344
  *
349
- * 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态
350
- * 一起作废;这个用于「宿主的清单内容变了,我(镜像推送调度器)先看见了」。后者在生成期
351
- * 每 500ms 就会发生一次(会话的 updatedAt 一直在动),如果也抬 generation,
352
- * buildSessionsResponse 里那句 `completionGenerationOf(...) === generation` 就永远不成立,
353
- * `confirmCompletionFromHistory` 再也执行不到 —— 侧栏的 completed / 红点会彻底不亮。
345
+ * 与 invalidateSessionList 的分工:那个用于「写操作刚发生」,必须连在飞构建与完成态一起
346
+ * 作废;这个用于「宿主的清单内容变了,镜像推送调度器先看见了」。后者每 500ms 就会发生一次,
347
+ * 若也抬 generation,`confirmCompletionFromHistory` 就再也执行不到,侧栏 completed / 红点会
348
+ * 彻底不亮。
354
349
  *
355
- * 所以在飞构建靠状态自带的「朝代」(droppedAt)而不是 generation 来作废:handleSessions
356
- * 建构建前记下朝代,落地时发现朝代变了就不把旧内容写回缓存。这样既不需要抬 generation,
357
- * 又不会出现「变更前发起的那次构建在变更后落地,把旧列表按新时间戳缓存 10 秒」。
350
+ * 所以在飞构建靠状态自带的「朝代」(droppedAt)作废:建构建前记下朝代,落地时发现变了就不
351
+ * 把旧内容写回缓存。
358
352
  *
359
353
  * @param {object} ctx
360
354
  * @param {string} [deviceKey] 只丢这一台设备的缓存;缺省丢掉该 ctx 下所有设备。
@@ -3,29 +3,16 @@
3
3
  /**
4
4
  * 交付文件的字节(POST /api/mobile/v1/session/file)。
5
5
  *
6
- * 轮尾那张「交付文件」卡在桌面上点一下就用系统默认程序打开;手机上没有那条路 ——
7
- * 文件在电脑上,宿主只给了路径,App 必须先取回字节。这条路由就是那条通道。
6
+ * 轮尾那张「交付文件」卡在桌面上点一下就打开;手机上没有那条路,App 必须先取回字节。
8
7
  *
9
- * 三条边界同时成立:
8
+ * 三条边界:
9
+ * 1. 放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径。前者判「解析后的落点是否
10
+ * 在 cwd 之下」,`../x` 落在外面就退回白名单,不在声明里就 403;后者是字符串全等的显式
11
+ * 声明,用于工作区之外的文件。放开工作区不等于能读这台电脑上的任意文件。
12
+ * 2. 相对路径按会话工作区解析:cwd 取自会话列表摘要;没有 cwd 的会话只能走 present 白名单。
13
+ * 3. 流式回写,不在插件进程里缓冲:几十 MB 的录屏不该整包躺在内存里。
10
14
  *
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。
24
- * 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
25
- * 导出同一个理由。
26
- *
27
- * 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
28
- * 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
15
+ * 两种失败分开:两条口子都不认就 403(`outside-workspace`),认了但盘上没了回 404(`missing`)。
29
16
  */
30
17
 
31
18
  const fs = require('node:fs');
@@ -17,21 +17,15 @@ const approvals = require('./approvals.js');
17
17
  const questions = require('./questions.js');
18
18
 
19
19
  /**
20
- * 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
20
+ * 宿主的两个会话集合(同一次 `workspace.list` 快照里拿到):
21
21
  *
22
- * - `archivedSessionIds`:已归档的会话。桌面端侧边栏据此把它们藏起来,而
23
- * `sessions.list` 自身**不过滤**归档,所以移动端必须自己读这个集合:此前完全没读,
24
- * App 的「归档会话」写是写成功了(宿主确实把 id 记进了 archivedSessionIds),列表却
25
- * 照旧把那一行发回手机,用户看到的就是一次毫无反馈的空操作。
26
- * - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
27
- * 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
28
- * 否则 App 既画不出置顶标记、也排不到工作区最前。
22
+ * - `archivedSessionIds`:已归档的会话。`sessions.list` 自身不过滤归档,移动端必须自己读,
23
+ * 否则「归档」写成功了、列表却照旧把那一行发回手机。
24
+ * - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
25
+ * 也排不到工作区最前。
29
26
  *
30
- * 两个集合合并成一次 RPC:它们同源同一次快照,而会话列表每 10 秒就会重建一次,
31
- * 分两次 `workspace.list` 等于平白给手机多塞一轮往返。
32
- *
33
- * 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
34
- * RPC 抖动让整个会话列表凭空变空。
27
+ * 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。取不到时都回空 ——
28
+ * 宁可多显示几行,也不能因为一次 RPC 抖动让整个列表变空。
35
29
  */
36
30
  async function workspaceSessionFlags(ctx) {
37
31
  try {
@@ -63,12 +63,9 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
63
63
  /**
64
64
  * Build mirror warm snapshots for the host mirror push.
65
65
  *
66
- * Two modes:
67
- * 1. High-priority (sessionIds provided): build ALL specified sessions at once,
68
- * up to `limit` messages each.
69
- * 2. Background (no sessionIds): build from summaries, one session at a time,
70
- * returning after each session so the caller can push incrementally.
71
- * Processes at most `maxSessions` sessions per call, using cache when fresh.
66
+ * Two modes: high-priority (sessionIds given) builds all specified sessions at once; background
67
+ * (no sessionIds) builds from summaries one at a time so the caller can push incrementally,
68
+ * at most `maxSessions` per call, using the cache when fresh.
72
69
  *
73
70
  * @param {object} ctx - harness context
74
71
  * @param {Array} summaries - session list summaries from host
@@ -77,15 +74,11 @@ async function buildSingleSessionMirror(ctx, sessionId, options = { limit: 200 }
77
74
  * @param {string[]} [options.sessionIds] - specific sessions to build (high-priority)
78
75
  * @param {number} [options.limit=200] - max messages per session (capped at 200)
79
76
  * @param {number} [options.maxSessions=32] - max sessions to build in one call
80
- * @param {boolean} [options.noCache=false] - 完全绕开时间型缓存(既不读也不写)。
81
- * 事件驱动的推送必须传它:那个缓存只认「宿主抬了 updatedAt」这一种内容变化,
82
- * 一条新消息落盘而摘要还没变时,读缓存会把上一份正文当成这一份发出去。
83
- * 注意**不能**靠「传一个必然不同的 updatedAt」来绕(调用方以前就是这么干的):
84
- * 指纹是 `updatedAt:running:limit`,同一个毫秒里的两次推送指纹相同,照样命中旧正文。
85
- * @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } }
86
- * In background mode (no sessionIds), also sets _moreSessions on the result when
87
- * there are remaining sessions beyond maxSessions, and _builtCount for how many
88
- * were actually built this call.
77
+ * @param {boolean} [options.noCache=false] - 完全绕开时间型缓存(既不读也不写)。事件驱动的
78
+ * 推送必须传它:缓存只认「宿主抬了 updatedAt」这一种变化,新消息落盘而摘要未变时会把上一份
79
+ * 正文当成这一份发出去。也不能靠传一个必然不同的 updatedAt 来绕:同一毫秒的两次推送指纹相同。
80
+ * @returns {Promise<object>} messages map { [sessionId]: { ok, items, lastSeq, hasMore } };
81
+ * 后台模式还会带 _moreSessions(是否还有剩余会话)与 _builtCount(本次真正构建了几个)。
89
82
  */
90
83
  async function buildMirrorWarmSnapshots(ctx, summaries, activeSessionId, options = {}) {
91
84
  const maxSessions = Math.max(1, Math.min(Number(options.maxSessions) || 32, 256));
@@ -165,9 +165,9 @@ async function buildModelsSnapshot(ctx, sessionId) {
165
165
 
166
166
  /** GET /api/mobile/v1/session/models?sessionId=…(token)
167
167
  * 返回会话当前模型选择与可用模型目录(透传宿主 session.models)。
168
- * 响应:{ ok, current:{provider,model,reasoningEffort?}, defaultModel:{provider,model,reasoningEffort?}, routable, groups:[{id,name,models:[{id,name,description?,reasoning?}]}], options?:[{id,title,value,choices:[{id,name,description?}]}], failures:[{id,name,message}] }
169
- * `current` 是**这个会话**实际会用的;`defaultModel` 是全局默认(宿主新建会话时的落点)。
170
- * `options` 是当前渠道的附加选项行(速度、输出详略……),没有就是没有这个字段。
168
+ * 响应:{ ok, current, defaultModel, routable, groups, options?, failures }。
169
+ * `current` 是这个会话实际会用的,`defaultModel` 是全局默认(新建会话的落点),
170
+ * `options` 是当前渠道的附加选项行,没有就没有这个字段。
171
171
  */
172
172
  async function handleModels(ctx, req, res) {
173
173
  const url = new URL(req.url, 'http://x');
@@ -1,23 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 会话行左边那只时钟:**这一条会话有没有还在生效的自动化任务**。
4
+ * 会话行左边那只时钟:这一条会话有没有还在生效的自动化任务。
5
5
  *
6
- * 桌面上那个标记在 `@deepseek-ai/dsh-client-ui-schedule` 的 `SessionScheduleMark`:它占
7
- * 会话行标题前那格 16px,画一颗 14px 的 `IconClockOutlineRegular`;只在 「这一行自己的
8
- * 主状态是空闲」时才挂得上(有审批 / 有新消息 / 正在跑时,那一格留给状态点),归档行那格
9
- * 留空。手机端此前没有这个标记,用户在桌面上排了定时任务,手机上那条会话看不出任何区别。
6
+ * 桌面上那个标记只在会话行的主状态空闲时挂得上(有审批 / 新消息 / 正在跑时那格留给状态点),
7
+ * 归档行留空;手机端此前没有这个标记。
10
8
  *
11
- * 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status,
12
- * 按 `status === 'active'` 收成一份「哪些会话有活动任务」的集合,列表每行只要查表。**不**
13
- * 每行发一次 `schedule/list` —— 会话列表每 10 秒重建一次,逐行请求等于每轮几十次往返。
9
+ * 数据从宿主的任务目录来:`schedule.catalog()` 一次给出全部任务的 sessionId + status,按
10
+ * `status === 'active'` 收成集合,列表每行查表。不逐行发 `schedule/list` —— 会话列表每 10 秒
11
+ * 重建一次,逐行请求等于每轮几十次往返。
14
12
  *
15
- * 三条取舍:
16
- * - **服务不在就当没有任务**(老宿主 / 极简 profile 没装):时钟是提示性信息,为它让整个
17
- * 会话列表失败是拿主要功能换装饰;
18
- * - **读失败也当没有**,但只吞这一条,不缓存:下一个 10 秒周期照常重问,宿主的
19
- * `schedule/changed` 与目录本身都是活的;
20
- * - 只看 `active`:一次性任务投完就自动结束(`status` 转 inactive),桌面上那颗也跟着消失。
13
+ * 三条取舍:服务不在(老宿主 / 极简 profile)就当没有任务;读失败也只吞这一条、不缓存,
14
+ * 下个周期照常重问;只看 active,一次性任务投完转 inactive 后标记自然消失。
21
15
  */
22
16
 
23
17
  /** 目录一次最多收多少条(与 schedule.js 的 SCHEDULE_MAX_ENTRIES 同量级,这里只用来防异常宿主)。 */
@@ -43,21 +43,15 @@ function singleTextOf(content) {
43
43
  }
44
44
 
45
45
  /**
46
- * 经网关执行 slash 命令(commands.execute——桌面 Web 端同一条派发路径:
47
- * 命令注册表命中才执行,绝不经 session.prompt 发给模型;agent 由 agentId 经同一
48
- * agent 查找器解析,与 session.prompt 的 turnAgentFor 同源,支持冷会话自动 resume)。
46
+ * 经网关执行 slash 命令(commands.execute,桌面 Web 端同一条派发路径:命令注册表命中才执行,
47
+ * 绝不经 session.prompt 发给模型;agent 由 agentId 经同一查找器解析,支持冷会话自动 resume)。
49
48
  *
50
- * ⚠️ args 的键名必须**恰好**是宿主 descriptor 的 wire 名(typertGateway 的
51
- * assertExactArguments 只认这组名字,多一个少一个都是 gateway/arguments-invalid)。
52
- * descriptor:`@deepseek-ai/dsh-commands#commands/execute`,scope wire = agentId,
53
- * 参数 = line + submittedAttachments(encoded images 与 staged file receipts,
54
- * 按提交顺序排列,纯命令调用传空数组即可)。
55
- * 这里曾经写成 `images: []`——少了 submittedAttachments 又多了 images,导致
56
- * /permission、/plan 等**所有** slash 命令 400;形状守卫见
57
- * test/commands-execute-shape.test.cjs(改这里必须同步那张表)。
49
+ * args 的键名必须恰好是宿主 descriptor 的 wire 名:scope = agentId,参数 = line +
50
+ * submittedAttachments(encoded images 与 staged file receipts,按提交顺序,纯命令传空数组)。
51
+ * 多一个少一个都是 gateway/arguments-invalid,形状守卫见 test/commands-execute-shape.test.cjs。
58
52
  *
59
- * @returns {execution} 派发成功(CommandExecution|undefined,undefined=未知命令/语法不符)
60
- * | {error} 查找或派发失败 | {missing} 网关服务未组合(调用方回退普通消息)
53
+ * @returns {execution} 派发成功(undefined=未知命令/语法不符)| {error} 查找或派发失败
54
+ * | {missing} 网关服务未组合(调用方回退普通消息)
61
55
  */
62
56
  async function executeCommand(ctx, sessionId, line) {
63
57
  // 走 host-bridge/transport.js 的 invoke:Peer 身份与「服务/方法不存在」的收敛都在
@@ -82,8 +76,7 @@ function promptOnce(ctx, clientMessageId, payload) {
82
76
  // 把这一发认回去的身份一起送进宿主的 prompt 请求(`request.requestId`,见
83
77
  // host-bridge/endpoints.js)。宿主要是不认识它,user 消息的 `source.rpcId` 就是那个
84
78
  // 端点自己生成的 `mobile-prompt-<uuid>` —— App 那边 `clientMessageId` 永远对不上,
85
- // 只能靠「同文本 + 同图数」猜着认领乐观行(2026-10-20 真机:自己发的图一直「发送中」,
86
- // 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
79
+ // 只能靠「同文本 + 同图数」猜着认领乐观行(实时帧里的图一旦漏读,张数对不上就彻底认领不了)。
87
80
  const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
88
81
  if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
89
82
  // 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
@@ -176,14 +169,13 @@ async function handleSend(ctx, req, res) {
176
169
  sendJson(res, 200, { ok: true, accepted: true });
177
170
  }
178
171
 
179
- /** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(与桌面 Web 端命令面板
180
- * 同源——commands.list,即服务端命令注册表对该 agent 生效的完整清单)。
172
+ /** GET /api/mobile/v1/commands?sessionId=…(token):会话有效命令目录(commands.list,与桌面
173
+ * Web 端命令面板同源)。
181
174
  * 响应:{ ok, items:[{name, description, input?:{hint}}] };网关未组合或失败时降级为空清单,
182
- * 客户端菜单据此只展示真实可执行的命令(不再硬编码)。
175
+ * 客户端菜单据此只展示真实可执行的命令。
183
176
  *
184
- * 失败一律降级成空清单是**刻意设计**:输入区不能因为目录拉不到就报错。代价是这个端点
185
- * 出问题时手机上看不出任何提示,「+」菜单静默只剩媒体与文件两行 —— 2026-10 宿主升到
186
- * 0.2.1 要求每个请求自带 Peer,这条路径没跟上,用户看到的就是「目标、计划、指令全没了」。 */
177
+ * 失败一律降级成空清单是刻意设计:输入区不能因为目录拉不到就报错。代价是这个端点出问题时
178
+ * 手机上看不出提示,「+」菜单静默只剩媒体与文件两行。 */
187
179
  async function handleCommands(ctx, req, res) {
188
180
  const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
189
181
  if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
@@ -77,12 +77,10 @@ async function buildStatsSnapshot(ctx, sessionId) {
77
77
  /**
78
78
  * 统计投影快照(handleStats 与 buildStatsSnapshot 共用)。
79
79
  *
80
- * 0.2.0 把整帧投影从 session.history/page 的返回值里拆走了(page 只回
81
- * events/hasMore/cursor),所以「拉 1 条历史顺带取投影」这条路已经断了;改走
82
- * host-bridge 翻译出来的 `apiProxy.sessions.projections({ request: { sessionId } })`,
83
- * 返回值是 `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(plan.active 加可选的
84
- * pending),排队条与计划条读的是 planMode。历史轮询是每 5 秒一次的热路径,多背一次
85
- * 投影 RPC 划不来,所以宁可让统计单独走投影端点。
80
+ * 0.2.0 把整帧投影从 history/page 的返回值里拆走了,「拉一条历史顺带取投影」这条路断了;
81
+ * 改走 `apiProxy.sessions.projections({ request: { sessionId } })`,返回值是
82
+ * `{ asOfSeq, values }`。顺带把 plan 归一化成 planMode(排队条与计划条读它)。
83
+ * 历史轮询是每 5 秒一次的热路径,多背一次投影 RPC 划不来,所以统计单独走投影端点。
86
84
  */
87
85
  async function statsProjections(ctx, sessionId) {
88
86
  // 显式给 signal:宿主部分方法内部直接读 signal.aborted,不做 undefined 兜底。
@@ -1,32 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端会话 API 的**会话列表**与**新建会话**。
4
+ * 移动端会话 API 的会话列表与新建会话。
5
5
  *
6
- * 本文件一度是 1964 行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;而且
7
- * 它是 lib/mobile 下被引用最多的模块——stream / credentials / skills / subagents /
8
- * workspaces / host-settings 全都从它取 unwrap、callSessions、缓存访问器,于是它成了
9
- * 事实上的公共工具库,谁都不好动。现在按域拆开:
10
- *
11
- * envelope.js 信封拆解(unwrap / envelopeValue),唯一的实现
12
- * rpc.js 对宿主 apiProxy 的窄接口封装(callSessions / callAgentPresets)
13
- * session-cache.js 按 ctx 隔离的状态表,全部 WeakMap 集中在这里
14
- * attachments.js 图片附件还原
15
- * completion.js 完成提醒状态机
16
- * session-models.js 模型目录与切模型
17
- * session-search.js 正文检索
18
- * session-send.js 发消息 / slash 命令 / 读 / 附件重取 / 停 / 改名 / 分叉
19
- * session-snapshot.js 消息与统计快照(镜像与分页共用)
20
- * session-mirror.js 镜像整帧构建
21
- * session-goal.js 目标条动作
22
- * session-presets.js Agent 预设目录
23
- * session-preview.js 列表行的 lastMessage 预览(按会话指纹复用 + 运行中会话的节流)
24
- * session-list-shortcut.js `?rev=` 条件刷新记住的那份清单是谁
6
+ * 这个文件一度是一千九百多行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;现在
7
+ * 按域拆开:envelope(信封拆解)、rpc(窄接口封装)、session-cache(按 ctx 隔离的状态表)、
8
+ * attachments、completion、session-models、session-search、session-send、session-snapshot、
9
+ * session-mirror、session-goal、session-presets、session-preview、session-list-shortcut。
25
10
  *
26
11
  * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认 + `?rev=` 条件刷新)、
27
- * GET /session(单会话摘要)、GET /session/head(正文水位探针)、GET /session/stats、
28
- * 新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
29
- * 所以 lib/mobile 下所有 import 路径一行都不用改。
12
+ * 单会话摘要、正文水位探针、统计、新建会话、排队消息动作。本文件继续转出各子模块的导出,
13
+ * 所以 lib/mobile 下的 import 路径一行都不用改。
30
14
  */
31
15
 
32
16
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -3,26 +3,14 @@
3
3
  /**
4
4
  * 统计采集:把宿主每条会话的累计用量读出来,和水位一比,增量记进账本。
5
5
  *
6
- * 为什么不重放会话事件:那是 dsh-context 在桌面上干的事(它让宿主注册一份投影,
7
- * 每个事件折一次)。手机这边要的是「按天看个大概」,重放整条日志在宿主上是一笔
8
- * 每秒都在烧的开销,而我们每轮只需要一个数 —— 会话的累计用量。
6
+ * 不重放会话事件:手机要的是「按天看个大概」,重放整条日志是每秒都在烧的开销。所以走水位差
7
+ * (宿主累计值 − 账本水位 = 这次入账的量),零依赖,代价是入账日期取「插件看到增量的那一天」,
8
+ * 跨零点的一轮会落在后一天。首采会把历史全量入账,日期取它最后一次活动的日期,算一次性回填。
9
9
  *
10
- * 所以走水位差:
11
- * 宿主累计值 − 账本记下的水位 = 这次该入账的量。
12
- * 好处是零依赖(只用现有的会话投影接口,不需要宿主新能力,也不用装别家插件),
13
- * 代价如实说:入账日期取「插件看到增量的那一天」,跨零点的一轮会落在后一天。
14
- * 这是刻意的近似 —— 为一个几十分钟的边界去重放事件流不值得。
10
+ * 采集是按需的(打开统计页 / 下拉刷新 / 清理已归档前补一刀),没有后台定时器。
15
11
  *
16
- * 首采(账本里没有这条会话)会把它的历史全量入账,日期取它最后一次活动的日期:
17
- * 这是一次性回填,用户装上这个版本之后第一次打开统计页就会看见过去的用量,
18
- * 而不是从今天零点重新计数。
19
- *
20
- * 采集是**按需**的(打开统计页 / 下拉刷新 / 清理已归档前补一刀),没有后台定时器:
21
- * 手机上没人看统计的时候不该让宿主一直读盘。
22
- *
23
- * 清理前那次补采(`skipPreview`)刻意不读「最后一句」:那一步要读会话日志,是整轮里
24
- * 最重的一趟,而删除不可恢复、时间又卡在手机那条请求的预算里。为了多把几十上百条会话的
25
- * 数字记进账本,宁可让首次见到的会话卡片少一行摘要 —— 标题与项目名还认得出来。
12
+ * 清理前那次补采(`skipPreview`)不读「最后一句」:那一步要读会话日志,是整轮最重的一趟,
13
+ * 而删除不可恢复、时间又卡在请求预算里。宁可让首次见到的会话卡片少一行摘要。
26
14
  */
27
15
 
28
16
  const { unwrap } = require('./envelope.js');
@@ -39,11 +27,9 @@ const CONCURRENCY = 4;
39
27
  /**
40
28
  * 这一轮先扫「最近还在动」的会话,再按「最久没采过」补老账。
41
29
  *
42
- * 之前每一轮都只按最后活动时间倒序扫、1.5 秒到点就停,于是最老的那批永远排在队尾、
43
- * 永远采不到(实测七十多条会话从没进过账本,其中大多是子代理)。现在把队列分成两段:
44
- * 近 7 天有动作的照旧优先(它们才是数字在变的那批),其余的按 `collectedAt` 升序 ——
45
- * 采过的会顺手把时间推到当下、自动排到队尾,于是每一轮的预算都接着上一轮的地方往下走,
46
- * 不需要额外持久化一个游标。
30
+ * 只按最后活动时间倒序扫会让最老的那批永远排在队尾、永远采不到。所以队列分两段:近 7 天有
31
+ * 动作的优先(它们才是数字在变的那批),其余按 `collectedAt` 升序 —— 采过的会把时间推到当下、
32
+ * 自动排到队尾,每一轮的预算接着上一轮往下走,不需要额外持久化游标。
47
33
  */
48
34
  const RECENT_WINDOW_MS = 7 * 86400_000;
49
35
 
@@ -51,7 +37,7 @@ const RECENT_WINDOW_MS = 7 * 86400_000;
51
37
  * 一次采集的时间预算。
52
38
  *
53
39
  * 手机上点开统计页时,插件会当场把每条会话的累计用量读一遍;一百多条会话串起来能拖到
54
- * 十几秒(每条两趟宿主调用),用户看到的就是「点统计,转好久」(2026-10-22 真机)。
40
+ * 十几秒(每条两趟宿主调用),用户看到的就是「点统计,转好久」。
55
41
  * 所以给这一趟设上限:到点就停,把**已经采到的**那部分记进账本先回给手机,剩下的等
56
42
  * 下一次打开 / 下拉刷新接着采 —— 水位差是累积的,采一半不会算错,只是数字暂时偏小。
57
43
  */
@@ -3,21 +3,15 @@
3
3
  /**
4
4
  * 统计账本:按天累计的用量 + 每条会话的累计与身份(`$DSH_HOME/mobile-stats.json`)。
5
5
  *
6
- * 为什么要有这一份、以及它为什么不寄在会话日志上:用户会归档会话、也会清理已归档的
7
- * 会话,清理删的是日志本身。统计如果每次现算,清理之后那部分用量就凭空消失(还常常
8
- * 表现为「今天的使用量突然掉了一半」)。所以采集一旦读到增量就落进这个文件,之后会话
9
- * 在不在都不影响历史数字 —— 那张会话卡片只会被标成「已清理」。
6
+ * 它不寄在会话日志上:用户会清理已归档的会话,删的就是日志本身;统计每次现算的话,清理之后
7
+ * 那部分用量会凭空消失。一旦读到增量就落进这个文件,之后会话在不在都不影响历史数字。
10
8
  *
11
- * 口径(与桌面插件 dsh-context 的每日账本一致,但只保留手机用得着的部分):
12
- * - 一天的账落在那**一天完成观测**的日期上(宿主本地日期,`YYYY-MM-DD`);
13
- * - 计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存,合并多台设备时
14
- * 才可能按同一口径重算计费量与命中率;
15
- * - 会话卡片保留最近 `MAX_SESSIONS` 条(按最后活动时间),更老的会话从卡片里消失,
16
- * 但它们的量**仍然留在按天的账上**(总量不会因为裁剪而缩水)。卡片退场只是不再展示
17
- * (`retired`),水位留着 —— 水位一丢,这条会话下次再有动作就会被当成首采、把整段
18
- * 历史重记一遍,按天的账凭空多出一截。
9
+ * 口径(与桌面 dsh-context 的每日账本一致,只保留手机用得着的部分):一天的账落在「观测到
10
+ * 增量」的宿主本地日期上;计费 token = 未命中输入 + 缓存读 + 缓存写 + 输出,四个桶分开存;
11
+ * 会话卡片只留最近 `MAX_SESSIONS` 条,更老的退场(`retired`)但量仍留在按天账上,水位也留着
12
+ * —— 水位一丢,那条会话下次有动作会被当成首采、把整段历史重记一遍。
19
13
  *
20
- * 这个文件只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
14
+ * 只做纯数据(读写 + 折算),不碰网络;采集在 stats-collect.cjs,路由在 stats.js。
21
15
  */
22
16
 
23
17
  const fs = require('node:fs');
@@ -170,8 +164,8 @@ function sanitizeLedger(raw) {
170
164
  }
171
165
  }
172
166
  const billed = sanitizeUsage(value.billed);
173
- // 老账本(2026-10-24 之前)没有每条会话的按天用量:把它的历史量记在它最后一次
174
- // 活动那天。这跟「首采回填」是同一条近似,也是一次性回填 —— 之后就只记增量。
167
+ // 更早的账本没有每条会话的按天用量:把它的历史量记在它最后一次活动那天。
168
+ // 这跟「首采回填」是同一条近似,也是一次性回填 —— 之后就只记增量。
175
169
  if (Object.keys(daily).length === 0 && billedOf(billed) > 0) {
176
170
  daily[dayKeyOf(num(value.lastAt) || Date.now())] = { ...billed };
177
171
  }
@@ -364,12 +358,11 @@ function usageInRange(record, startDay, today) {
364
358
  /**
365
359
  * 按范围折算成 App 要的总览(纯函数)。
366
360
  *
367
- * 逐日序列会把范围内**每一天都列出来**(没有活动的补 0):柱状图靠它保持等距,
368
- * 由页面决定怎么省略日期标签。
361
+ * 逐日序列会把范围内每一天都列出来(没有活动的补 0),柱状图靠它保持等距。
369
362
  *
370
- * 三块口径必须对得上(2026-10-24 改):`kpi` 是范围内总量,`sessions` 里的每张卡片是
371
- * **这条会话在同一个范围里**的用量,`retired` / `untracked` 把不在卡片上的那部分明说出来 ——
372
- * 卡片加起来永远解释不了总量,是这个页面被读成「数据对不上」的根源。
363
+ * 三块口径必须对得上:`kpi` 是范围内总量,`sessions` 里的每张卡片是这条会话在同一个范围里
364
+ * 的用量,`retired` / `untracked` 把不在卡片上的那部分明说出来 —— 卡片加起来解释不了总量,
365
+ * 是页面被读成「数据对不上」的根源。
373
366
  */
374
367
  function summarize(ledger, range, now) {
375
368
  const today = dayKeyOf(now);
@@ -3,32 +3,21 @@
3
3
  /**
4
4
  * dsh-mobile 流式:SSE 转发目标会话的增量事件。
5
5
  *
6
- * 订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
7
- * 每 15s 发一行注释 + 一帧 `session/heartbeat` 数据帧;客户端断开即取消订阅。
6
+ * 订阅宿主事件流,只转发目标会话的 session/event 帧,每 15s 发一行注释加一帧
7
+ * `session/heartbeat`;客户端断开即取消订阅。心跳那帧不能省:注释行只是保活,客户端一个
8
+ * 事件都收不到,而「流被正常收尾」在 App 那边看起来和「还开着」一样,它需要一个自己数得到
9
+ * 的活口。心跳不带 seq、不进消息模型、不写缓存。
8
10
  *
9
- * 为什么要那帧心跳:注释行只是保活,客户端一个事件都收不到。而「流被正常收尾」
10
- * (中转重启、隧道掉了、宿主自己结束响应)在 App 那边看起来和「这条流还开着」一模一样
11
- * —— react-native-sse 在 readyState 走到 DONE 时既不派发 error 也不派发 close。所以
12
- * App 需要一个**自己数得到**的活口,两拍没到就主动断开重连(见 app 侧
13
- * services/viewmodels/stream-liveness.ts)。这帧不带 seq、不进消息模型、不写缓存。
11
+ * 断线恢复:客户端重连带 `Last-Event-ID: <sessionId>:<seq>`(或 `lastSeq`),服务端先回放
12
+ * `seq > lastSeq` 的事件再接入实时流,并用 seq 去重。回放受窗口与字节数上限约束,超出的
13
+ * 缺口让客户端去拉权威快照。
14
14
  *
15
- * 断线恢复:SSE 客户端重连时携带 `Last-Event-ID: <sessionId>:<seq>`(或查询参数
16
- * `lastSeq=<seq>`)。服务端先在 history 中回放 `seq > lastSeq` 的事件(收敛状态),
17
- * 再无缝接入实时流,并用 seq 去重避免回放/实时交界处重复交付。由于宿主 history
18
- * 只支持窗口回放,若断线窗口超过回放上限,客户端仍应在其 SSRD 重连补偿刷新
19
- * 权威快照(/session/messages、/workspaces)兜底。
15
+ * 生命周期:所有失败路径都必须经 logStream 留痕(守卫逐条钉着字面量),收尾一定给客户端一帧
16
+ * `session/resync-required`;keepAlive 定时器要 unref。
20
17
  *
21
- * 生命周期:所有失败路径都必须经 logStream 留痕(守卫 test/stream-liveness.test.cjs
22
- * 逐条钉着字面量),收尾一定给客户端一帧 `session/resync-required`;keepAlive 定时器
23
- * 要 unref —— 它只是保活,不该把进程退出按在 15s 一拍上。
24
- *
25
- * 出流量的三个口子(2026-10-25,都在这一层收):
26
- * - 草稿增量按 200ms 合并成一帧(`DRAFT_FLUSH_MS`):一帧固定成本两三百字节,而一个
27
- * token 只有十几字节,逐帧发等于按帧收费;
28
- * - 草稿的填充窗口 250ms(`DRAFT_PAD_INTERVAL_MS`):填充是按时间补的,120ms 就是
29
- * 16KB/s 的底线,与正文多少无关;
30
- * - 推理只推每一步的开头 600 字、工具结果只推前 2000 字:运行中用户不读它们,完整内容
31
- * 仍在 `assistant/message` 与 `/session/messages` 里,一个字都不丢。
18
+ * 出流量收在三处:草稿增量按 200ms 合并成一帧(一帧固定成本两三百字节,token 才十几字节);
19
+ * 草稿填充窗口 250ms;推理只推每一步开头 600 字、工具结果只推前 2000 字,完整内容仍在
20
+ * `assistant/message` 与 `/session/messages` 里。
32
21
  */
33
22
 
34
23
  const { sendJson } = require('../shared/http-util');
@@ -3,25 +3,12 @@
3
3
  /**
4
4
  * 移动端「子代理」API:把某个会话派出去的子代理目录转给手机。
5
5
  *
6
- * 桌面端会话标题栏里挂着一个「N 个子代理」下拉(@deepseek-ai/dsh-client-ui-subagent
7
- * 的 SubagentHeaderLineage):父会话头部按 `origin === "subagent" && parentId ===
8
- * 当前会话` 把子代理收成一棵树,点进去能读子会话的完整记录。手机端此前把
9
- * `origin === 'subagent'` 的行在侧栏里直接 continue 掉(app/src/drawerGroups.ts:122),
10
- * 于是用户既不知道这个会话派过子代理,也没有任何入口能打开它 —— 子代理在手机上
11
- * 等于不存在。
6
+ * 手机侧栏把 `origin === 'subagent'` 的行直接跳过,于是用户既不知道会话派过子代理,也没有
7
+ * 入口打开它。子会话在普通消息接口里只有带 durable parent address(parentSessionId + mode)
8
+ * 才能读,而 /sessions 只给 origin/parentSessionId,mode 来自这里的子代理目录。
12
9
  *
13
- * 子会话在普通消息接口中只有携带 durable parent address 才能读取(parentSessionId + mode);
14
- * /sessions 只给 origin/parentSessionId,mode 则来自这里的子代理目录。缺的只是
15
- * 「这个会话有哪些子代理」——
16
- * 那正是 subagent.list 的返回值(entries + parentAvailable),这里只做形状收敛、
17
- * 限长与限条数,避免异常宿主把手机端列表撑爆。
18
- *
19
- * 宿主 subagent.list 的第二个形参是取消信号(内部 listChildren 会读 signal),
20
- * 故与 session.search 一样显式传一个 AbortController 的 signal,不裸传 undefined。
21
- *
22
- * DSH 0.2.0 没有 subagent.list 这个 RPC 了,目录改由 host-bridge.js 从会话投影的
23
- * subagentCatalog 重建(形状仍是 entries + parentAvailable),本文件对它的形状收敛、
24
- * 限长与限条数一字未动。
10
+ * 0.2.0 没有 subagent.list 这个 RPC,目录由 host-bridge.js 从会话投影的 subagentCatalog
11
+ * 重建(形状仍是 entries + parentAvailable);本文件只做形状收敛、限长与限条数。
25
12
  */
26
13
 
27
14
  const { sendJson, readJsonBody } = require('../shared/http-util');