dsh-zen-remote 1.1.16 → 2.0.0

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 (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,1272 @@
1
+ /**
2
+ * Pure mergers for the three GLOBAL calls (T23b-2): `workspace/follow` and
3
+ * `session/control` are STREAMS whose frames carry no session-locating
4
+ * argument, and `session/list` is an unscoped invoke — the sidebar reads all
5
+ * three to render its tree, so a sub-client must see the server's shared
6
+ * sessions merged INTO the local answer instead of in place of it.
7
+ *
8
+ * Frame and result shapes are the generated 0.2.0 codecs, field by field:
9
+ * `workspace_follow_result` (baseline / upsert / remove / order / archived /
10
+ * pinned), `session_control_result` (exactly two frame types: `baseline`
11
+ * nesting `value.projections`, and one-key `projection` updates), and
12
+ * `session_list_result` (`{ items }` — no cursor field). The same shapes the
13
+ * server filters by (relay-filter.ts); remote frames arriving here have
14
+ * ALREADY been narrowed to the shared sessions and carry ORIGINAL ids.
15
+ *
16
+ * The rules, mirroring tasks/T23b2.md as corrected by tasks/T23b2-fix.md:
17
+ * - local frames pass through, except that `baseline` / `order` get the
18
+ * remote workspaces APPENDED to their order (remote groups sort after
19
+ * local) and the `archived` / `pinned` lists merge local + remote;
20
+ * - a remote baseline NEVER travels as a second baseline — it would wipe the
21
+ * local data. It becomes one virtualized `upsert` per remote workspace,
22
+ * then the merged `order` / `archived` / `pinned`;
23
+ * - remote frames that arrive before the local baseline are CACHED as state
24
+ * only — the UI has seen nothing yet, so emitting them would order
25
+ * workspaces the local baseline would immediately erase;
26
+ * - a remote stream that died (or a merely offline relay) emits NOTHING and
27
+ * keeps the shown state ({@link WorkspaceMerger.onRemoteDown}): the UI's
28
+ * `ClientWorkspaceModel.remove()` records ids in a never-cleared blacklist
29
+ * and both `upsert` and baseline replacement drop blacklisted ids, so one
30
+ * `remove` for a virtual id would make that group un-revivable on
31
+ * reconnect — while the UI itself keeps the last complete projection
32
+ * visible across carrier loss (its `handleCarrierFailure`);
33
+ * - a NEW remote baseline on the same server is DIFFED against the shown
34
+ * set: still present → fresh `upsert` (content from the new baseline),
35
+ * absent (deleted server-side / no longer shared) → `remove`, then the
36
+ * merged `order` / `archived` / `pinned`;
37
+ * - a server RENAME (same serverId) re-upserts the shown groups under the
38
+ * new title — no removals ({@link WorkspaceMerger.onServerRenamed});
39
+ * - only a serverId CHANGE removes the whole group
40
+ * ({@link WorkspaceMerger.onRemoteGone}): the new prefix is fresh, so it
41
+ * can never collide with the UI's blacklist. The relay entering
42
+ * `unpaired` / `revoked` is NOT this case (T23b2-fix3): the server
43
+ * persists its serverId (relay-server.ts loadServerId), so a re-pair to
44
+ * the same server reuses the prefix and is handled as a remote death
45
+ * ({@link WorkspaceMerger.onRemoteDown}) — the reconnect diff revives
46
+ * the group;
47
+ * - a relay status that is not serving normally (T34: offline, unpaired,
48
+ * revoked, interface mismatch) only ANNOTATES TITLES: setStatus +
49
+ * onStatusChanged re-upsert the shown groups under the annotated titles,
50
+ * and the reconnecting baseline (which always upserts) restores them.
51
+ * - a session the SERVER stopped serving (remote closed / idle-slept / its
52
+ * fork parent closed → the filtered workspace upsert drops it from
53
+ * `sessionIds`) keeps a TOMBSTONE in the group it was last seen in (CP4,
54
+ * reworked by CP4-client-fix2): the merger remembers the workspace each
55
+ * session id was last carried by, and the forwarded record for THAT
56
+ * workspace still lists the id (appended at the end of `sessionIds`). The
57
+ * UI's session store keeps the merged projection/list entry and nothing
58
+ * ever removes it, so without this the sidebar would park the session in
59
+ * 「未分组」 forever — and hiding it in the merged `archived` frame instead
60
+ * (what CP4-client first did) makes the RT navigation guard
61
+ * `clearArchivedCurrent` (dsh-client-ui-workspace `watchNavigation`) kick
62
+ * the OPEN session page back to the home page before its 「远程已关闭」
63
+ * banner can show. A re-share (the id back in some workspace's
64
+ * `sessionIds`) renders it live again and clears the tombstone; the
65
+ * tombstone dies with its workspace (a remove) and with the identity
66
+ * (onRemoteGone).
67
+ * - T58 reworks the tombstone's VISIBILITY, reshaped by T65: a closed-remote
68
+ * session is HIDDEN — moved into the merged `archived` set (the host's
69
+ * `sessionVisible` hides archived ids under the default filter) — while
70
+ * STAYING in its group's forwarded `sessionIds`: under「显示已归档」it
71
+ * renders in place, and it can never resurface as a 「未分组」 stray. The
72
+ * one exception is the session the user currently has OPEN: it is not
73
+ * hidden at all (the page must keep its 「远程已关闭」 banner), and once
74
+ * the caller reports a different current session
75
+ * ({@link WorkspaceMerger.setCurrentSession}) it moves into `archived`
76
+ * too. `archived` is exactly what the host's navigation guard
77
+ * `clearArchivedCurrent` acts on, so the CURRENT session may never enter
78
+ * it — and an unreadable current-session signal falls back to the
79
+ * conservative tombstone behavior (nothing is hidden). Because the
80
+ * browser's report can lag the user's switch by up to a poll interval, a
81
+ * JUST-created tombstone is not hidden for a short grace (T65, injectable
82
+ * clock): the 1s poll re-judges with the fresh report afterwards — the
83
+ * session the user really switched to keeps its tombstone, anything else
84
+ * hides. Re-sharing the session takes it back out of `archived`
85
+ * (live-carried ids never count as hidden). Since hiding moves only the
86
+ * archived set, hide/reveal updates are exactly one merged archived frame
87
+ * — no group content changes, no ordering dance; the current session
88
+ * never enters the set by construction. The hidden ids are sticky: they
89
+ * survive even the death of their home workspace until a re-share or the
90
+ * identity ends.
91
+ * - a workspace with NOTHING to show is not shown at all (T56): the server
92
+ * keeps every workspace and only narrows `sessionIds` (relay-filter.ts),
93
+ * so a workspace where nothing is shared would arrive as an empty group
94
+ * and render as a bare 「服务端名 · 工作区名」 heading. A workspace the UI
95
+ * has never seen is therefore held back — no baseline item, no upsert, no
96
+ * order entry — until its forwarded `sessionIds` (live sessions or
97
+ * tombstones it must carry) first gains content. From that first forward
98
+ * on it counts as SHOWN and stays shown even when the sessions leave
99
+ * again: a `remove` would blacklist the id in the UI's ClientWorkspaceModel
100
+ * and make the group un-revivable, so an emptied group keeps its (empty)
101
+ * display until the page reload rebuilds the model and this rule hides it
102
+ * again. Every removal path — the reconnecting baseline diff, an explicit
103
+ * server remove, {@link WorkspaceMerger.onRemoteGone} — emits a `remove`
104
+ * only for shown workspaces; never-shown ones leave the state silently.
105
+ *
106
+ * Two KNOWN LIMITATIONS, both rooted in the UI's `removedIds` blacklist
107
+ * never clearing during a page's life (a reload rebuilds the model from
108
+ * scratch, which is why a reload fixes both):
109
+ * - switch to a DIFFERENT server and later back to the SAME old one: the
110
+ * switch-away removed the old prefix, so the returning group's upserts
111
+ * are dropped by the blacklist and it stays invisible until the page is
112
+ * reloaded;
113
+ * - the server deletes a workspace and later shares a NEW workspace under
114
+ * the SAME id: the deletion's remove blacklists the id, and the new
115
+ * workspace's upserts are dropped the same way.
116
+ *
117
+ * Everything here is a pure state machine: no DSH imports, no network, no
118
+ * clocks — the virtual-id arithmetic is the one dependency (virtual-id.ts).
119
+ * Shapes are read defensively like relay-filter.ts does: a malformed frame is
120
+ * dropped (diagnosed through the optional hook), never forwarded.
121
+ *
122
+ * T52 adds two RESULT-side pure helpers on the same principles:
123
+ * {@link mergeModelCatalogs} folds the relay's `session/modelCatalog` answer
124
+ * into the local one (the catalog is the model-selection dropdown's data), and
125
+ * {@link virtualizeModelSelectionValue} rewrites a `modelSelection` projection
126
+ * value's provider ids to virtual group ids so the UI can find the current
127
+ * model's display name in the merged catalog.
128
+ *
129
+ * T52-fix2 made that rewrite CONDITIONAL on the server catalog listing the
130
+ * provider; T52-fix3 reverts it to UNCONDITIONAL, and the revert is final:
131
+ * the host's projection store is FIRST-WRITE-WINS per sequence number
132
+ * (dsh-api-session-controller lib/client.js:986-994 —
133
+ * `if (row?.kind === "sequenced" && seq <= row.seq) return`, and the
134
+ * seed / list blocks take the same road). A projection rewritten ORIGINAL
135
+ * while the catalog had not landed yet occupies its seq; when the catalog
136
+ * arrives, the same seq's VIRTUAL value is dropped as stale — the server
137
+ * group never gets its check mark, and resubmitting a reasoning effort
138
+ * echoes the ORIGINAL provider back, which `session/selectModel`'s routing
139
+ * refuses ("请选择「服务端 · …」分组里的模型"). The rewrite of ONE provider
140
+ * must therefore not depend on when the catalog happened to arrive. The
141
+ * accepted cost: a provider absent from the server's OWN catalog renders the
142
+ * `zr~<serverId>~provider/model` fallback string (the pre-fix2 display).
143
+ */
144
+
145
+ import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
146
+
147
+ /** The (serverId, serverName) pair a merger virtualizes with. A re-handshake
148
+ * with a different server retargets the SAME merger ({@link retarget}) so the
149
+ * locally observed baseline/order state survives the swap. */
150
+ export interface MergerIdentity {
151
+ serverId: string
152
+ serverName: string
153
+ }
154
+
155
+ /**
156
+ * The status annotation (T34) appended to every shown remote group's title
157
+ * while the relay is not serving normally. The CALLER (intercept.ts) maps the
158
+ * relay state onto one annotation — `revoked` / `unpaired` outrank `offline`,
159
+ * which outranks `mismatch` — and the merger only renders the suffix it is
160
+ * told to. The suffixes are the CHINESE copy, deliberately hardcoded here:
161
+ * this module is background code with no access to the UI language (the
162
+ * English forms live in src/client/locales.ts, `remoteGroup*` keys).
163
+ */
164
+ export type MergerAnnotation = 'none' | 'offline' | 'revoked' | 'unpaired' | 'mismatch'
165
+
166
+ const ANNOTATION_SUFFIX: Record<Exclude<MergerAnnotation, 'none'>, string> = {
167
+ offline: '(离线)',
168
+ revoked: '(令牌已吊销)',
169
+ unpaired: '(已解除配对)',
170
+ mismatch: '(版本有差异)',
171
+ }
172
+
173
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
174
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
175
+ }
176
+
177
+ /** The string prefix of a wire list — a malformed frame degrades to []. */
178
+ function stringList(value: unknown): string[] {
179
+ if (!Array.isArray(value)) return []
180
+ return value.filter((item): item is string => typeof item === 'string')
181
+ }
182
+
183
+ /** The local workspace-id order of a baseline `value.items` / order frame. */
184
+ function idList(value: unknown): string[] {
185
+ if (!Array.isArray(value)) return []
186
+ const ids: string[] = []
187
+ for (const item of value) {
188
+ if (isPlainObject(item) && typeof item.workspaceId === 'string') ids.push(item.workspaceId)
189
+ else if (typeof item === 'string') ids.push(item)
190
+ }
191
+ return ids
192
+ }
193
+
194
+ /** One workspace record rewritten for the UI: ids virtualized, the title
195
+ * prefixed with the server name and — while an annotation is set — suffixed
196
+ * with its status copy. Values are copied — the caller's frame is never
197
+ * mutated. */
198
+ function virtualizeWorkspace(
199
+ record: Record<string, unknown>,
200
+ identity: MergerIdentity,
201
+ annotation: MergerAnnotation,
202
+ ): Record<string, unknown> {
203
+ const virtualize = (id: string): string => toVirtual(identity.serverId, id)
204
+ const out: Record<string, unknown> = { ...record }
205
+ if (typeof record.workspaceId === 'string') out.workspaceId = virtualize(record.workspaceId)
206
+ if (typeof record.title === 'string') {
207
+ const suffix = annotation === 'none' ? '' : ANNOTATION_SUFFIX[annotation]
208
+ out.title = `${identity.serverName} · ${record.title}${suffix}`
209
+ }
210
+ if (Array.isArray(record.sessionIds)) {
211
+ out.sessionIds = record.sessionIds.map((id) => (typeof id === 'string' ? virtualize(id) : id))
212
+ }
213
+ return out
214
+ }
215
+
216
+ export interface WorkspaceMergerOptions extends MergerIdentity {
217
+ /** Diagnostics for frames this merger dropped (unknown type or malformed).
218
+ * Optional: without it the drop is silent. */
219
+ onDiagnostic?: (message: string) => void
220
+ /** T65: the clock the tombstone hide-grace runs on. Default Date.now;
221
+ * tests advance an injectable fake to cross the grace. */
222
+ now?: () => number
223
+ }
224
+
225
+ export interface WorkspaceMerger {
226
+ readonly serverId: string
227
+ readonly serverName: string
228
+ /** Feed one frame of the LOCAL workspace/follow stream; returns the frames
229
+ * the UI should see (usually exactly the input, order-merged when remote
230
+ * state exists). */
231
+ onLocal(frame: unknown): unknown[]
232
+ /** Feed one server-filtered frame of the REMOTE stream; returns the frames
233
+ * the UI should see (possibly none — state-only while the local baseline
234
+ * has not passed). A later baseline is DIFFED against what was shown. */
235
+ onRemote(frame: unknown): unknown[]
236
+ /** The remote stream died or the relay went offline (the same server is
237
+ * expected back): emits NOTHING and keeps the shown state — the UI keeps
238
+ * the last projection visible across carrier loss, and a `remove` here
239
+ * would blacklist the virtual ids against revival. */
240
+ onRemoteDown(): unknown[]
241
+ /** The remote side is gone FOR GOOD under THIS identity: a serverId
242
+ * change. (Since T23b2-fix3 an `unpaired` / `revoked` relay is NOT this
243
+ * case — the same serverId comes back on re-pair, so the caller uses
244
+ * {@link onRemoteDown} there.) Removes everything shown from the remote
245
+ * side, resets the remote state. Idempotent — a second call with no
246
+ * remote state returns []. */
247
+ onRemoteGone(): unknown[]
248
+ /** The server was renamed (same serverId — call after {@link retarget}):
249
+ * re-upserts every shown workspace under the new title, nothing else. */
250
+ onServerRenamed(): unknown[]
251
+ /** Record the status annotation (T34) future virtualized titles carry. No
252
+ * frames on its own — pair it with {@link onStatusChanged} to re-upsert the
253
+ * shown groups under the new annotation, or let the next natural upserts
254
+ * (a reconnecting baseline) carry it. */
255
+ setStatus(annotation: MergerAnnotation): void
256
+ /** Re-upsert every SHOWN workspace under the CURRENT annotation: the whole
257
+ * update when the relay's serving status changed without any remote frame
258
+ * (offline, revoked, unpaired, version mismatch). [] while nothing is
259
+ * shown (no local baseline yet, or no remote state). */
260
+ onStatusChanged(): unknown[]
261
+ /** Record the session id the UI currently has OPEN (T58) — the raw id as
262
+ * the UI knows it (a virtual id for a remote session, a local id
263
+ * otherwise); `undefined` means a READABLE signal says no session is
264
+ * open. An UNREADABLE signal is expressed by not calling this at all —
265
+ * the merger then keeps every tombstone (the conservative fallback).
266
+ * Returns the frames that apply the resulting hide transitions ([] when
267
+ * nothing changed). */
268
+ setCurrentSession(sessionId: string | undefined): unknown[]
269
+ /** Whether some closed-remote session is being kept visible — it is the
270
+ * current session (waiting for the user to navigate away), or the current
271
+ * session was never readable (conservative fallback). While true the
272
+ * caller should keep polling {@link setCurrentSession} at ≥1s intervals. */
273
+ readonly hasPendingHide: boolean
274
+ /** Point the merger at a (possibly different) server. Local state survives;
275
+ * for a serverId change the remote state must be gone first (onRemoteGone
276
+ * first); for a rename it may stay. */
277
+ retarget(identity: MergerIdentity): void
278
+ }
279
+
280
+ /**
281
+ * The workspace/follow merger. Remote state is a Map keyed by the ORIGINAL
282
+ * workspace id whose INSERTION ORDER tracks the remote order (baseline sets
283
+ * it, upsert appends new keys, order frames reorder it) — that sequence is
284
+ * what gets appended to every local baseline/order that passes through.
285
+ */
286
+ export function createWorkspaceMerger(options: WorkspaceMergerOptions): WorkspaceMerger {
287
+ const onDiagnostic = options.onDiagnostic
288
+ let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName }
289
+
290
+ // The locally observed truth, updated from every local baseline / order /
291
+ // archived / pinned frame. Required to rebuild remote-free frames on
292
+ // onRemoteGone and to merge remote ids INTO local order frames.
293
+ let localSeen = false
294
+ let localOrder: string[] = []
295
+ let localArchived: string[] = []
296
+ let localPinned: string[] = []
297
+
298
+ // The remote truth in ORIGINAL ids; insertion order = remote order. A
299
+ // reconnecting baseline diffs its content against this; what the UI has
300
+ // ever been SHOWN is tracked beside it (the `shown` set, T56).
301
+ let remote = new Map<string, Record<string, unknown>>()
302
+ let remoteArchived: string[] = []
303
+ let remotePinned: string[] = []
304
+ // The tombstone registry (CP4-client-fix2): every session id any remote
305
+ // workspace has ever carried for this identity (original ids) → the
306
+ // original workspace id that LAST carried it. A session the server stopped
307
+ // serving (remote closed → the filtered workspace upsert drops it from
308
+ // `sessionIds`) keeps its UI entry, so the forwarded record of its last
309
+ // workspace still lists it. Entries die when the session is carried again
310
+ // (re-shared, anywhere — the home moves with it), when its workspace is
311
+ // removed, and with the identity (onRemoteGone).
312
+ let sessionHome = new Map<string, string>()
313
+ // T56: the workspace ids (originals) the UI has EVER been shown. The UI's
314
+ // ClientWorkspaceModel blacklists removed ids for the page's whole life, so
315
+ // only a shown workspace may ever receive a `remove` — and a workspace the
316
+ // UI never saw stays invisible while its forwarded sessionIds (live
317
+ // sessions + tombstones) is empty, instead of rendering as an empty
318
+ // 「服务端名 · 工作区名」 group. Once shown, a workspace stays shown even
319
+ // when its last session leaves (no remove — the blacklist would make the
320
+ // id un-revivable); the page reload rebuilds the model and the emptied
321
+ // group then never comes back.
322
+ const shown = new Set<string>()
323
+ // T58: the raw session id the UI currently has open, exactly as read from
324
+ // the host's persisted selection store — a virtual id for a remote
325
+ // session, a local id otherwise. The CALLER distinguishes an UNREADABLE
326
+ // signal (no usable storage) from a readable "no session is open" by not
327
+ // calling {@link setCurrentSession} at all in the former case — so
328
+ // `currentReadable` is what gates the conservative fallback, and
329
+ // `currentRaw === undefined` once readable means a real "nothing open".
330
+ // Devirtualized on use, so a rename needs no re-write and a foreign/local
331
+ // id naturally means "no remote session of ours is current".
332
+ let currentRaw: string | undefined = undefined
333
+ let currentReadable = false
334
+ // T58: the original session ids this merger moved into the merged
335
+ // `archived` set because their remote closed while they were not the
336
+ // current session. STICKY: entries survive even the death of their home
337
+ // workspace (a hidden session whose group is deleted must not resurface
338
+ // as a 「未分组」 stray) until the session is live-carried again (re-share)
339
+ // or the identity ends (onRemoteGone). Never contains the current
340
+ // session — the host's clearArchivedCurrent would kick its open page.
341
+ const hiddenSessions = new Set<string>()
342
+ // T65: when each tombstone was CREATED (the moment a close frame dropped a
343
+ // live id), for the hide grace below. Entries die with the tombstone: a
344
+ // re-carried id leaves, a forgotten workspace takes its ids, the identity
345
+ // end clears the rest.
346
+ const closedAt = new Map<string, number>()
347
+ // The status annotation every virtualized title currently carries (T34).
348
+ let annotation: MergerAnnotation = 'none'
349
+ const now = options.now ?? Date.now
350
+
351
+ const virtualize = (id: string): string => toVirtual(identity.serverId, id)
352
+ // Only SHOWN workspaces travel in order frames (T56): a hidden group has no
353
+ // position the UI knows about.
354
+ const remoteIds = (): string[] => [...remote.keys()].filter((id) => shown.has(id)).map(virtualize)
355
+ const remoteArchivedVirtual = (): string[] => remoteArchived.map(virtualize)
356
+ const remotePinnedVirtual = (): string[] => remotePinned.map(virtualize)
357
+
358
+ /** Learn the session ids one remote workspace record carries: each id is
359
+ * remembered under THIS workspace as its last home (originals, cumulative). */
360
+ function learnSessions(record: Record<string, unknown>, workspaceId: string): void {
361
+ if (!Array.isArray(record.sessionIds)) return
362
+ for (const id of record.sessionIds) if (typeof id === 'string') sessionHome.set(id, workspaceId)
363
+ }
364
+
365
+ /** Drop the ids whose last home was this workspace — tombstones die with
366
+ * their group (a server remove / a workspace diffed away; a session that
367
+ * moved on lives under its NEW home and survives this). */
368
+ function forgetWorkspace(workspaceId: string): void {
369
+ for (const [id, home] of sessionHome) {
370
+ if (home === workspaceId) {
371
+ sessionHome.delete(id)
372
+ closedAt.delete(id)
373
+ }
374
+ }
375
+ }
376
+
377
+ /** T65: stamp the ids that JUST left a workspace's live list (their
378
+ * tombstones were created by this frame) and clear the ones that came
379
+ * back. The grace window is judged from these stamps. */
380
+ function noteClosedTransitions(prevRecord: Record<string, unknown> | undefined, nextRecord: Record<string, unknown>): void {
381
+ const prevLive = prevRecord === undefined ? [] : stringList(prevRecord.sessionIds)
382
+ const nextLive = stringList(nextRecord.sessionIds)
383
+ const stamp = now()
384
+ for (const id of prevLive) {
385
+ if (!nextLive.includes(id)) closedAt.set(id, stamp)
386
+ }
387
+ for (const id of nextLive) {
388
+ if (closedAt.has(id)) closedAt.delete(id)
389
+ }
390
+ }
391
+
392
+ /** The CURRENT session as an ORIGINAL id of THIS server — undefined when
393
+ * no readable current is known, it is a local session, or it belongs to
394
+ * another server. All three mean "no remote session of ours is open". */
395
+ function currentOriginal(): string | undefined {
396
+ if (currentRaw === undefined) return undefined
397
+ const parts = fromVirtual(currentRaw)
398
+ return parts !== undefined && parts.serverId === identity.serverId ? parts.id : undefined
399
+ }
400
+
401
+ /** The hide grace (T65): a freshly closed session is not hidden for this
402
+ * long, because the browser's current-session report can lag the user's
403
+ * switch by up to a poll interval — a close frame naming the session the
404
+ * user JUST opened would read the stale value and archive the open page
405
+ * (the host's clearArchivedCurrent kicks it home). The 1s poll re-judges
406
+ * with the fresh report once the grace passes. */
407
+ const TOMBSTONE_HIDE_GRACE_MS = 2_000
408
+
409
+ /** T58: whether the tombstone of `id` is HIDDEN (moved into the merged
410
+ * archived set) rather than kept visible in its group. Only an actually
411
+ * readable current-session signal hides anything: before the first
412
+ * {@link WorkspaceMerger.setCurrentSession} call, every tombstone stays
413
+ * (the conservative fallback) — and a JUST-created tombstone stays for
414
+ * the grace regardless (T65). */
415
+ function hideTombstone(id: string): boolean {
416
+ if (!currentReadable) return false
417
+ const closed = closedAt.get(id)
418
+ if (closed !== undefined && now() - closed < TOMBSTONE_HIDE_GRACE_MS) return false
419
+ return currentOriginal() !== id
420
+ }
421
+
422
+ /** T58: re-derive {@link hiddenSessions} from the live tombstone set —
423
+ * tombstones hide unless they ARE the current session (or no readable
424
+ * current is known, or the tombstone is inside the T65 hide grace);
425
+ * live-carried ids and ids that (re-)became the current session leave the
426
+ * set. Since T65 the hide/reveal transitions move ONLY the archived set —
427
+ * the session stays in its group's forwarded copy either way — so the
428
+ * caller emits just the merged archived frame when anything changed. */
429
+ function syncHiddenSessions(): { hidden: string[]; revealed: string[] } {
430
+ const hidden: string[] = []
431
+ const revealed: string[] = []
432
+ for (const [id, home] of sessionHome) {
433
+ const record = remote.get(home)
434
+ if (record === undefined) continue
435
+ if (stringList(record.sessionIds).includes(id)) {
436
+ if (hiddenSessions.delete(id)) revealed.push(id)
437
+ continue
438
+ }
439
+ if (hideTombstone(id)) {
440
+ if (!hiddenSessions.has(id)) {
441
+ hiddenSessions.add(id)
442
+ hidden.push(id)
443
+ }
444
+ } else if (hiddenSessions.delete(id)) {
445
+ revealed.push(id)
446
+ }
447
+ }
448
+ return { hidden, revealed }
449
+ }
450
+
451
+ /** One workspace record as the UI should see it: virtualized, PLUS the
452
+ * tombstones — ids whose last home is this workspace but that the server's
453
+ * record no longer carries — appended at the end of `sessionIds`
454
+ * (CP4-client-fix2). Liveness is judged against the record's ORIGINAL
455
+ * sessionIds (the registry stores originals; the forwarded copy is
456
+ * virtualized). Since T65 a HIDDEN tombstone rides the group too: the
457
+ * session stays in its group so the host's「显示已归档」filter can show it
458
+ * in place — hiding is purely the archived set. */
459
+ function forwardWorkspace(record: Record<string, unknown>): Record<string, unknown> {
460
+ const out = virtualizeWorkspace(record, identity, annotation)
461
+ const workspaceId = typeof record.workspaceId === 'string' ? record.workspaceId : undefined
462
+ const live = stringList(record.sessionIds)
463
+ if (workspaceId === undefined || !Array.isArray(out.sessionIds)) return out
464
+ for (const [id, home] of sessionHome) {
465
+ if (home === workspaceId && !live.includes(id)) out.sessionIds.push(virtualize(id))
466
+ }
467
+ return out
468
+ }
469
+ /** T56: the forwarded record when the UI may see this workspace, else
470
+ * undefined — a never-shown workspace is forwarded only once its forwarded
471
+ * sessionIds first carries VISIBLE content, and forwarding it marks it
472
+ * shown for good. Only call on paths that actually emit (after the local
473
+ * baseline has passed): a mark here is a claim that the UI now holds the
474
+ * group. Visible means NOT hidden (T65): the host draws a group heading
475
+ * even when every member is archived-invisible under the default filter,
476
+ * so a group whose members are all hidden must not be first-shown —
477
+ * the forwarded copy itself still carries the hidden ids. */
478
+ function forwardVisible(record: Record<string, unknown>): Record<string, unknown> | undefined {
479
+ const id = typeof record.workspaceId === 'string' ? record.workspaceId : undefined
480
+ if (id !== undefined && shown.has(id)) return forwardWorkspace(record)
481
+ const out = forwardWorkspace(record)
482
+ const ids = Array.isArray(out.sessionIds) ? out.sessionIds : []
483
+ let visible = false
484
+ for (const virtualId of ids) {
485
+ const parts = fromVirtual(virtualId)
486
+ if (parts !== undefined && parts.serverId === identity.serverId && hiddenSessions.has(parts.id)) continue
487
+ visible = true
488
+ break
489
+ }
490
+ if (!visible) return undefined
491
+ if (id !== undefined) shown.add(id)
492
+ return out
493
+ }
494
+ const virtualWorkspaces = (): Record<string, unknown>[] => {
495
+ const out: Record<string, unknown>[] = []
496
+ for (const record of remote.values()) {
497
+ const forwarded = forwardVisible(record)
498
+ if (forwarded !== undefined) out.push(forwarded)
499
+ }
500
+ return out
501
+ }
502
+ const upsertFrames = (): unknown[] => virtualWorkspaces().map((workspace) => ({ type: 'upsert', workspace }))
503
+ const mergedOrderFrame = (): unknown => ({ type: 'order', workspaceIds: [...localOrder, ...remoteIds()] })
504
+ /** The merged archived set: local, then remote-archived, then the T58
505
+ * hidden closed-remote sessions, deduplicated in that order. Closed
506
+ * sessions that are NOT hidden keep their group slot as tombstones
507
+ * (CP4-client-fix2), and the RT navigation guard keys on exactly this
508
+ * list — which is why the CURRENT session never enters it. */
509
+ function mergedArchivedIds(): string[] {
510
+ const seen = new Set<string>()
511
+ const out: string[] = []
512
+ const add = (ids: string[]): void => {
513
+ for (const id of ids) {
514
+ if (seen.has(id)) continue
515
+ seen.add(id)
516
+ out.push(id)
517
+ }
518
+ }
519
+ add(localArchived)
520
+ add(remoteArchivedVirtual())
521
+ add([...hiddenSessions].map(virtualize))
522
+ return out
523
+ }
524
+ const mergedArchivedFrame = (): unknown => ({ type: 'archived', archivedSessionIds: mergedArchivedIds() })
525
+ const mergedPinnedFrame = (): unknown => ({
526
+ type: 'pinned',
527
+ pinnedSessionIds: [...localPinned, ...remotePinnedVirtual()],
528
+ })
529
+
530
+ /** Learn from a local frame without emitting anything. */
531
+ function observeLocal(frame: Record<string, unknown>): void {
532
+ if (frame.type === 'baseline') {
533
+ const value = isPlainObject(frame.value) ? frame.value : {}
534
+ localOrder = idList(value.items)
535
+ localArchived = stringList(value.archivedSessionIds)
536
+ localPinned = stringList(value.pinnedSessionIds)
537
+ localSeen = true
538
+ return
539
+ }
540
+ if (frame.type === 'order') {
541
+ localOrder = stringList(frame.workspaceIds)
542
+ return
543
+ }
544
+ if (frame.type === 'archived') {
545
+ localArchived = stringList(frame.archivedSessionIds)
546
+ return
547
+ }
548
+ if (frame.type === 'pinned') {
549
+ localPinned = stringList(frame.pinnedSessionIds)
550
+ }
551
+ }
552
+
553
+ function drop(message: string): void {
554
+ onDiagnostic?.(message)
555
+ }
556
+
557
+ return {
558
+ get serverId(): string {
559
+ return identity.serverId
560
+ },
561
+ get serverName(): string {
562
+ return identity.serverName
563
+ },
564
+
565
+ retarget(next: MergerIdentity): void {
566
+ identity = { serverId: next.serverId, serverName: next.serverName }
567
+ },
568
+
569
+ onLocal(frame: unknown): unknown[] {
570
+ if (!isPlainObject(frame)) return [frame]
571
+ observeLocal(frame)
572
+ // The local order carries the remote groups at its tail ONLY once
573
+ // remote state exists — before that every local frame is verbatim.
574
+ if (frame.type === 'baseline') {
575
+ // T58: the cached tombstones become decidable at the flush — sync
576
+ // BEFORE building, so the baseline's items (hidden tombstones
577
+ // dropped) and its archived list (hidden ids added) agree atomically
578
+ // in the one frame.
579
+ syncHiddenSessions()
580
+ const shownWorkspaces = virtualWorkspaces()
581
+ // Nothing VISIBLE (T56 — a remote full of hidden empty groups counts
582
+ // as nothing) and no lists to merge: the frame passes verbatim.
583
+ if (
584
+ shownWorkspaces.length === 0 &&
585
+ remoteArchived.length === 0 &&
586
+ remotePinned.length === 0 &&
587
+ sessionHome.size === 0 &&
588
+ hiddenSessions.size === 0
589
+ ) {
590
+ return [frame]
591
+ }
592
+ const value = isPlainObject(frame.value) ? frame.value : {}
593
+ const items = Array.isArray(value.items) ? value.items : []
594
+ // The baseline's own order already shows the remote groups; the
595
+ // upserts that follow restate their content (the cached-remote path
596
+ // never emits a separate order frame).
597
+ return [
598
+ {
599
+ ...frame,
600
+ value: {
601
+ ...value,
602
+ items: [...items, ...shownWorkspaces],
603
+ archivedSessionIds: mergedArchivedIds(),
604
+ pinnedSessionIds: [...localPinned, ...remotePinnedVirtual()],
605
+ },
606
+ },
607
+ ...upsertFrames(),
608
+ ]
609
+ }
610
+ if (frame.type === 'order') {
611
+ if (remote.size === 0) return [frame]
612
+ return [mergedOrderFrame()]
613
+ }
614
+ if (frame.type === 'archived') {
615
+ // T58-fix: with hidden sessions around, the local list must travel
616
+ // merged — verbatim it would WIPE the hidden ids from the UI's
617
+ // archived set and flush them back as 「未分组」 strays.
618
+ if (remoteArchived.length === 0 && hiddenSessions.size === 0) return [frame]
619
+ return [mergedArchivedFrame()]
620
+ }
621
+ if (frame.type === 'pinned') {
622
+ if (remotePinned.length === 0) return [frame]
623
+ return [mergedPinnedFrame()]
624
+ }
625
+ // upsert / remove / anything else local: purely local facts.
626
+ return [frame]
627
+ },
628
+
629
+ onRemote(frame: unknown): unknown[] {
630
+ if (!isPlainObject(frame)) {
631
+ drop('dropped a non-object workspace/follow frame from the relay')
632
+ return []
633
+ }
634
+ switch (frame.type) {
635
+ case 'baseline': {
636
+ const value = isPlainObject(frame.value) ? frame.value : undefined
637
+ // Build the new remote truth beside the shown one, then DIFF
638
+ // (T23b2-fix): a reconnecting baseline must not blindly re-add —
639
+ // the upserts refresh content, and the only removes it synthesizes
640
+ // are workspaces the server dropped (deleted / no longer shared).
641
+ const next = new Map<string, Record<string, unknown>>()
642
+ if (value !== undefined && Array.isArray(value.items)) {
643
+ for (const item of value.items) {
644
+ if (isPlainObject(item) && typeof item.workspaceId === 'string') next.set(item.workspaceId, { ...item })
645
+ }
646
+ }
647
+ const nextArchived = value === undefined ? [] : stringList(value.archivedSessionIds)
648
+ const nextPinned = value === undefined ? [] : stringList(value.pinnedSessionIds)
649
+ // The session inventory is cumulative — a baseline only ever adds
650
+ // known session ids (each under the workspace now carrying it).
651
+ for (const [id, record] of next) learnSessions(record, id)
652
+ // Never a second baseline: the UI has either seen local data (which
653
+ // it must keep) or nothing (cache only).
654
+ if (!localSeen) {
655
+ remote = next
656
+ remoteArchived = nextArchived
657
+ remotePinned = nextPinned
658
+ return []
659
+ }
660
+ const out: unknown[] = []
661
+ // The remote truth moves FIRST (the sync and every frame below are
662
+ // built against the NEW records; close stamps start the grace per
663
+ // dropped id — T65), then the diffed upserts / removes and the
664
+ // canonical order / archived / pinned. The trailing archived frame
665
+ // covers any hide/reveal the new records caused: since T65 hiding
666
+ // moves only the archived set, nothing can flash in between.
667
+ const prev = remote
668
+ remote = next
669
+ remoteArchived = nextArchived
670
+ remotePinned = nextPinned
671
+ for (const [id, record] of next) noteClosedTransitions(prev.get(id), record)
672
+ syncHiddenSessions()
673
+ for (const [, record] of next) {
674
+ const forwarded = forwardVisible(record)
675
+ if (forwarded !== undefined) out.push({ type: 'upsert', workspace: forwarded })
676
+ }
677
+ for (const id of prev.keys()) {
678
+ if (next.has(id)) continue
679
+ forgetWorkspace(id)
680
+ // Only a workspace the UI was SHOWN may receive a remove (T56) —
681
+ // the model blacklists the id forever; a never-shown one is
682
+ // unknown to the UI and leaves silently.
683
+ if (shown.has(id)) out.push({ type: 'remove', workspaceId: virtualize(id) })
684
+ }
685
+ out.push(mergedOrderFrame(), mergedArchivedFrame(), mergedPinnedFrame())
686
+ return out
687
+ }
688
+ case 'upsert': {
689
+ if (!isPlainObject(frame.workspace) || typeof frame.workspace.workspaceId !== 'string') {
690
+ drop('dropped a workspace upsert without a workspaceId')
691
+ return []
692
+ }
693
+ const workspace: Record<string, unknown> = frame.workspace
694
+ const id = workspace.workspaceId as string
695
+ // T65: stamp the close first — the ids the OLD record carried live
696
+ // and this one drops just became tombstones (the grace starts now).
697
+ const prevRecord = remote.get(id)
698
+ remote.set(id, { ...workspace })
699
+ learnSessions(workspace, id)
700
+ noteClosedTransitions(prevRecord, workspace)
701
+ if (!localSeen) return []
702
+ // T65: hide/reveal moves only the archived set (the session keeps
703
+ // its group slot), so the group's upsert and the archived frame no
704
+ // longer race — one trailing archived frame after the group's own
705
+ // update covers both directions.
706
+ const changed = syncHiddenSessions()
707
+ // The tombstone-padded upsert is the whole update; a workspace the
708
+ // UI has not seen (new, or hidden until now — T56) additionally
709
+ // needs a position.
710
+ const wasShown = shown.has(id)
711
+ const forwarded = forwardVisible(workspace)
712
+ const out: unknown[] = []
713
+ if (forwarded !== undefined) {
714
+ out.push({ type: 'upsert', workspace: forwarded })
715
+ if (!wasShown) out.push(mergedOrderFrame())
716
+ }
717
+ if (changed.hidden.length > 0 || changed.revealed.length > 0) out.push(mergedArchivedFrame())
718
+ return out
719
+ }
720
+ case 'remove': {
721
+ const id = typeof frame.workspaceId === 'string' ? frame.workspaceId : undefined
722
+ if (id === undefined) {
723
+ drop('dropped a workspace remove without a workspaceId')
724
+ return []
725
+ }
726
+ // A remove of a workspace this merger never knew is a no-op: the UI
727
+ // was never shown it.
728
+ const removed = remote.get(id)
729
+ if (removed === undefined) return []
730
+ remote.delete(id)
731
+ // The group's tombstones die with it (CP4-client-fix2).
732
+ forgetWorkspace(id)
733
+ if (!localSeen) return []
734
+ // T56: a workspace the UI was never shown must not receive a
735
+ // remove — the model would blacklist the id against any future
736
+ // re-share of the same workspace id.
737
+ if (!shown.has(id)) return []
738
+ return [{ type: 'remove', workspaceId: virtualize(id) }, mergedOrderFrame()]
739
+ }
740
+ case 'order': {
741
+ if (!Array.isArray(frame.workspaceIds)) {
742
+ drop('dropped a workspace order frame without a workspaceIds array')
743
+ return []
744
+ }
745
+ // Same reorder rule as the server's own state (relay-filter.ts):
746
+ // mentioned ids first in frame order, unmentioned ones keep their
747
+ // relative order behind them — a partial reorder drops nothing.
748
+ const reordered = new Map<string, Record<string, unknown>>()
749
+ for (const id of frame.workspaceIds) {
750
+ if (typeof id !== 'string') continue
751
+ const workspace = remote.get(id)
752
+ if (workspace !== undefined) reordered.set(id, workspace)
753
+ }
754
+ for (const [id, workspace] of remote) {
755
+ if (!reordered.has(id)) reordered.set(id, workspace)
756
+ }
757
+ remote.clear()
758
+ for (const [id, workspace] of reordered) remote.set(id, workspace)
759
+ if (!localSeen) return []
760
+ return [mergedOrderFrame()]
761
+ }
762
+ case 'archived':
763
+ remoteArchived = stringList(frame.archivedSessionIds)
764
+ if (!localSeen) return []
765
+ return [mergedArchivedFrame()]
766
+ case 'pinned':
767
+ remotePinned = stringList(frame.pinnedSessionIds)
768
+ if (!localSeen) return []
769
+ return [mergedPinnedFrame()]
770
+ default:
771
+ drop(`dropped a workspace/follow frame of unknown type ${String(frame.type)}`)
772
+ return []
773
+ }
774
+ },
775
+
776
+ onRemoteDown(): unknown[] {
777
+ // Deliberately nothing: the shown state STAYS (the UI keeps the last
778
+ // projection visible while the carrier is gone), so the reconnecting
779
+ // baseline can diff against it instead of re-adding through the UI's
780
+ // remove-blacklist. Pre-baseline cached state stays cached.
781
+ return []
782
+ },
783
+
784
+ onServerRenamed(): unknown[] {
785
+ // The caller has already retarget()ed to the new name; re-upserting the
786
+ // shown workspaces under the CURRENT identity is the whole update.
787
+ if (!localSeen || remote.size === 0) return []
788
+ return upsertFrames()
789
+ },
790
+
791
+ setStatus(next: MergerAnnotation): void {
792
+ annotation = next
793
+ },
794
+
795
+ onStatusChanged(): unknown[] {
796
+ if (!localSeen || remote.size === 0) return []
797
+ return upsertFrames()
798
+ },
799
+
800
+ setCurrentSession(sessionId: string | undefined): unknown[] {
801
+ currentRaw = sessionId
802
+ currentReadable = true
803
+ if (!localSeen) return []
804
+ // T65: hide/reveal moves ONLY the archived set — the session keeps its
805
+ // group slot either way, so the whole update is the merged archived
806
+ // frame (no group upserts, no ordering dance; the current session can
807
+ // never enter the set, and a re-share removes its id from it).
808
+ const changed = syncHiddenSessions()
809
+ if (changed.hidden.length === 0 && changed.revealed.length === 0) return []
810
+ return [mergedArchivedFrame()]
811
+ },
812
+
813
+ get hasPendingHide(): boolean {
814
+ // Some tombstone is being kept visible: it is not in the hidden set
815
+ // (either it IS the current session, or no readable current made any
816
+ // decision yet) — the caller keeps polling until it hides.
817
+ for (const [id, home] of sessionHome) {
818
+ if (hiddenSessions.has(id)) continue
819
+ const record = remote.get(home)
820
+ if (record !== undefined && !stringList(record.sessionIds).includes(id)) return true
821
+ }
822
+ return false
823
+ },
824
+
825
+ onRemoteGone(): unknown[] {
826
+ const out: unknown[] = []
827
+ // The UI only ever SAW remote data when the local baseline had passed —
828
+ // cached pre-baseline state is discarded silently instead. And only
829
+ // SHOWN workspaces may leave via a remove (T56): a never-shown one is
830
+ // unknown to the UI and just leaves the state. The archived/pinned
831
+ // restore still rides along whenever remote sessions reached those
832
+ // lists (session lists merge independently of workspace visibility).
833
+ const removedIds = [...remote.keys()].filter((id) => shown.has(id))
834
+ // T58-fix: hidden sessions alone also demand the restore — their ids
835
+ // sit in the UI's archived set and must leave it with the identity.
836
+ if (localSeen && (removedIds.length > 0 || remoteArchived.length > 0 || remotePinned.length > 0 || hiddenSessions.size > 0)) {
837
+ for (const id of removedIds) out.push({ type: 'remove', workspaceId: virtualize(id) })
838
+ out.push({ type: 'order', workspaceIds: [...localOrder] })
839
+ out.push({ type: 'archived', archivedSessionIds: [...localArchived] })
840
+ out.push({ type: 'pinned', pinnedSessionIds: [...localPinned] })
841
+ }
842
+ remote.clear()
843
+ remoteArchived = []
844
+ remotePinned = []
845
+ // The tombstone registry dies with the identity (a new server mints new
846
+ // ids); the archived frame above already restored the local-only set.
847
+ sessionHome = new Map()
848
+ // So do the T65 close stamps, the T58 hidden sessions and the
849
+ // current-session knowledge (a NEW server may reuse original
850
+ // workspace/session ids, and a stale entry would forward or mis-hide
851
+ // the new server's sessions).
852
+ closedAt.clear()
853
+ hiddenSessions.clear()
854
+ currentRaw = undefined
855
+ currentReadable = false
856
+ // So does the shown set: a NEW server may reuse original workspace ids,
857
+ // and a stale entry would forward the new server's empty groups.
858
+ shown.clear()
859
+ return out
860
+ },
861
+ }
862
+ }
863
+
864
+ export interface ControlMergerOptions {
865
+ serverId: string
866
+ serverName?: string
867
+ onDiagnostic?: (message: string) => void
868
+ }
869
+
870
+ export interface ControlMerger {
871
+ readonly serverId: string
872
+ readonly serverName: string
873
+ /** Local control frames pass through untouched; the local BASELINE also
874
+ * flushes anything the remote side buffered before it arrived. */
875
+ onLocal(frame: unknown): unknown[]
876
+ /** Remote baseline → per-session per-key `projection` frames; remote
877
+ * projection updates → virtualized. Unknown types are dropped. Frames
878
+ * arriving before the local baseline are buffered (the host's snapshot
879
+ * stream treats an update before the opening snapshot as a protocol
880
+ * violation and kills the stream). */
881
+ onRemote(frame: unknown): unknown[]
882
+ /** Always [] — a control stream has nothing to remove on a temporary
883
+ * remote end (the workspace merger owns the group). */
884
+ onRemoteDown(): unknown[]
885
+ /** Always [] — leftover projection state for vanished virtual sessions is
886
+ * harmless; the workspace merger removes the group. */
887
+ onRemoteGone(): unknown[]
888
+ /** Always [] — projection frames carry no display name. */
889
+ onServerRenamed(): unknown[]
890
+ /** Accepted and ignored: a status annotation is a TITLE concern, and
891
+ * control projections carry no titles. */
892
+ setStatus(annotation: MergerAnnotation): void
893
+ /** Always [] — nothing shown here could carry an annotation. */
894
+ onStatusChanged(): unknown[]
895
+ /** Accepted and ignored; always answers []. */
896
+ setCurrentSession(sessionId: string | undefined): unknown[]
897
+ /** Always false — the control merger hides nothing. */
898
+ readonly hasPendingHide: boolean
899
+ retarget(identity: MergerIdentity): void
900
+ }
901
+
902
+ /**
903
+ * The session/control merger. The control stream is key-value projection
904
+ * traffic with no ordering constraints between sessions, so it needs no
905
+ * state beyond the pre-baseline buffer: local frames pass through, remote
906
+ * frames get their session id virtualized, and a remote baseline is EXPLODED
907
+ * into one `projection` frame per key (never a second baseline).
908
+ */
909
+ export function createControlMerger(options: ControlMergerOptions): ControlMerger {
910
+ const onDiagnostic = options.onDiagnostic
911
+ let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName ?? '' }
912
+ const virtualize = (id: string): string => toVirtual(identity.serverId, id)
913
+ // Remote output converted but not yet shown: the UI's snapshot stream
914
+ // throws `emitted an update before its opening snapshot` — a protocol
915
+ // violation that permanently fails the stream — so nothing may leave
916
+ // before the local baseline has passed.
917
+ let localSeen = false
918
+ let buffered: unknown[] = []
919
+
920
+ /** Convert one remote control frame into the projection frames the UI
921
+ * understands (empty for anything malformed). */
922
+ function convert(frame: Record<string, unknown>): unknown[] {
923
+ if (frame.type === 'baseline') {
924
+ const value = isPlainObject(frame.value) ? frame.value : undefined
925
+ const projections = value !== undefined && isPlainObject(value.projections) ? value.projections : {}
926
+ const out: unknown[] = []
927
+ for (const [sessionId, projection] of Object.entries(projections)) {
928
+ if (!isPlainObject(projection)) continue
929
+ const values = isPlainObject(projection.values) ? projection.values : {}
930
+ const asOfSeq = projection.asOfSeq
931
+ // A zero-event session's asOfSeq is -1, and the UI's SessionSeq
932
+ // THROWS on a negative seq — one such frame would permanently stop
933
+ // the whole control stream. Skip the record (one diagnostic) instead
934
+ // of clamping: the UI compares `seq <= held → drop`, so a clamped 0
935
+ // would swallow the real seq-0 entry when it arrives.
936
+ if (typeof asOfSeq !== 'number' || !Number.isSafeInteger(asOfSeq) || asOfSeq < 0) {
937
+ onDiagnostic?.(
938
+ `skipped control projections for ${sessionId}: asOfSeq ${String(asOfSeq)} is not a usable sequence number`,
939
+ )
940
+ continue
941
+ }
942
+ for (const key of Object.keys(values)) {
943
+ // The baseline gives one sequence number per projection record
944
+ // (`asOfSeq`) — every key of that record rides it. A modelSelection
945
+ // value additionally gets its provider ids virtualized (T52) so the
946
+ // UI matches it against the merged catalog's virtual groups —
947
+ // UNCONDITIONALLY (T52-fix3): the host's projection store is
948
+ // first-write-wins per seq, so a catalog-gated original value would
949
+ // occupy the seq and the later virtual rewrite could never land.
950
+ out.push({
951
+ type: 'projection',
952
+ sessionId: virtualize(sessionId),
953
+ key,
954
+ value: key === 'modelSelection' ? virtualizeModelSelectionValue(values[key], identity.serverId) : values[key],
955
+ seq: asOfSeq,
956
+ })
957
+ }
958
+ }
959
+ return out
960
+ }
961
+ if (frame.type === 'projection') {
962
+ if (typeof frame.sessionId !== 'string') {
963
+ onDiagnostic?.('dropped a session/control projection frame without a sessionId')
964
+ return []
965
+ }
966
+ if (typeof frame.seq !== 'number' || !Number.isSafeInteger(frame.seq) || frame.seq < 0) {
967
+ // Same SessionSeq hazard on live updates (a server reboot resets the
968
+ // counters — a -1 or fractional seq must not kill the stream).
969
+ onDiagnostic?.(`dropped a session/control projection frame with unusable seq ${String(frame.seq)}`)
970
+ return []
971
+ }
972
+ return [
973
+ {
974
+ ...frame,
975
+ sessionId: virtualize(frame.sessionId),
976
+ // The modelSelection update's providers go virtual with the session
977
+ // id (T52) — same unconditional rewrite as the exploded baseline
978
+ // above (T52-fix3: first-write-wins per seq forbids a catalog gate).
979
+ ...(frame.key === 'modelSelection'
980
+ ? { value: virtualizeModelSelectionValue(frame.value, identity.serverId) }
981
+ : {}),
982
+ },
983
+ ]
984
+ }
985
+ onDiagnostic?.(`dropped a session/control frame of unknown type ${String(frame.type)}`)
986
+ return []
987
+ }
988
+
989
+ return {
990
+ get serverId(): string {
991
+ return identity.serverId
992
+ },
993
+ get serverName(): string {
994
+ return identity.serverName
995
+ },
996
+
997
+ // TODO(seq-epoch): the server owns `seq` and restarts it from 0 on
998
+ // reboot, while the UI drops any projection whose seq is ≤ the value it
999
+ // already holds — so after a server restart every remote session's
1000
+ // projections stay frozen at their pre-restart values until the live seq
1001
+ // climbs past them. A fix would offset each remote stream generation's
1002
+ // seq (e.g. by a per-generation epoch base) and shift the forwarded
1003
+ // `session/projections` results by the same offset so both routes agree.
1004
+ // Deliberately out of scope for T23b2-fix; recorded here where the seq
1005
+ // conversion happens.
1006
+
1007
+ retarget(next: MergerIdentity): void {
1008
+ identity = { serverId: next.serverId, serverName: next.serverName }
1009
+ },
1010
+
1011
+ onLocal(frame: unknown): unknown[] {
1012
+ if (isPlainObject(frame) && frame.type === 'baseline' && !localSeen) {
1013
+ localSeen = true
1014
+ // The baseline opens the stream; the buffered remote output follows.
1015
+ const out = [frame, ...buffered]
1016
+ buffered = []
1017
+ return out
1018
+ }
1019
+ return [frame]
1020
+ },
1021
+
1022
+ onRemote(frame: unknown): unknown[] {
1023
+ if (!isPlainObject(frame)) {
1024
+ onDiagnostic?.('dropped a non-object session/control frame from the relay')
1025
+ return []
1026
+ }
1027
+ const converted = convert(frame)
1028
+ if (!localSeen) {
1029
+ buffered.push(...converted)
1030
+ return []
1031
+ }
1032
+ return converted
1033
+ },
1034
+
1035
+ onRemoteDown(): unknown[] {
1036
+ return []
1037
+ },
1038
+
1039
+ onRemoteGone(): unknown[] {
1040
+ // Buffered pre-baseline remote output dies with the remote leg; the UI
1041
+ // never saw it.
1042
+ buffered = []
1043
+ return []
1044
+ },
1045
+
1046
+ onServerRenamed(): unknown[] {
1047
+ return []
1048
+ },
1049
+
1050
+ setStatus(_annotation: MergerAnnotation): void {},
1051
+
1052
+ onStatusChanged(): unknown[] {
1053
+ return []
1054
+ },
1055
+
1056
+ setCurrentSession(_sessionId: string | undefined): unknown[] {
1057
+ return []
1058
+ },
1059
+
1060
+ get hasPendingHide(): boolean {
1061
+ return false
1062
+ },
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Merge one `session/list` result pair (first page only — the caller skips
1068
+ * paged requests). Local items keep their order and their pagination fields;
1069
+ * the remote items are appended with virtualized session ids — `sessionId`
1070
+ * AND `parentSessionId` (CP4): the fork link must point at the VIRTUAL parent
1071
+ * id the UI knows, or the fork would sort beside a parent id that exists in
1072
+ * no list the UI holds — and a T52-fix rewrite of the row's own projections
1073
+ * block: a list row carries `{kind:'cached'|'sequenced', asOfSeq, values}`
1074
+ * (RT dsh-api-session-controller lib/typert.remote-client.js:381-428) and the
1075
+ * client face applies it PER SESSION (lib/client.js:2633 → applyListBlock
1076
+ * :2842-2855) — a `sequenced` modelSelection lands under higher-seq-wins
1077
+ * (lib/client.js:986-995, `seq <= row.seq` rejects), and the control stream's
1078
+ * REWRITTEN frame rides that projection's OWN seq, so an original-provider
1079
+ * value left in a row would poison the store forever: the remote session's
1080
+ * dropdown would show the raw `provider/model` fallback with no check mark.
1081
+ * The rewrite is UNCONDITIONAL (T52-fix3): first-write-wins per seq forbids
1082
+ * a catalog gate — a row rewritten while the catalog was missing would
1083
+ * occupy the seq forever. A missing or malformed remote result means "remote
1084
+ * said nothing" — the local result passes back untouched.
1085
+ */
1086
+ export function mergeSessionList(localResult: unknown, remoteResult: unknown, serverId: string): unknown {
1087
+ if (!isPlainObject(localResult)) return localResult
1088
+ if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return localResult
1089
+ const localItems = Array.isArray(localResult.items) ? localResult.items : []
1090
+ const virtualize = (id: string): string => toVirtual(serverId, id)
1091
+ const remoteItems = remoteResult.items.map((item) => {
1092
+ if (!isPlainObject(item) || typeof item.sessionId !== 'string') return item
1093
+ const out: Record<string, unknown> = { ...item, sessionId: virtualize(item.sessionId) }
1094
+ if (typeof item.parentSessionId === 'string') out.parentSessionId = virtualize(item.parentSessionId)
1095
+ if (
1096
+ isPlainObject(item.projections) &&
1097
+ isPlainObject(item.projections.values) &&
1098
+ Object.hasOwn(item.projections.values, 'modelSelection')
1099
+ ) {
1100
+ const values: Record<string, unknown> = { ...item.projections.values }
1101
+ values.modelSelection = virtualizeModelSelectionValue(values.modelSelection, serverId)
1102
+ out.projections = { ...item.projections, values }
1103
+ }
1104
+ return out
1105
+ })
1106
+ return { ...localResult, items: [...localItems, ...remoteItems] }
1107
+ }
1108
+
1109
+ /**
1110
+ * Fold the relay's `session/modelCatalog` answer into the local one (T52).
1111
+ * The result shape is RT dsh-api-session-controller
1112
+ * `session_modelCatalog_result$schema` (lib/typert.remote-client.js:489-518):
1113
+ * `{default, routableProviders, groups, failures}` — provider group ids and
1114
+ * display names only, no session data anywhere, so no output filter guards it
1115
+ * server-side and nothing here needs narrowing.
1116
+ *
1117
+ * - server groups are APPENDED after the local ones (the dropdown sorts
1118
+ * `deepseek-account` / `deepseek-official` first and keeps the rest in
1119
+ * order, RT dsh-client-ui-model-selection lib/client.js:510 — appended
1120
+ * groups render last); each group id becomes the virtual group id
1121
+ * `zr~<serverId>~<original>` and its name is prefixed
1122
+ * `${serverName} · ${name}` — the same workspace title discipline the
1123
+ * merger uses;
1124
+ * - `default` and `failures` stay LOCAL untouched: the default drives what a
1125
+ * blank LOCAL session would run (a remote session's current model comes
1126
+ * from its projection instead), and the UI renders every failure as a
1127
+ * prominent warning row whose Retry reloads the WHOLE catalog
1128
+ * (lib/client.js:816-827) — a server-side group failure is not retryable
1129
+ * from here and would put a permanent alarm on every dropdown, so it does
1130
+ * not travel (dsh-vision-router's settings read the same result and shows
1131
+ * an error state exactly when groups are empty AND failures exist,
1132
+ * lib/client.js:635-647 — dropping remote failures keeps that honest).
1133
+ * KNOWN BOUNDARY (pre-T52 behavior, unchanged): a NEW server session's
1134
+ * projection `next` is null until its first selection or turn, so the UI
1135
+ * shows `catalog.value.default` — the LOCAL default — as that session's
1136
+ * current model (RT dsh-client-ui-model-selection lib/client.js:316,
1137
+ * `projected?.next ?? catalog.value?.default`), which may differ from what
1138
+ * the server would actually run until its first selection lands.
1139
+ * - `routableProviders` mirrors the group ids by construction host-side
1140
+ * (RT dsh-api-session-controller lib/index.js:544 — literally
1141
+ * `groups.map(g => g.id)`); no client UI reads it (the dropdown's
1142
+ * routable verdict comes from the groups themselves, lib/client.js:334),
1143
+ * so appending the virtual ids is a consistency move: the merged catalog
1144
+ * keeps the invariant the Host maintains, whatever reads it next.
1145
+ *
1146
+ * A malformed local or remote value means "one side said nothing" — the
1147
+ * local result passes back untouched, like {@link mergeSessionList}.
1148
+ */
1149
+ export function mergeModelCatalogs(localValue: unknown, remoteValue: unknown, identity: MergerIdentity): unknown {
1150
+ if (!isPlainObject(localValue)) return localValue
1151
+ if (!isPlainObject(remoteValue) || !Array.isArray(remoteValue.groups)) return localValue
1152
+ const localGroups = Array.isArray(localValue.groups) ? localValue.groups : []
1153
+ const virtualGroups: unknown[] = []
1154
+ const virtualIds: string[] = []
1155
+ for (const group of remoteValue.groups) {
1156
+ if (!isPlainObject(group) || typeof group.id !== 'string' || group.id === '') continue
1157
+ const id = toVirtual(identity.serverId, group.id)
1158
+ virtualIds.push(id)
1159
+ virtualGroups.push({
1160
+ ...group,
1161
+ id,
1162
+ ...(typeof group.name === 'string' ? { name: `${identity.serverName} · ${group.name}` } : {}),
1163
+ })
1164
+ }
1165
+ if (virtualGroups.length === 0) return localValue
1166
+ return {
1167
+ ...localValue,
1168
+ groups: [...localGroups, ...virtualGroups],
1169
+ routableProviders: [...stringList(localValue.routableProviders), ...virtualIds],
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * Virtualize the provider ids inside one `modelSelection` projection value —
1175
+ * `{lastUsed: {provider, model, reasoningEffort?} | null, next: …}` (RT
1176
+ * dsh-api-session-controller lib/types/model-selection-projection.js:12-15,
1177
+ * wire shapes at lib/typert.remote-client.js:83-94). The UI resolves the
1178
+ * current model's display name by matching `(provider, model)` against the
1179
+ * catalog groups (dsh-client-ui-model-selection lib/client.js:334, 520); for
1180
+ * a remote session the provider is a SERVER group id, so it must become the
1181
+ * virtual group id to be found in the merged catalog — and while the relay
1182
+ * serves, the merged catalog always carries it, so the composer trigger
1183
+ * shows the model NAME instead of the raw `provider/model` fallback string
1184
+ * (lib/client.js:700).
1185
+ *
1186
+ * UNCONDITIONAL by decision (T52-fix3, reverting T52-fix2's catalog gate):
1187
+ * the host's projection store is first-write-wins per sequence number
1188
+ * (lib/client.js:986-994), so whether a value is rewritten may not depend on
1189
+ * WHEN the catalog happened to arrive — an original written pre-catalog
1190
+ * occupies the seq and the later virtual value is dropped, leaving the
1191
+ * server group unchecked and the next reasoning-effort submit refused.
1192
+ * Accepted cost: a provider the server's own catalog does not list renders
1193
+ * the `zr~<serverId>~provider/model` fallback string. Nulls and malformed
1194
+ * entries pass through untouched; `model` and `reasoningEffort` are
1195
+ * group-agnostic ids and stay as they are.
1196
+ */
1197
+ export function virtualizeModelSelectionValue(value: unknown, serverId: string): unknown {
1198
+ if (!isPlainObject(value)) return value
1199
+ const out: Record<string, unknown> = { ...value }
1200
+ for (const key of ['lastUsed', 'next'] as const) {
1201
+ const selection = value[key]
1202
+ if (isPlainObject(selection) && typeof selection.provider === 'string') {
1203
+ out[key] = { ...selection, provider: toVirtual(serverId, selection.provider) }
1204
+ }
1205
+ }
1206
+ return out
1207
+ }
1208
+
1209
+ /**
1210
+ * T63: the virtual session ids a set of FORWARDED workspace frames carries —
1211
+ * the `sessionIds` of `upsert` records and of baseline `value.items`. The
1212
+ * workspace route feeds these to the summary sync, which announces list rows
1213
+ * for ids the UI has no summary for yet (the sidebar drops a group member
1214
+ * without one — RT dsh-client-ui-workspace orderByRecency / groupByWorkspace).
1215
+ * Reading the FORWARDED output (not the merger's raw state) is the point:
1216
+ * T56 already withheld empty groups and T58 already dropped hidden sessions,
1217
+ * so a hidden closed-remote session never reaches here and is never
1218
+ * announced. Only virtual ids count — local baseline content rides these
1219
+ * frames verbatim and its rows already have summaries.
1220
+ */
1221
+ export function virtualSessionIdsInWorkspaceFrames(frames: readonly unknown[]): string[] {
1222
+ const out = new Set<string>()
1223
+ const collect = (record: unknown): void => {
1224
+ if (!isPlainObject(record) || !Array.isArray(record.sessionIds)) return
1225
+ for (const id of record.sessionIds) {
1226
+ if (typeof id === 'string' && isVirtual(id)) out.add(id)
1227
+ }
1228
+ }
1229
+ for (const frame of frames) {
1230
+ if (!isPlainObject(frame)) continue
1231
+ if (frame.type === 'upsert') collect(frame.workspace)
1232
+ if (frame.type === 'baseline') {
1233
+ const value = isPlainObject(frame.value) ? frame.value : undefined
1234
+ if (value !== undefined && Array.isArray(value.items)) for (const item of value.items) collect(item)
1235
+ }
1236
+ }
1237
+ return [...out]
1238
+ }
1239
+
1240
+ /**
1241
+ * T63: the synthesized `api-session/added` emit frames for one relay
1242
+ * `session/list` answer — one `{type:'emit', event:'api-session/added',
1243
+ * args:[row]}` frame per row. This is the host's own mechanism for adding a
1244
+ * list row without a re-pull (RT dsh-api-session-controller: the client face
1245
+ * subscribes `api-session/added` → handleSessionAdded → mergeSummary →
1246
+ * applyMutation's upsert, which ADDS an unknown row and fills an existing
1247
+ * one — idempotent), and the emit frame shape is the client face's
1248
+ * exact-keys `{type, event, args}` with args a JSON array
1249
+ * (dsh-api-gateway lib/client.js parseRemoteEventFrame) — the same shape the
1250
+ * T52 catalog-refresh frame uses. The rows are virtualized by
1251
+ * {@link mergeSessionList} itself, so a synthesized row carries exactly what
1252
+ * the merged `session/list` route would have answered: the session id AND
1253
+ * the fork parent virtualized, and the projections' modelSelection providers
1254
+ * rewritten (a row's `sequenced` block must not poison the projection store
1255
+ * against the control stream's rewritten frames — T52-fix3). A malformed
1256
+ * remote result means "the server said nothing" — no frames.
1257
+ */
1258
+ export function sessionSummaryAddedFrames(remoteResult: unknown, serverId: string): unknown[] {
1259
+ if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return []
1260
+ const merged = mergeSessionList({ items: [] }, remoteResult, serverId)
1261
+ if (!isPlainObject(merged) || !Array.isArray(merged.items)) return []
1262
+ const frames: unknown[] = []
1263
+ for (const row of merged.items) {
1264
+ // mergeSessionList passes a malformed row through as-is; the UI's
1265
+ // applyMutation would file it under `byId[undefined]`, so only a
1266
+ // well-formed row (plain object, string sessionId — the shape
1267
+ // handleSessionAdded's mergeSummary keys on) becomes a frame.
1268
+ if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
1269
+ frames.push({ type: 'emit', event: 'api-session/added', args: [row] })
1270
+ }
1271
+ return frames
1272
+ }