dsh-mobile-plugin 0.4.0 → 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 (95) hide show
  1. package/dsh-mobile-credentials/lib/credentials.js +8 -8
  2. package/host.js +22 -36
  3. package/lib/client.js +29 -82
  4. package/lib/desktop-view.cjs +5 -10
  5. package/lib/git/read.js +6 -8
  6. package/lib/host-compat.cjs +13 -25
  7. package/lib/host-guard.cjs +9 -39
  8. package/lib/host-patches.cjs +5 -16
  9. package/lib/http/routing.js +7 -27
  10. package/lib/impl.js +6 -8
  11. package/lib/mirror/pusher.cjs +33 -85
  12. package/lib/mirror-snapshot.cjs +6 -24
  13. package/lib/mobile/agent-presets.js +6 -26
  14. package/lib/mobile/approvals.js +18 -44
  15. package/lib/mobile/archive-clean.js +10 -19
  16. package/lib/mobile/attachments.js +11 -36
  17. package/lib/mobile/channels.js +15 -41
  18. package/lib/mobile/credential-upgrade.js +7 -16
  19. package/lib/mobile/credentials.js +5 -21
  20. package/lib/mobile/directory-browse.js +8 -27
  21. package/lib/mobile/directory-listing.js +10 -23
  22. package/lib/mobile/drive-roots.js +59 -0
  23. package/lib/mobile/envelope.js +4 -15
  24. package/lib/mobile/events.js +35 -65
  25. package/lib/mobile/extensions.js +5 -8
  26. package/lib/mobile/git-repos.js +6 -15
  27. package/lib/mobile/git.js +11 -19
  28. package/lib/mobile/history-derive.js +23 -41
  29. package/lib/mobile/host-bridge/endpoints.js +4 -6
  30. package/lib/mobile/host-bridge/event-synthesis.js +21 -35
  31. package/lib/mobile/host-bridge/shims.js +12 -22
  32. package/lib/mobile/host-bridge.js +8 -20
  33. package/lib/mobile/host-settings.js +9 -19
  34. package/lib/mobile/inbox-clean.js +7 -24
  35. package/lib/mobile/local-images.js +11 -17
  36. package/lib/mobile/monitor.js +8 -17
  37. package/lib/mobile/pairing.js +7 -19
  38. package/lib/mobile/plugin-self.js +5 -22
  39. package/lib/mobile/plugin-update.js +8 -27
  40. package/lib/mobile/plugins.js +6 -24
  41. package/lib/mobile/questions.js +15 -40
  42. package/lib/mobile/references.js +11 -31
  43. package/lib/mobile/relay-control.js +6 -27
  44. package/lib/mobile/rpc.js +5 -13
  45. package/lib/mobile/schedule.js +8 -33
  46. package/lib/mobile/self-check.cjs +9 -36
  47. package/lib/mobile/session-cache.js +13 -19
  48. package/lib/mobile/session-file.js +8 -21
  49. package/lib/mobile/session-flags.js +7 -13
  50. package/lib/mobile/session-mirror.js +8 -15
  51. package/lib/mobile/session-models.js +3 -3
  52. package/lib/mobile/session-schedule-mark.js +8 -14
  53. package/lib/mobile/session-send.js +13 -21
  54. package/lib/mobile/session-snapshot.js +4 -6
  55. package/lib/mobile/sessions.js +7 -23
  56. package/lib/mobile/stats-collect.cjs +10 -24
  57. package/lib/mobile/stats-ledger.cjs +13 -20
  58. package/lib/mobile/stream.js +12 -23
  59. package/lib/mobile/subagents.js +5 -18
  60. package/lib/mobile/uploads.js +8 -17
  61. package/lib/mobile/workspaces.js +20 -8
  62. package/lib/panel/index.html +7 -7
  63. package/lib/read-receipt.cjs +10 -21
  64. package/lib/relay/lan-network.cjs +14 -28
  65. package/lib/relay/lan-proxy.js +14 -21
  66. package/lib/relay/pairing-policy.js +6 -9
  67. package/lib/relay/preview.js +8 -25
  68. package/lib/relay/protocol.js +6 -13
  69. package/lib/relay/status.js +5 -8
  70. package/lib/relay/tunnel.js +9 -14
  71. package/lib/routes/control-plane.js +28 -45
  72. package/lib/routes/mobile-api.js +19 -32
  73. package/lib/session-cleanup.cjs +16 -27
  74. package/lib/shared/credentials-entry.js +4 -11
  75. package/lib/shared/dshtoken.js +4 -4
  76. package/lib/shared/e2ee-request.js +1 -1
  77. package/lib/shared/e2ee.js +8 -14
  78. package/lib/shared/fresh-request.js +6 -22
  79. package/lib/shared/http-util.js +3 -4
  80. package/lib/shared/list-revision.js +5 -14
  81. package/lib/shared/session-signal.js +7 -16
  82. package/lib/shared/sse-write.js +14 -20
  83. package/lib/shared/workspace-guard.js +7 -15
  84. package/lib/shell/device-ota.js +4 -6
  85. package/lib/shell/manager.js +12 -27
  86. package/lib/shell/ota-package.js +7 -12
  87. package/lib/shell/runtime.js +8 -16
  88. package/lib/shell/self-check-after-swap.js +9 -23
  89. package/lib/shell/updater.js +13 -26
  90. package/lib/sidecar-facade-worker.cjs +20 -33
  91. package/lib/sidecar-facade.cjs +7 -7
  92. package/lib/sidecar-lifecycle.cjs +7 -9
  93. package/lib/update-install.cjs +14 -59
  94. package/lib/update-registry.cjs +6 -18
  95. package/package.json +1 -1
@@ -3,28 +3,12 @@
3
3
  /**
4
4
  * 移动端「宿主凭据」API:把密钥的查看 / 写入 / 清除补齐到手机上。
5
5
  *
6
- * 桌面端的设置面板能往宿主写 API Key(dsh-client-ui-settings-plugins/lib/client.js
7
- * 的 writeKey() → credentials.set,写完再 credentials.describe 复核),手机此前
8
- * 只能在设置页看到一句「· 1 项密钥未设置」,却没有任何入口去填——这正是本轮补的缺口。
6
+ * 三条接口只转发宿主的 credentials 域:describe / set / unset。密钥值只在手机 → 宿主这个
7
+ * 方向出现一次,宿主从不回传值,所以响应里永远没有 value。
9
8
  *
10
- * 三条接口都只是转发宿主的 credentials 域:
11
- * - GET /credentials?refs=A,B → credentials.describe
12
- * - POST /credentials/set → credentials.set
13
- * - POST /credentials/unset → credentials.unset
14
- *
15
- * 密钥值只在这条路上出现一次(手机 → 宿主)。宿主从不回传值:describe 只给
16
- * {configured, source?, writable},所以这里的响应里也永远不会有 value 字段。
17
- *
18
- * 宿主 describe **回的就是一张平表**:键是引用名、值是上面那个视图
19
- * (`dsh-api-settings-controller/lib/types/credentials.js` 的
20
- * `Object.fromEntries(branded.map(...))`,0.2.0 的网关与旧 apiProxy 两条路都打到
21
- * 同一个 Remote 命名空间)。曾经在这里多拆一层 `value.credentials` —— 那一层从来
22
- * 不存在,于是每个引用都落成「未配置」,设置页永远显示「密钥未设置」,而测试假体
23
- * 照抄了同一个错形状,绿的。读了宿主源码才看出来。
24
- *
25
- * 「能不能写」由宿主的 credentials 服务说了算:如果某个引用是启动环境变量提供的,
26
- * 宿主会回 writable:false,set 则回 credential-rejected("supplied read-only by the
27
- * launching environment")。这里不自己判断,一律如实透传,让 App 显示宿主的原话。
9
+ * 宿主 describe 回的是平表(键是引用名、值是 `{configured, source?, writable}`),不要再拆
10
+ * 一层;多拆一层会让每个引用都落成「未配置」。能不能写由宿主说了算:环境变量提供的引用回
11
+ * writable:false、set 回 credential-rejected,这里如实透传,让 App 显示宿主原话。
28
12
  */
29
13
 
30
14
  const { sendJson, readJsonBody } = require('../shared/http-util');
@@ -1,35 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 手机端目录浏览的**兜底实现**:宿主组合出的目录选择器只服务 native 时,由插件自己
5
- * 把「列一层目录 / 建一个子目录」补上。
4
+ * 手机端目录浏览的兜底实现:宿主组合出的目录选择器只服务 native 时,由插件自己把
5
+ * 「列一层目录 / 建一个子目录」补上。
6
6
  *
7
- * 为什么需要它:宿主那套由 `dsh-host-directory-picker-auto` 在启动时二选一
8
- * (`resolveDirectoryPickerBackend`)——只有「宿主 web 服务绑定到非 127.0.0.1」或者
9
- * 「平台不是 darwin/win32」才挂 browse。无头移动宿主由本包的 cordis.mobile-host.yml
10
- * 把那一行钉成 browse,桌面 profile 宿主则被解析成 native。于是
11
- * **「桌面 profile 宿主 + 手机远程连」**这一格谁都盖不到:宿主按「操作者就在机器前」
12
- * 选了原生对话框,而手机是远程客户端,看不到也点不到那个框 —— 点「添加工作区」只能
13
- * 看到 `host.listDirectory needs the browse capability; the composed picker serves
14
- * "native"`,一个条目都列不出来,「新建工作文件夹」也从根上走不通。
7
+ * 「桌面 profile 宿主 + 手机远程连」这一格谁都盖不到:宿主按「操作者就在机器前」选了原生
8
+ * 对话框,手机是远程客户端,点「添加工作区」只能看到 `needs the browse capability`。不能把
9
+ * mobile-host.yml 里钉 browse 的两行搬进 bundle 层——那会把桌面操作者的原生选择框也换掉。
10
+ * 所以宿主只要服务 browse 就整条走它,只有它明确只服务 native 时才用这份。
15
11
  *
16
- * 为什么兜底放在这里,而不是把 cordis.mobile-host.yml 里钉 browse 的两行搬进
17
- * cordis.patch.yml:后者两种宿主都会加载,而桌面操作者就坐在机器前,把它的原生选择框
18
- * 换成 browse 是纯粹的退步 —— 守卫 `test/directory-picker.test.cjs` 与
19
- * docs/dev-map.md 的 A3-07 都把「那两行不许留在 bundle 层」钉成了规格。插件自带一份
20
- * 原语则谁也不动:宿主只要服务 browse,就仍然整条走宿主(策略与 maxEntries 保持单一
21
- * 真源);只有宿主**明确**只服务 native(或没有 browse 能力)时,才用这一份把手机那条
22
- * 路补上。判定用同进程直查 `ctx.get('directoryPicker').capability()`,不额外打一趟网关。
23
- *
24
- * 语义与 `dsh-host-directory-picker-browse` 逐条对齐(那份是这条 seam 的规范实现):
25
- * - 隐藏项**标记但返回**(`hidden: name.startsWith('.')`),过滤交给客户端;
26
- * - **跟随符号链接**:能进入的才列出来,断链/成环的静静跳过;
27
- * - **全文件系统范围**,没有白名单 —— 这与宿主 browse 后端一致,能力面没有扩大:
28
- * 手机端本来就经 `workspace.create` 采纳任意已存在目录;
29
- * - 名字升序有界窗口,默认上限 `maxEntries = 1000`(对齐 GitHub 网页版),满了置
30
- * `truncated`;
31
- * - 回 `resolve(path)`,**不是 realpath**(macOS 上 /var 是 /private/var 的软链,
32
- * 回 realpath 会让面包屑和客户端手里的路径对不上)。
12
+ * 语义与宿主 browse 后端逐条对齐:隐藏项标记但返回、跟随符号链接、全文件系统无白名单、
13
+ * 名字升序有界窗口(默认 1000,满了置 `truncated`)、回 `resolve(path)` 而不是 realpath。
33
14
  */
34
15
 
35
16
  const { mkdir, opendir, stat } = require('node:fs/promises');
@@ -1,32 +1,19 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 手机端右栏「文件浏览器」的目录列表:**文件与文件夹都列**,每条带类型、大小、修改时间。
4
+ * 手机端右栏「文件浏览器」的目录列表:文件与文件夹都列,每条带类型、大小、修改时间。
5
5
  *
6
- * 与 `/directory/list` 的分工:那条是「为设备挑一个工作区目录」用的,走宿主 directoryPicker,
7
- * 只列**能进去的目录**、每条只有 `{name,path,hidden}`(见 lib/mobile/workspaces.js)。右栏
8
- * 要的是「这个目录里有什么」——文件也是内容,而且要知道类型、大小、修改时间,所以那条路
9
- * 一个字段都对不上。老那条**一个字都不改**:选目录那条流程还挂在它上面。
6
+ * 与 `/directory/list` 的分工:那条为设备挑工作区目录,走宿主 directoryPicker,只列能进去
7
+ * 的目录、只有 `{name,path,hidden}`;右栏要知道「这个目录里有什么」,字段对不上,而选目录
8
+ * 那条流程还挂着,一个字都不改。
10
9
  *
11
- * 这条直接读宿主自己的文件系统,**不经 directoryPicker 网关**。能力面没有因此变大:宿主
12
- * 那份 browse 后端与本地兜底原语(lib/mobile/directory-browse.js)本来都是全文件系统范围、
13
- * 没有白名单,`workspace.create` 也接受任意已存在目录;而「桌面 profile 宿主 + 手机远程连」
14
- * 这一格宿主只服务 native,经网关只会拿到那句 `needs the browse capability`,右栏一个条目
15
- * 都没有。策略原语(隐藏项标记、上限、面包屑)直接 import 那份副本,不抄第二份。
10
+ * 这条直接读宿主文件系统,不经 directoryPicker 网关。能力面没变大:宿主 browse 后端与本地
11
+ * 兜底本来都是全文件系统范围,而「桌面 profile 宿主 + 手机远程连」那一格经网关只会拿到
12
+ * `needs the browse capability`。策略原语直接 import 那份副本,不抄第二份。
16
13
  *
17
- * 契约(App 侧 app/src/services/viewmodels/apiPaths.ts 的 directoryListing 同时按这份改):
18
- * - `path` 可选、绝对路径;缺省 / 空串 / 全空白 = 被控端主目录(os.homedir())。相对路径
19
- * 直接 400 —— 不拿宿主进程的当前目录去猜,那是另一个语义;
20
- * - entries 名字升序(**大小写不敏感**),文件与文件夹混在一份里,分组由客户端做;
21
- * - hidden = 名字以 '.' 开头,照旧标记但返回,过滤交给客户端;
22
- * - kind 由 stat 得出(**跟随符号链接**):目录是 'folder',其余是 'file',指向目录的
23
- * 链接算 'folder';
24
- * - size(字节)/ modified(epoch 毫秒)**只对文件给**;单条 stat 失败(断链、权限)按
25
- * 'file' 返回且不带这两格 —— 一个坏条目不许把整条列表带走;
26
- * - 上限复用 directory-browse.js 的 DEFAULT_MAX_ENTRIES,超了截断并置 truncated = true;
27
- * - crumbs 复用 directory-browse.js 的 ancestryCrumbs(根 → 当前),形状与选目录那条一致。
28
- * 失败一律 400 + `{ ok:false, error:{ code, message } }`,code 取 bad-request / not-found /
29
- * not-directory / permission,message 是简短英文,人话由 App 侧自己的字典翻。
14
+ * 契约:path 可选且必须绝对(缺省为主目录,相对路径 400);名字升序、大小写不敏感;hidden
15
+ * 标记但返回;kind 由 stat 得出并跟随符号链接;size / modified 只对文件给,单条 stat 失败
16
+ * 按 file 返回;上限复用 DEFAULT_MAX_ENTRIES,超了置 truncated;失败统一 400 + code。
30
17
  */
31
18
 
32
19
  const { opendir, stat } = require('node:fs/promises');
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 盘符层:Windows 上「此电脑」里那一排盘,供手机端「添加工作区」跨盘浏览。
5
+ *
6
+ * 为什么需要这一层:宿主 browse 后端只列**某一层目录的内容**、从不枚举盘符,而目录浏览器
7
+ * 一打开就落在被控端主目录 —— Windows 上它永远在 C 盘。于是 D/E 盘从来不是「读不到」,
8
+ * 而是客户端根本没有任何入口能跳过去(手输完整路径能进,但那条路藏起来了)。
9
+ *
10
+ * 为什么是探而不是查:本插件没有 shell 能力面,也不该为了 26 次 stat 去开一个。A–Z 逐个
11
+ * `stat('X:\\')`,能 stat 成目录的才算那块盘存在;不存在的盘符会很快返回 ENOENT,整个过程
12
+ * 只有一次网络请求的开销。
13
+ *
14
+ * 非 Windows 返回根目录一项:调用方只需处理「一排根」这一种形状,不必自己判平台;只有一项
15
+ * 时客户端不显示这一层,所以 Mac/Linux 的界面一个像素都不变。
16
+ */
17
+
18
+ const { stat } = require('node:fs/promises');
19
+
20
+ /** 盘符顺序取 A–Z,`Promise.all` 保序,所以结果天然按字母排。 */
21
+ const DRIVE_LETTERS = Object.freeze('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
22
+
23
+ /** 一块盘在客户端眼里的样子:与目录行同形状(`hidden` 恒 false)。 */
24
+ function driveRoot(letter) {
25
+ return { name: `${letter}:`, path: `${letter}:\\`, hidden: false };
26
+ }
27
+
28
+ /**
29
+ * Windows:真实存在的盘符。软盘 A:/B: 在现代机器上 stat 一下就 ENOENT,不会卡住。
30
+ *
31
+ * @param {(path: string) => Promise<{ isDirectory(): boolean }>} statFile 注入点,测试用。
32
+ * @returns 盘符行数组(按盘符升序,探不到的跳过)
33
+ */
34
+ async function listWindowsRoots({ statFile = stat } = {}) {
35
+ const rows = await Promise.all(
36
+ DRIVE_LETTERS.map(async (letter) => {
37
+ const root = driveRoot(letter);
38
+ try {
39
+ return (await statFile(root.path)).isDirectory() ? root : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }),
44
+ );
45
+ return rows.filter((row) => row !== null);
46
+ }
47
+
48
+ /**
49
+ * 这台被控端可跳的根:Windows 是各块盘,其余平台是文件系统根。
50
+ *
51
+ * @param {{ platform?: string, statFile?: typeof stat }} [options] 平台与 stat 注入点(测试用)。
52
+ * @returns 根行数组
53
+ */
54
+ async function listRoots({ platform = process.platform, statFile = stat } = {}) {
55
+ if (platform === 'win32') return listWindowsRoots({ statFile });
56
+ return [{ name: '/', path: '/', hidden: false }];
57
+ }
58
+
59
+ module.exports = { DRIVE_LETTERS, listRoots, listWindowsRoots };
@@ -3,22 +3,11 @@
3
3
  /**
4
4
  * 宿主 apiProxy 的回执信封。
5
5
  *
6
- * 宿主所有 `apiProxy.*` 调用都回同一个形状:
7
- * 成功 `{ result: { ok: true, value } }`
8
- * 失败 `{ result: { ok: false, error: { code, message, details? } } }`
9
- * 异常(只有 envelope.js 之外的调用方会碰到)`{ result: { ok: true, value } }` 的缺位形态
6
+ * 所有 `apiProxy.*` 调用都回同一个形状:成功 `{ result: { ok: true, value } }`,失败
7
+ * `{ result: { ok: false, error: { code, message, details? } } }`。
10
8
  *
11
- * 以前这个拆信封的动作在仓库里有 6 种写法:history-derive.js / workspaces.js /
12
- * agent-presets.js 三份逐字相同;session-cleanup.cjs 是「只取 value」的变体;
13
- * plugins.js / references.js 在此之上又叠了各自的字段校验与错误码
14
- * (那两个是有意的领域解码,不算重复);auto-archive.cjs 则干脆绕过信封直接读
15
- * `response?.value?.items`——它读的那一层压根不是信封,是 bug。
16
- *
17
- * 把唯一实现收到这里,是因为 5 个模块为了拿这 5 行去 require 一个 1400 行、
18
- * 带 9 个模块级 WeakMap 的 sessions.js——那才是 sessions.js 拆不动的真正原因:
19
- * 它同时扮演了会话领域、缓存中心、RPC 封装和公共工具库四个角色。
20
- *
21
- * 依赖方向:envelope.js 是叶子模块,不 require 任何东西。
9
+ * 拆信封只有这一份实现:此前几个模块各写一遍,还要为了这 5 行去 require 一个 1400 行的
10
+ * sessions.js。envelope.js 是叶子模块,不 require 任何东西。
22
11
  */
23
12
 
24
13
  /**
@@ -1,20 +1,13 @@
1
1
  /**
2
2
  * 设备级事件流(GET /api/mobile/v1/events/stream):手机上侧栏那条 SSE。
3
3
  *
4
- * 它只被当成「有东西变了」的信号,绝不当作数据源(真正的数据还要 App 自己去拉列表),
5
- * 所以这里把每条宿主帧削到只剩能表示「列表需要重拉」的部分(见 listRelevantPayload)。
6
- * 但宿主在 DSH 0.2.0 兼容桥下只会订阅 workspace/follow + session/control,会话状态变化
7
- * 走的是 session/projection(被白名单丢掉),于是这条流连着却可能长时间一帧都不发;
8
- * 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
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
- * 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
4
+ * 它只是「有东西变了」的信号,绝不是数据源,所以每条宿主帧都削到只剩「列表需要重拉」的
5
+ * 部分。0.2.0 兼容桥只订阅 workspace/follow + session/control,会话状态变化会被丢掉;插件
6
+ * 因此自产四类帧:device/list-changed、device/session-activity、device/desktop-view-changed
7
+ * 与 device/heartbeat,App 靠它们判断流还活着并决定是否降频兜底轮询。
13
8
  *
14
- * 断流补偿:这条流不做 Last-Event-ID 续传(App 只把它当信号,不当数据源),所以**连上时**
15
- * 会立刻补发「当前状态」——一帧最近已知 revision 的 device/list-changed + 每个已跟踪会话
16
- * 各一帧 device/session-activity(见 replayKnownSignals)。补的是断流窗口,不是新变更:
17
- * 只读信号总线的快照,不 bump、不前移基准,App 侧对同 revision / 同水位幂等。
9
+ * 这条流没有 Last-Event-ID 续传,所以连上时立刻补一帧当前 revision 与每个已跟踪会话的水位;
10
+ * 补的是断流窗口,不是新变更。
18
11
  */
19
12
  'use strict';
20
13
 
@@ -30,11 +23,8 @@ const DEDUP_WINDOW = 2048;
30
23
  const HEARTBEAT_MS = 15_000;
31
24
 
32
25
  /**
33
- * 诊断日志。这条流过去的失败路径是一个空 catch(里面只有一句「客户端断开或流已结束」
34
- * 的注释),于是「连着却一句话都不说」在宿主侧没有任何痕迹:手机把兜底轮询降成 60s,
35
- * 而宿主日志里查不到这条流是正常收尾、被客户端断开,还是循环抛错。会话级流
36
- * (mobile/stream.js)早就有 logStream,这里是它的设备级对偶——任何一条异常路径都必须
37
- * 留下可 grep 的行。
26
+ * 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 60s,宿主日志里却查不到这条流是
27
+ * 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
38
28
  */
39
29
  function logDeviceStream(message, error) {
40
30
  const detail = error instanceof Error ? (error.stack || error.message) : (error == null ? '' : String(error));
@@ -46,11 +36,9 @@ function logDeviceStream(message, error) {
46
36
  /**
47
37
  * 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
48
38
  *
49
- * 这条流的策略:软水位**一律** shed —— 但 shed 不能当成功:设备流没有 Last-Event-ID
50
- * 续传,丢了帧又继续读,App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须
51
- * 立即 `abort` 断流,让客户端重连后走 `replayKnownSignals()` 全量补水位。
52
- * 这比把帧堆在写缓冲里好(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞
53
- * 4GB 上限被杀)。不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
39
+ * 这条流的软水位一律 shed,而 shed 不能当成功:没有 Last-Event-ID 续传,丢了帧又继续读,
40
+ * App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须立即断流,让客户端重连后
41
+ * 走 `replayKnownSignals()` 全量补水位 —— 这比把帧堆在写缓冲里好。
54
42
  *
55
43
  * 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉(调用方必须断流),
56
44
  * `needDrain:true` 写进去了但底下已经写不动。
@@ -60,7 +48,7 @@ function writeSse(res, payload, frame, sealFrame) {
60
48
  frame,
61
49
  namespace: 'device',
62
50
  // 协议 v2 的会话上这条流也逐帧封装:`App` 判定「这条会话是加密的」之后,收到一帧
63
- // 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询(2026-10-24 现场)。
51
+ // 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询。
64
52
  sealFrame: typeof sealFrame === 'function' ? sealFrame : null,
65
53
  shedWhenBuffered: () => true,
66
54
  });
@@ -87,14 +75,10 @@ async function* mergeHostAndMux(ctx, signal) {
87
75
  }
88
76
 
89
77
  /**
90
- * Trim a device-wide stream payload to the metadata the mobile session-list
91
- * actually consumes. `session/event` frames carry the FULL message content
92
- * (payload.event.data includes the whole assistant/user message); forwarding
93
- * them to every paired phone floods the relay and the client with megabytes
94
- * whenever a session is actively generating. The device-wide stream is only
95
- * used by the phone to refresh the session list (turn/start/end/title), so it
96
- * needs the event identity and inner type, not the content. Other frame types
97
- * (summary/queue/projection/question/approval/host) are small and kept intact.
78
+ * Trim a device-wide stream payload to the metadata the mobile session-list consumes.
79
+ * `session/event` frames carry the full message content; forwarding them to every paired
80
+ * phone floods the relay whenever a session is generating. Only the event identity and
81
+ * inner type are needed. Other frame types are small and kept intact.
98
82
  */
99
83
  function lightPayload(payload) {
100
84
  if (!payload || typeof payload !== 'object' || payload.type !== 'session/event') return payload;
@@ -174,15 +158,13 @@ async function handleDeviceStream(ctx, req, res) {
174
158
  return !result.needDrain;
175
159
  };
176
160
  /**
177
- * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
161
+ * 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
178
162
  *
179
- * 设备级流此前只有宿主事件这一个来源,而宿主的 0.2.0 兼容桥只订阅 workspace/follow +
180
- * session/control,会话状态变化(session/projection)会被 listRelevantPayload 丢掉 ——
181
- * 手机于是「连着却收不到任何会话状态变化」,兜底轮询又被降成 60s。这里补上真源。
163
+ * 设备级流此前只有宿主事件这一个来源,而 0.2.0 兼容桥的订阅里没有 session/projection,
164
+ * 会话状态变化会被丢掉,手机于是「连着却收不到任何变化」。
182
165
  *
183
- * 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再
184
- * 等于「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听(隧道断着也照常判定),
185
- * 收尾时必须注销,否则它永远以为有人听。
166
+ * 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再等于
167
+ * 「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听,收尾时必须注销。
186
168
  */
187
169
  const releaseSignal = sharedListSignal.watch();
188
170
  const unsubscribeSignal = sharedListSignal.onBump((event) => {
@@ -192,17 +174,12 @@ async function handleDeviceStream(ctx, req, res) {
192
174
  /**
193
175
  * 自产的「某个会话的正文水位涨了」帧,与「清单变了」是两条独立的通知。
194
176
  *
195
- * 清单信号只说「列表内容变了」,手机据此重拉列表;但列表摘要里的 lastMessage 只是
196
- * 一句话,正文(回复的长文本、提问的选项)在会话缓存里。没有这条帧时,只有**打开着**
197
- * 那个会话的手机才会实时收到正文(session/stream 是按会话订阅的),没打开的会话要等
198
- * 用户点进去才拉 —— 而这正好是「点开对话要等一会儿才看到内容」的来源。
177
+ * 清单信号只说列表内容变了;而正文在会话缓存里,没有这条帧时只有打开着那个会话的手机才
178
+ * 会实时收到正文,没打开的会话要等点进去才拉 —— 这正是「点开对话要等一会儿才看到内容」
179
+ * 的来源。
199
180
  *
200
- * 水位由镜像调度器**算出后广播**,与镜像是否真的推进去解耦(见 lib/mirror/pusher.cjs
201
- * 的 announceSessionActivity:隧道未连、内容未变跳过、推送成功三条路径都会广播)——
202
- * 它说的是「本机正文到哪了」,不是「中转镜像里有到哪了」。手机收到帧后立刻带 afterSeq
203
- * 去 /session/messages 增量拉:走中转的手机读到的是刚推上去的镜像,走局域网直连的手机
204
- * 读到的是 sidecar 本地那份快照(/tunnel/send 先落盘再尝试发),两条路都拿得到。
205
- * 同样必须直订 + 收尾注销,理由与上面那条一致。
181
+ * 水位由镜像调度器算出后广播,说的是「本机正文到哪了」,不是「中转镜像里有到哪了」,与
182
+ * 镜像是否真的推成功解耦。同样必须直订 + 收尾注销。
206
183
  */
207
184
  const unsubscribeActivity = sharedSessionSignal.onBump((event) => {
208
185
  if (ac.signal.aborted) return;
@@ -211,14 +188,12 @@ async function handleDeviceStream(ctx, req, res) {
211
188
  /**
212
189
  * 自产的「桌面侧栏折叠态变了」帧。
213
190
  *
214
- * 桌面端那份 groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报,
215
- * 落在 lib/desktop-view.cjs;手机要拿到它只能问 GET /desktop-view。过去 App 用
216
- * 「跟列表刷新走 + 5 分钟最短间隔」去猜它有没有变,稳态下一天白问几百次 —— 而插件
217
- * 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
191
+ * groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报落在
192
+ * lib/desktop-view.cjs;手机要拿它只能问 GET /desktop-view。过去 App 按「跟列表刷新走 +
193
+ * 5 分钟最短间隔」去猜它变没变,而插件早就知道它哪一刻变了。
218
194
  *
219
- * 只发 updatedAt、不发内容:手机据此重拉那一发,拿到的是与列表同一口径的完整镜像,
220
- * 也不必让这条「信号流」承担数据同步(它的帧一律可丢、可重放)。
221
- * 同样必须直订 + 收尾注销,理由与上面两条一致。
195
+ * 只发 updatedAt、不发内容:手机据此重拉那一发,不必让信号流承担数据同步。
196
+ * 同样必须直订 + 收尾注销。
222
197
  */
223
198
  const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
224
199
  if (ac.signal.aborted) return;
@@ -227,16 +202,11 @@ async function handleDeviceStream(ctx, req, res) {
227
202
  /**
228
203
  * 连上就先补一帧「当前状态」。
229
204
  *
230
- * 设备级流没有会话级流那套 Last-Event-ID 续传(App 对这条流不做断点续传,它只把帧
231
- * 当「有东西变了」的信号),所以断流窗口里发生的清单变更与正文水位在重连后必须补上。
232
- * 不补的后果:手机重连后 state 是空的,而侧栏兜底轮询已经因为「流活着」被降到 60s ——
233
- * 中间这几分钟的变化要等到下一次真实变更才可见。
205
+ * 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
206
+ * 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 60s。
234
207
  *
235
- * 这里只读两条信号总线**已经广播过**的最近值(snapshot),既不 bump 也不前移任何基准,
236
- * 所以不会制造新变更、不影响推送侧的幂等。App 侧对同 revision / 同水位是幂等的
237
- * (app 的 handleDeviceSignal 按 revision 与 seq 判重),重连补发不会引发重复刷新。
238
- * 帧形状与实时帧完全一致(同为 device/event 信封包 device/list-changed 或
239
- * device/session-activity),App 不需要任何改动。
208
+ * 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
209
+ * App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
240
210
  */
241
211
  const replayKnownSignals = () => {
242
212
  const list = sharedListSignal.snapshot();
@@ -3,15 +3,12 @@
3
3
  /**
4
4
  * 扩展卡的宿主侧:卡状态账本、扩展动作登记表,以及给模型用的 `extension_present` 工具。
5
5
  *
6
- * 为什么卡状态要落在宿主家里:一张卡「现在长什么样」不能只靠声明它的那一次工具调用。
7
- * 扩展页回写标题与说明之后,手机上重进会话、刷新列表都会从历史重新拼卡 —— 没有这份账本,
8
- * 卡会一直停在声明那一刻的样子(卡的状态得由宿主认账)。账本与会话
9
- * 日志无关,清理会话不连带它,与统计账本同口径。
6
+ * 卡状态要落在宿主家里:扩展页回写标题与说明之后,手机重进会话、刷新列表都会从历史重新
7
+ * 拼卡,没有这份账本,卡就停在声明那一刻的样子。账本与会话日志无关,清理会话不连带它。
10
8
  *
11
- * 为什么动作名是一张硬编码白名单:扩展是网页,它能让宿主做的事必须由宿主预先枚举,而不是
12
- * 由扩展在消息里现拼一个动作名交给宿主执行。这条边界和移动 API 的能力面是同一条 —— 移动
13
- * API 不暴露 shell、系统设置、任意文件读写,这里也一个都不开。**登记表现在是空的**:宿主动作
14
- * 这一档还没有对任何扩展放开,所以任何动作名都必须被拒。
9
+ * 动作名是一张硬编码白名单:扩展能让宿主做的事必须由宿主预先枚举,不能在消息里现拼一个
10
+ * 动作名。这条边界与移动 API 的能力面相同——不暴露 shell、系统设置、任意文件读写。
11
+ * 登记表现在是空的,所以任何动作名都必须被拒。
15
12
  */
16
13
 
17
14
  const fs = require('node:fs');
@@ -3,23 +3,14 @@
3
3
  /**
4
4
  * 项目目录的 Git 仓库识别:回传「这里有没有仓库、仓库根在哪、当前在哪个分支」。
5
5
  *
6
- * 为什么由插件做:手机看不到电脑上的目录,侧栏那个 Git 入口的可见性只能靠宿主回传的
7
- * 标记决定(docs/git-manager.md 第 2 节)。判据不许 App 猜,也不许等用户点进去才发现
8
- * 是空页 —— 字段缺失一律按「不是仓库」处理。
6
+ * 手机看不到电脑上的目录,侧栏 Git 入口的可见性只能靠宿主回传的标记;判据不许 App 猜,
7
+ * 字段缺失一律按「不是仓库」处理。
9
8
  *
10
- * 为什么只读文件、不起 git 进程:这段代码跟着工作区清单一轮一轮地跑(镜像推送每
11
- * 500ms 构建一次清单),起进程太贵;而且宿主上未必装了 git,装没装不该影响「这个项目
12
- * 是不是仓库」这个事实。仓库的形状就是 `.git` 这个入口加上里面的 HEAD,读到就够答了。
9
+ * 只读文件、不起 git 进程:这段代码跟着工作区清单一轮轮跑,起进程太贵,而且宿主未必装了
10
+ * git。三种都算有仓库:项目根自己就是仓库、项目落在某个仓库的子目录里(往上找最近的根)、
11
+ * 项目下面并列着若干仓库。往上走到主目录为止,往下只扫两层、条数封顶。
13
12
  *
14
- * 认哪些(三种都算「有仓库」):
15
- * 1. 项目根自己就是仓库根;
16
- * 2. 项目目录落在某个仓库的子目录里 —— 往上找最近的那个根,操作按它执行;
17
- * 3. 项目下面并列着若干个仓库(第 1、2 层,含 `.git` 是文件的子模块与 worktree)。
18
- * 往上走到主目录为止不再往上:主目录本身是个 dotfiles 仓库时,再往上是把每个项目都
19
- * 判成仓库,那不是这些项目的意思。往下只扫两层、条数封顶,扫满置 `truncated`。
20
- *
21
- * 结果按目录缓存一会儿(GIT_CACHE_MS):下拉刷新带 `fresh` 跳过缓存,见
22
- * lib/mobile/workspaces.js 的 handleWorkspaces。
13
+ * 结果按目录缓存一会儿,下拉刷新带 `fresh` 跳过。
23
14
  */
24
15
 
25
16
  const { readdir, readFile, stat } = require('node:fs/promises');
package/lib/mobile/git.js CHANGED
@@ -1,26 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 移动端 Git 管理器:十一条 `/api/mobile/v1/git/*` 的 handler。
4
+ * 移动端 Git 管理器:`/api/mobile/v1/git/*` 的 handler。
5
5
  *
6
- * 能力面刻意收成**固定命名的动词**,不接受「传一条 git 命令」:这条 API 的边界是不暴露
7
- * shell,而 `git -c core.pager=less ...` 这种透传等于把 shell 交出去。所以这里只认
8
- * 「仓库摘要 / 变更清单 / 单个文件的 diff / 历史 / 单次提交 / 分支 / 概览」加上
9
- * 「暂存 / 取消暂存 / 提交 / 切分支 / 新建分支 / 推送到上游」这些动作,参数只有仓库、
10
- * 相对路径、分支名、提交信息四类值(push 连分支名都不收:只推当前分支到它自己的上游)。
6
+ * 能力面刻意收成固定命名的动词,不接受「传一条 git 命令」:这条 API 的边界是不暴露 shell,
7
+ * 透传等于把 shell 交出去。参数只有仓库、相对路径、分支名、提交信息四类值。
11
8
  *
12
- * 归属校验在这一层做两遍:① `workspaceId` 必须是这台设备已注册的工作区(宿主 `workspace.list`
13
- * 里找得到);② 请求里的仓库根必须是 `lib/mobile/git-repos.js` 给这个项目识别出来的那几个根
14
- * 之一 —— 于是「随便传一个路径」这条路根本走不通,`..`、绝对路径、仓库外的目录都进不来。
9
+ * 归属校验做两遍:workspaceId 必须是本设备已注册的工作区;仓库根必须是 git-repos.js 给这个
10
+ * 项目识别出来的根之一,于是随意传路径、`..`、仓库外目录都进不来。写操作按仓库串行,避免
11
+ * 并发写撞 `.git/index.lock`,写完顺手回一份新的变更清单。
15
12
  *
16
- * 写操作按仓库串行(`withRepoLock`):手机上连点两下就是两个并发写,撞上 `.git/index.lock`
17
- * 会互相踩死;写完顺手回一份新的变更清单,省掉前端一次往返。
18
- *
19
- * 明确不做:合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 /
20
- * 子模块管理 / fetch、pull。**push 只做最窄的一档**(2026-10-22):推当前分支到它已有的上游,
21
- * 不接受 `--force`、不收分支名参数 —— 远端分叉、没有上游一律拒掉并叫用户回电脑处理,
22
- * 因为「拉下来再合」这件事本来就留在电脑上。丢弃改动也没做 —— 那是不可恢复的删工作,
23
- * 接口留白比给一个能被误按的按钮安全。
13
+ * 明确不做合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 / 子模块 /
14
+ * fetch、pull;push 只推当前分支到已有上游,远端分叉或没有上游一律拒掉。丢弃改动也没做,
15
+ * 接口留白比一个能被误按的按钮安全。
24
16
  */
25
17
 
26
18
  const { isAbsolute, resolve } = require('node:path');
@@ -434,8 +426,8 @@ function cleanCommitMessage(raw) {
434
426
  * `llm` 服务(跟渠道包、agent loop 同一条流式路径),让它写一行 Conventional Commits 标题填进
435
427
  * 提交框 —— 只是**填框**,不提交、不推送。跟 VSCode 那颗一致:没暂存也要能写。
436
428
  *
437
- * 边界:模型要由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒(没有改动可总结,让模型凭空写一条比报错更糟)。(没有改动可总结,
438
- * 让模型凭空写一条比报错更糟)。这里只读 `git diff --cached`,不写盘。
429
+ * 边界:模型由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒,让模型凭空写一条
430
+ * 比报错更糟。这里只读 diff,不写盘。
439
431
  */
440
432
  async function handleGitCommitMessage(ctx, req, res) {
441
433
  const body = await bodyJson(req);
@@ -48,15 +48,12 @@ function capDiffText(text) {
48
48
  /**
49
49
  * 收窄 tool/result 的 `data.meta`(write/edit 的 FsDiffMeta)为可回传的 diff 列表。
50
50
  *
51
- * 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在
52
- * `result.meta` 上(dsh-tool-fs 的 computeHunkDiffs,DIFF_CONTEXT=3),
53
- * agent loop 原样写进 tool/result 事件(dsh-agent-loop/lib/index.js:313
54
- * `...result.meta !== void 0 ? { meta: result.meta } : {}`),并随会话日志持久化。
55
- * 此前移动端投影只读 message.content,把 meta 整个丢掉——于是 App 里 write/edit
56
- * 只剩一坨 JSON 参数,而桌面显示的是真正改了什么。
51
+ * 桌面 diff 卡的数据源头就是这里:fs 工具把「已应用的分块上下文 diff」挂在 `result.meta`
52
+ * 上,随 tool/result 事件持久化。投影只读 message.content 的话 meta 会整个丢掉,App 里
53
+ * write/edit 只剩一坨 JSON 参数。
57
54
  *
58
- * 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),
59
- * 畸形数据返回 undefined 让调用方退回通用卡片,绝不抛错。
55
+ * 只接受形状正确的条目(path 非空字符串、newText 字符串、oldText 字符串或 null),畸形数据
56
+ * 返回 undefined 让调用方退回通用卡片,绝不抛错。
60
57
  */
61
58
  function normalizeResultDiffs(meta) {
62
59
  if (!meta || typeof meta !== 'object' || !Array.isArray(meta.diffs)) return undefined;
@@ -328,19 +325,14 @@ function presentedPathsOf(events) {
328
325
 
329
326
  /**
330
327
  * 从 history 事件折叠出消息与「结构化行」(wire v1 扩展,向后兼容):
331
- * - 普通消息维持现状:{ id, seq, time, role, text, blocks?, images?, clientMessageId? }(user/assistant);
332
- * - compaction:{ kind:'compaction', id, seq, time, state:'running'|'done'|'failed', error?, turn?,
333
- * summary?, shadowedItemCount?, shadowedTokenCount? };
334
- * 压缩检查点 user/message(source.kind==='plugin' && source.plugin==='compact')绝不渲染成用户气泡,
335
- * 渲染成 compaction 行(id=compactionId,state='done'),并用同 compactionId 的 compaction/summary 补 counts;
336
- * compaction/end 给这次尝试落定(带 error → state='failed' + error 原文,否则 'done');
337
- * **同一轮里的多次尝试只占一行**(新尝试原地顶替还没落定的那条)—— 宿主一步重试一次,
338
- * 不并就是几十条「正在压缩…」;
339
- * - retry:{ kind:'retry', id: retryId, seq, time, status:'scheduled'|'started', retry, maxRetries, delayMs?, failure? };
340
- * - turn-error / max-tokens:{ kind:'turn-error', id:'turn-<turn>', message, code? } / { kind:'max-tokens', id:'turn-<turn>' };
341
- * - command:{ kind:'command', id:commandId, name, status:'running'|'done', resultKind?, text? }
342
- * (command/run 发出行,command/done 原地升级:name 保 run 的,resultKind/text 取 done 的)。
343
- * 结构化项与消息按事件 seq 升序混排返回;旧载荷字段缺失时跳过对应项,绝不抛错。
328
+ * - 普通消息:{ id, seq, time, role, text, blocks?, images?, clientMessageId? };
329
+ * - compaction:渲染成 compaction 行而不是用户气泡,id=compactionId;同 compactionId 的
330
+ * compaction/summary 补 counts,compaction/end 落定 state(带 error 即 failed);
331
+ * 同一轮的多次尝试只占一行,新尝试原地顶替还没落定的那条;
332
+ * - retry:{ kind:'retry', id: retryId, status:'scheduled'|'started', retry, maxRetries, … };
333
+ * - turn-error / max-tokens:id 为 `turn-<turn>`;
334
+ * - command:command/run 发出行,command/done 原地升级。
335
+ * 结构化项与消息按 seq 升序混排;旧载荷字段缺失时跳过对应项,绝不抛错。
344
336
  */
345
337
  function deriveMessages(events) {
346
338
  const out = [];
@@ -357,7 +349,7 @@ function deriveMessages(events) {
357
349
  // 交付声明预扫描(`deliverables/presented`):模型调 `present` 声明「这几份请你收下」时,
358
350
  // 宿主的 dsh-tool-present 会往会话日志追一条这个事件。桌面那张「交付文件」卡只认它 ——
359
351
  // **不是**「这一轮改过哪些文件」。手机第一版从 write/edit 反推,于是「每改一次都冒卡、
360
- // 命令行改的却一张都没有」(2026-10-22 用户报的正是这个)。这里按 **turn** 归并,
352
+ // 命令行改的却一张都没有」。这里按 **turn** 归并,
361
353
  // 同一个路径后声明的覆盖先声明的(桌面 `presentedForClosing` 的 `files.set` 同口径)。
362
354
  const presentedByTurn = new Map();
363
355
  for (const entry of events) {
@@ -383,8 +375,7 @@ function deriveMessages(events) {
383
375
  * 这一轮**被 write / edit 动过**的文件,按轮攒。
384
376
  *
385
377
  * 为什么按轮而不是按单条消息:一份文件是一轮里某一步写的,而收尾答复那条消息自己一个
386
- * 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的(2026-10-25
387
- * 现场:手机上那个文件名一直是灰的,数一遍 54 条消息只有 4 条带得动这份清单)。
378
+ * 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的。
388
379
  * 桌面 `dsh-client-ui-deliverables` 的 `selectProducedFiles(owner)` 也是按**整轮**取。
389
380
  *
390
381
  * 攒完在循环之后回填:同一轮里先发的消息也要能看到这一轮后几步才写出来的文件。
@@ -804,25 +795,16 @@ function deriveMessages(events) {
804
795
  * 入口窗口的「内容化」裁剪(`GET /session/messages?thin=1`,只对不带 beforeSeq / afterSeq 的
805
796
  * 一次性入口窗口生效;往上翻页与增量补拉保持完整)。
806
797
  *
807
- * 为什么:入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息
808
- * (推理块 + 工具调用块)。一次长回合几十上百步,最后 40 条可能全是这些过程消息 ——
809
- * 用户自己那句话被挤出窗口,进对话只剩半轮,看起来像「我说的话凭空消失」。
798
+ * 入口原来按「消息条数」截尾巴,而宿主每跑一个 agent 步就落一条 assistant 消息,长回合最后
799
+ * 几十条可能全是过程消息,用户自己那句话被挤出窗口。现在按「轮」分组,只留用户消息、助手
800
+ * 正文、带图消息与结构化行;过程明细不下发,每轮裁掉多少项挂在开头那条用户消息的
801
+ * `turnProcess` 上,点开时按 startSeq / endSeq 回头要那一轮的事件。
810
802
  *
811
- * 现在入口按「轮」分组、只留用户看得见的内容:
812
- * - 用户消息 / 定时任务唤醒行(原样);
813
- * - 助手正文(中途汇报与最终答复都算)与带图的消息;
814
- * - 结构化行(压缩、重试、本轮失败等,体量小,且带着这一轮的状态)。
815
- * 过程明细(推理块、工具调用块、只有过程没有文字的消息)不下发,开启某一轮时再逐轮补取。
816
- * 每轮被裁掉多少项、范围到哪,挂在**这一轮开头那条用户消息**的 `turnProcess` 上:App 用它
817
- * 渲染「还有 N 项处理过程」,点开时按 startSeq / endSeq 回头要那一轮的事件。
803
+ * 窗口最老那一组没有起点(截断正好切在一轮中间)时整组丢掉,否则会画出「凭空冒出的 AI
804
+ * 发言」,它会被「加载更早」整轮补回。
818
805
  *
819
- * 窗口最老那一组没有起点(宿主按 maxMessages 截断时正好切在一轮中间)时整组丢掉:
820
- * 半轮的消息没有用户消息打头,画出来就是「凭空冒出的 AI 发言」;它会被「加载更早」整轮补回。
821
- *
822
- * 三种「出卡」工具的调用块要留一条(定时任务 / 计划 / 扩展卡):轮尾那几张卡是从工具块
823
- * 现算的(`services/viewmodels/turn-tail-cards` 的 scheduleTailCard / planTailCard /
824
- * extensionTailCard),整块丢掉会让卡在收口时凭空不见。它们体量很小。
825
- * **新增一种轮尾卡时要同步这里**,否则那张卡只在「点开过程之后」才出现。
806
+ * 三种「出卡」工具(定时任务 / 计划 / 扩展卡)的调用块要留一条,轮尾那几张卡是从工具块现算
807
+ * 的,整块丢掉会让卡凭空不见。新增一种轮尾卡时要同步这里。
826
808
  */
827
809
  const ENTRY_OUTCOME_TOOLS = new Set(['schedule_create', 'exit_plan_mode', 'extension_present']);
828
810
 
@@ -3,12 +3,10 @@
3
3
  /**
4
4
  * 端点表:旧 `apiProxy.<ns>.<method>` 的键 ⇄ 宿主 wire 名与参数形状。
5
5
  *
6
- * ⚠️ 这张表是人肉对照宿主 descriptor 校验的,**没有任何机器可校验机制**,而它错一次的
7
- * 后果是静默的:`sessions.projections` 曾少包一层 `request`,宿主每次都回 400,
8
- * 而 App 侧把 400 catch 掉了,于是整个统计 UI 凭空消失。args 的键名必须**恰好**是
9
- * descriptor 的 wire 名(多一个少一个都报 gateway/arguments-invalid,dropUnexpectedArgs
10
- * 只摘多余的键,救不了「少一个」)。改动前请先在 test/host-bridge-endpoints.test.cjs
11
- * 里补一条参数化用例。
6
+ * 这张表是人肉对照宿主 descriptor 校验的,没有任何机器校验,错的后果是静默的:少一层
7
+ * `request` 时宿主只回 400,而 App 把 400 catch 掉,整个页面凭空消失。args 的键名必须恰好
8
+ * 是 descriptor 的 wire 名,多一个少一个都报 gateway/arguments-invalid。改动前先在
9
+ * test/host-bridge-endpoints.test.cjs 里补一条参数化用例。
12
10
  */
13
11
 
14
12
  const { invoke, invokeEnvelope, ok, fail, pick, requestId, unsupported, warn } = require('./transport.js');