dsh-zen-remote 2.3.11 → 2.4.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 (244) hide show
  1. package/CHANGELOG.md +39 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +564 -363
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-store.js +65 -27
  70. package/lib/share-store.js.map +1 -1
  71. package/lib/state-store.js +400 -0
  72. package/lib/state-store.js.map +1 -0
  73. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  74. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  75. package/lib/types/admin-routes.d.ts.map +1 -1
  76. package/lib/types/cli/args.d.ts +78 -0
  77. package/lib/types/cli/args.d.ts.map +1 -0
  78. package/lib/types/cli/bootstrap.d.ts +170 -0
  79. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  80. package/lib/types/cli/command-setup.d.ts +72 -0
  81. package/lib/types/cli/command-setup.d.ts.map +1 -0
  82. package/lib/types/cli/commands-client.d.ts +36 -0
  83. package/lib/types/cli/commands-client.d.ts.map +1 -0
  84. package/lib/types/cli/commands-host.d.ts +82 -0
  85. package/lib/types/cli/commands-host.d.ts.map +1 -0
  86. package/lib/types/cli/commands-status.d.ts +46 -0
  87. package/lib/types/cli/commands-status.d.ts.map +1 -0
  88. package/lib/types/cli/config-writer.d.ts +92 -0
  89. package/lib/types/cli/config-writer.d.ts.map +1 -0
  90. package/lib/types/cli/context.d.ts +39 -0
  91. package/lib/types/cli/context.d.ts.map +1 -0
  92. package/lib/types/cli/i18n.d.ts +32 -0
  93. package/lib/types/cli/i18n.d.ts.map +1 -0
  94. package/lib/types/cli/ipc-client.d.ts +83 -0
  95. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-server.d.ts +113 -0
  97. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  98. package/lib/types/cli/main.d.ts +57 -0
  99. package/lib/types/cli/main.d.ts.map +1 -0
  100. package/lib/types/cli/qr-terminal.d.ts +17 -0
  101. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  102. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  103. package/lib/types/client/current-session-report.d.ts +4 -0
  104. package/lib/types/client/current-session-report.d.ts.map +1 -1
  105. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  106. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  107. package/lib/types/client/locales.d.ts +47 -3
  108. package/lib/types/client/locales.d.ts.map +1 -1
  109. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  110. package/lib/types/client/settings/settings-css.d.ts +1 -1
  111. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  112. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  113. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  114. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  115. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  116. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  117. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  118. package/lib/types/client-data/model-group-side.d.ts +23 -2
  119. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  120. package/lib/types/client-data/remote-status.d.ts +47 -1
  121. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  122. package/lib/types/client-data/settings-form.d.ts +85 -1
  123. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  124. package/lib/types/client-data/wizard.d.ts +56 -1
  125. package/lib/types/client-data/wizard.d.ts.map +1 -1
  126. package/lib/types/client-routes.d.ts +25 -1
  127. package/lib/types/client-routes.d.ts.map +1 -1
  128. package/lib/types/config.d.ts +40 -14
  129. package/lib/types/config.d.ts.map +1 -1
  130. package/lib/types/debug-report.d.ts.map +1 -1
  131. package/lib/types/fetch-route-intercept.d.ts +66 -8
  132. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  133. package/lib/types/index.d.ts +9 -2
  134. package/lib/types/index.d.ts.map +1 -1
  135. package/lib/types/intercept.d.ts +72 -10
  136. package/lib/types/intercept.d.ts.map +1 -1
  137. package/lib/types/merge-streams.d.ts +79 -1
  138. package/lib/types/merge-streams.d.ts.map +1 -1
  139. package/lib/types/plugin-route-intercept.d.ts +25 -9
  140. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  141. package/lib/types/relay-access.d.ts +3 -1
  142. package/lib/types/relay-access.d.ts.map +1 -1
  143. package/lib/types/relay-client.d.ts +23 -2
  144. package/lib/types/relay-client.d.ts.map +1 -1
  145. package/lib/types/relay-pool.d.ts +148 -0
  146. package/lib/types/relay-pool.d.ts.map +1 -0
  147. package/lib/types/relay-server.d.ts +30 -3
  148. package/lib/types/relay-server.d.ts.map +1 -1
  149. package/lib/types/restart-fields.d.ts +5 -5
  150. package/lib/types/restart-fields.d.ts.map +1 -1
  151. package/lib/types/share-store.d.ts.map +1 -1
  152. package/lib/types/state-store.d.ts +179 -0
  153. package/lib/types/state-store.d.ts.map +1 -0
  154. package/lib/types/upload-limits.d.ts +15 -0
  155. package/lib/types/upload-limits.d.ts.map +1 -1
  156. package/lib/types/virtual-id.d.ts +21 -0
  157. package/lib/types/virtual-id.d.ts.map +1 -1
  158. package/lib/types/wizard/probe.d.ts.map +1 -1
  159. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  160. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  161. package/lib/types/wizard/routes.d.ts +1 -0
  162. package/lib/types/wizard/routes.d.ts.map +1 -1
  163. package/lib/types/wizard/status.d.ts +9 -0
  164. package/lib/types/wizard/status.d.ts.map +1 -1
  165. package/lib/types/wizard/verify.d.ts +3 -0
  166. package/lib/types/wizard/verify.d.ts.map +1 -1
  167. package/lib/upload-limits.js +15 -0
  168. package/lib/upload-limits.js.map +1 -1
  169. package/lib/virtual-id.js +60 -0
  170. package/lib/virtual-id.js.map +1 -1
  171. package/lib/wizard/probe.js +46 -1
  172. package/lib/wizard/probe.js.map +1 -1
  173. package/lib/wizard/proxy-snippets.js +14 -1
  174. package/lib/wizard/proxy-snippets.js.map +1 -1
  175. package/lib/wizard/routes.js +23 -4
  176. package/lib/wizard/routes.js.map +1 -1
  177. package/lib/wizard/status.js +21 -0
  178. package/lib/wizard/status.js.map +1 -1
  179. package/lib/wizard/verify.js +31 -8
  180. package/lib/wizard/verify.js.map +1 -1
  181. package/package.json +8 -4
  182. package/pwa/icons/badge-96.png +0 -0
  183. package/pwa/icons/badge.svg +1 -0
  184. package/pwa/manifest.json +2 -1
  185. package/pwa/sw.js +3 -1
  186. package/scripts/check-debug-report.mjs +41 -1
  187. package/scripts/check-settings-form.mjs +14 -3
  188. package/scripts/check-settings-view.mjs +247 -0
  189. package/scripts/check-wizard-ui.mjs +37 -0
  190. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  191. package/src/admin-routes.ts +10 -2
  192. package/src/cli/args.ts +289 -0
  193. package/src/cli/bootstrap.ts +569 -0
  194. package/src/cli/command-setup.ts +540 -0
  195. package/src/cli/commands-client.ts +369 -0
  196. package/src/cli/commands-host.ts +1127 -0
  197. package/src/cli/commands-status.ts +296 -0
  198. package/src/cli/config-writer.ts +354 -0
  199. package/src/cli/context.ts +38 -0
  200. package/src/cli/i18n.ts +645 -0
  201. package/src/cli/ipc-client.ts +216 -0
  202. package/src/cli/ipc-server.ts +531 -0
  203. package/src/cli/main.ts +285 -0
  204. package/src/cli/qr-terminal.ts +49 -0
  205. package/src/client/RemoteStatusIcon.tsx +4 -1
  206. package/src/client/current-session-report.ts +28 -4
  207. package/src/client/effects/model-group-side.ts +19 -5
  208. package/src/client/effects/remote-session.ts +26 -6
  209. package/src/client/locales.ts +96 -6
  210. package/src/client/settings/SettingsSection.tsx +287 -92
  211. package/src/client/settings/settings-css.ts +18 -0
  212. package/src/client/styles/remote-session.css.ts +15 -1
  213. package/src/client/wizard/GatewayWizard.tsx +470 -109
  214. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  215. package/src/client/wizard/wizard-css.ts +23 -0
  216. package/src/client-data/model-group-side.ts +37 -10
  217. package/src/client-data/remote-status.ts +112 -17
  218. package/src/client-data/settings-form.ts +164 -8
  219. package/src/client-data/wizard.ts +112 -2
  220. package/src/client-routes.ts +334 -26
  221. package/src/config.ts +62 -16
  222. package/src/debug-report.ts +27 -1
  223. package/src/fetch-route-intercept.ts +381 -32
  224. package/src/index.ts +566 -367
  225. package/src/intercept.ts +1866 -286
  226. package/src/merge-streams.ts +155 -10
  227. package/src/plugin-route-intercept.ts +120 -71
  228. package/src/relay-access.ts +14 -3
  229. package/src/relay-client.ts +163 -5
  230. package/src/relay-pool.ts +387 -0
  231. package/src/relay-server.ts +312 -16
  232. package/src/restart-fields.ts +6 -4
  233. package/src/share-store.ts +59 -26
  234. package/src/state-store.ts +428 -0
  235. package/src/upload-limits.ts +16 -0
  236. package/src/virtual-id.ts +57 -0
  237. package/src/wizard/probe.ts +54 -2
  238. package/src/wizard/proxy-snippets.ts +17 -2
  239. package/src/wizard/routes.ts +24 -5
  240. package/src/wizard/status.ts +28 -0
  241. package/src/wizard/verify.ts +33 -11
  242. package/AGENTS.md +0 -185
  243. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  244. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/lib/intercept.js CHANGED
@@ -82,7 +82,7 @@ import { RelayError, defaultClock } from './relay-client.js';
82
82
  import { checkGatewayShape } from './intercept-shape.js';
83
83
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js';
84
84
  import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary } from './invoke-binary.js';
85
- import { createControlMerger, createWorkspaceMerger, mergeModelCatalogs, mergeSessionList, sessionSummaryAddedFrames, virtualSessionIdsInWorkspaceFrames, virtualizeModelSelectionValue, } from './merge-streams.js';
85
+ import { composeMergedWorkspaceFrames, createControlMerger, createWorkspaceMerger, localWorkspaceLists, mergeModelCatalogs, mergeSessionList, sessionSummaryAddedFrames, virtualSessionIdsInWorkspaceFrames, virtualizeModelSelectionValue, } from './merge-streams.js';
86
86
  /**
87
87
  * The client-side half of the server's `RELAY_METHODS` registry: which
88
88
  * argument field locates the session for each method. METHODS AND FIELDS
@@ -171,6 +171,11 @@ export const CLIENT_METHOD_FIELDS = {
171
171
  'terminal/rename': ['agentId'],
172
172
  'terminal/close': ['agentId'],
173
173
  'terminal/follow': ['agentId'],
174
+ // TA-1: the 0.2.2 duplex stream (follow+write+resize merged), located by
175
+ // the same agentId. TA-2 wired the interceptor half: the host's uplink
176
+ // inbox and its window travel to the relay's openStream options (see
177
+ // REMOTE_UPLINK_STREAM_METHODS) instead of being half-closed.
178
+ 'terminal/attach': ['agentId'],
174
179
  // the retain/list half locates by a plain top-level json sessionId
175
180
  'terminal/list': ['sessionId'],
176
181
  'terminal/retain': ['sessionId'],
@@ -299,11 +304,50 @@ export const REMOTE_READ_METHODS = new Set([
299
304
  export function isRemoteWrite(endpoint) {
300
305
  return !REMOTE_READ_METHODS.has(endpoint);
301
306
  }
302
- /** The refusal a write gets while the relay is not serving (T34). */
303
- const WRITE_OFFLINE_MESSAGE = '服务端离线,远程会话暂时只读';
304
- /** What a call or stream on a closed remote session answers in place of the
305
- * relay's own text (T73) — the banner already says why. */
306
- const CLOSED_MESSAGE = '远程已关闭';
307
+ /**
308
+ * Stream endpoints whose caller-supplied `uplink` iterable and
309
+ * `uplinkWindow` travel to the relay instead of being half-closed locally
310
+ * with `releaseUplink`. DSH 0.2.2+ replaced `terminal/follow|write|resize`
311
+ * with the duplex `terminal/attach` stream (`TerminalInput` write/resize
312
+ * commands ride the uplink; `ack` frames ride the downlink). The relay
313
+ * client's pump (relay-client.ts) is the other half: it forwards the host's
314
+ * frames through the server's `/v1/stream-uplink` channel and releases the
315
+ * host inbox itself when the stream ends — the interceptor only hands the
316
+ * iterable over, it never half-closes a member of this set.
317
+ */
318
+ export const REMOTE_UPLINK_STREAM_METHODS = new Set(['terminal/attach']);
319
+ const INTERCEPT_COPY = {
320
+ zh: {
321
+ writeOffline: '服务端离线,远程会话暂时只读',
322
+ closed: '远程已关闭',
323
+ notConnected: '尚未连接到服务端',
324
+ mismatch: '此远程会话属于其他主服务端',
325
+ unsupported: '此功能暂不支持远程会话',
326
+ referenceNotOnServer: '引用的会话不在服务端上,无法转发',
327
+ revoked: '配对令牌已被吊销,请在设置页重新配对',
328
+ unpaired: '尚未与服务端配对,请在设置页完成配对',
329
+ modelGroupRemote: '该模型分组属于远程服务端,无法在本机会话中使用',
330
+ modelGroupPickServer: (displayName) => `请选择「${displayName} · …」分组里的模型`,
331
+ modelGroupMismatch: '此模型分组属于其他主服务端',
332
+ },
333
+ en: {
334
+ writeOffline: 'Server offline — remote session is temporarily read-only',
335
+ closed: 'Remote closed',
336
+ notConnected: 'Not connected to the server yet',
337
+ mismatch: 'This remote session belongs to another server',
338
+ unsupported: 'This feature is not supported in remote sessions yet',
339
+ referenceNotOnServer: 'Referenced session is not on the server and cannot be forwarded',
340
+ revoked: 'Pairing token was revoked — please pair again in Settings',
341
+ unpaired: 'Not paired with the server yet — please pair in Settings',
342
+ modelGroupRemote: 'This model group belongs to a remote server and cannot be used in a local session',
343
+ modelGroupPickServer: (displayName) => `Select a model under "${displayName} · …"`,
344
+ modelGroupMismatch: 'This model group belongs to another server',
345
+ },
346
+ };
347
+ function resolveInterceptLang(lang) {
348
+ const value = typeof lang === 'function' ? lang() : lang;
349
+ return value === 'en' ? 'en' : 'zh';
350
+ }
307
351
  /**
308
352
  * The RelayError codes that mean "the LINK went down under a live stream"
309
353
  * (CP4): `offline` is the transport death, `server-restart` the server's
@@ -449,6 +493,27 @@ function releaseUplink(uplink) {
449
493
  // A non-iterable uplink has nothing to release.
450
494
  }
451
495
  }
496
+ /**
497
+ * The shape check one duplex stream's `uplinkWindow` must pass before it
498
+ * travels (TA-2) — the same rule the server re-applies on `/v1/stream`
499
+ * (relay-server.ts): positive safe-integer `maxBytes`, non-negative
500
+ * safe-integer `frameOverheadBytes`. Anything else — an older host's
501
+ * `undefined`, a non-object, a fractional or negative budget — comes back
502
+ * `undefined` and the stream forwards with the relay's own default window:
503
+ * the numbers are the host's contract with itself, garbage is dropped, never
504
+ * refused.
505
+ */
506
+ function normalizeUplinkWindow(raw) {
507
+ if (!isPlainObject(raw))
508
+ return undefined;
509
+ const { maxBytes, frameOverheadBytes } = raw;
510
+ if (typeof maxBytes !== 'number' || !Number.isSafeInteger(maxBytes) || maxBytes <= 0)
511
+ return undefined;
512
+ if (typeof frameOverheadBytes !== 'number' || !Number.isSafeInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
513
+ return undefined;
514
+ }
515
+ return { maxBytes, frameOverheadBytes };
516
+ }
452
517
  /**
453
518
  * The gateway error codes that count as version-mismatch symptoms, verified
454
519
  * against dsh-api-gateway 0.2.0-rc.1 (`lib/index.js`):
@@ -1128,6 +1193,7 @@ async function* mergedGlobalStream(deps) {
1128
1193
  serverName: initial?.serverName ?? '',
1129
1194
  onDiagnostic,
1130
1195
  now: clock.now,
1196
+ lang: deps.lang,
1131
1197
  ...(sessionSlot !== undefined ? { onKnownSessions: (ids) => sessionSlot.replace(ids) } : {}),
1132
1198
  });
1133
1199
  /**
@@ -1188,7 +1254,14 @@ async function* mergedGlobalStream(deps) {
1188
1254
  // away, and a navigation writes no relay frames. An `unavailable` read is
1189
1255
  // skipped entirely: the merger falls back to the conservative tombstone
1190
1256
  // behavior then.
1257
+ let lastLang = resolveInterceptLang(deps.lang);
1191
1258
  const informCurrentSession = () => {
1259
+ const nextLang = resolveInterceptLang(deps.lang);
1260
+ if (nextLang !== lastLang) {
1261
+ lastLang = nextLang;
1262
+ for (const frame of merger.onStatusChanged())
1263
+ channel.push(frame);
1264
+ }
1192
1265
  const read = deps.getCurrentSession();
1193
1266
  if (read.kind === 'unavailable')
1194
1267
  return;
@@ -2229,256 +2302,1545 @@ async function* mergedEventsStream(deps) {
2229
2302
  }
2230
2303
  }
2231
2304
  /**
2232
- * Install the two own-property wrappers on the raw gateway. Assumes
2233
- * {@link checkGatewayShape} passed (the wiring gates on it) — this function
2234
- * records the verdict but does not re-gate, so it stays usable in tests
2235
- * that install over deliberately odd shapes.
2305
+ * The pool-mode merged global stream. Per server slot the same rules as the
2306
+ * single-server merged stream apply; the slots' outputs differ only in the
2307
+ * GLOBAL frames (`order` / `archived` / `pinned` and the baseline's merged
2308
+ * lists), which are never taken from a slot's own merger — they are
2309
+ * composed canonically from every slot's contribution in pairing order
2310
+ * (composeMergedWorkspaceFrames). Per-workspace frames (upserts and
2311
+ * removes) are slot-local facts and pass as the merger produced them.
2236
2312
  */
2237
- export function installIntercept(options) {
2238
- const { raw, relay, getServerId, log } = options;
2239
- const clock = options.clock ?? defaultClock;
2240
- // T63: the summary-sync hub shared by the merged streams — the workspace
2241
- // route nudges, the events leg fetches-and-announces. One per install: it
2242
- // outlives individual stream generations so a nudge from a workspace leg
2243
- // reaches whichever $events generation is open.
2244
- const summarySync = createSessionSummarySync();
2245
- // PB-25b: the inventory slot factory (the wiring's union feed) — passed
2246
- // down to every merged stream, which owns one slot for its lifetime.
2247
- const createSessionInventorySlot = options.createSessionInventorySlot;
2248
- // T62: the current-session getter and the live-merged-stream listener
2249
- // registry behind handle.currentSessionChanged().
2250
- const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }));
2251
- const currentSessionListeners = new Set();
2252
- const watchCurrentSession = (fn) => {
2253
- currentSessionListeners.add(fn);
2254
- return () => { currentSessionListeners.delete(fn); };
2313
+ async function* mergedGlobalStreamMulti(deps) {
2314
+ const { endpoint, namespace, method, local, pool, signal, recordFailure, log, clock } = deps;
2315
+ if (signal?.aborted === true)
2316
+ return;
2317
+ const isWorkspace = endpoint === 'workspace/follow';
2318
+ const onDiagnostic = (message) => {
2319
+ log?.('client multi merge dropped a frame on %s: %s', endpoint, message);
2255
2320
  };
2256
- const uninstallListeners = new Set();
2257
- const watchUninstall = (fn) => {
2258
- uninstallListeners.add(fn);
2259
- return () => { uninstallListeners.delete(fn); };
2321
+ const channel = createFrameChannel();
2322
+ let alive = true;
2323
+ const slots = new Map();
2324
+ const onlineWaiters = [];
2325
+ const flushWaiters = () => {
2326
+ for (const wake of onlineWaiters.splice(0))
2327
+ wake();
2260
2328
  };
2261
- const gateway = raw;
2262
- const shape = checkGatewayShape(raw);
2263
- const counters = { openWireStream: 0, dispatchRpc: 0 };
2264
- const failures = [];
2265
- const incompatible = [];
2266
- // The closed-session registry (T34): insertion-ordered, capped, virtual-id
2267
- // keyed. Entries live until THEIR session proves reachable again (a
2268
- // succeeding call for it) — a reconnect clears nothing (T34-fix): a link
2269
- // that flapped under a still-standing closure would make the banner flicker
2270
- // away and back.
2271
- const closedSessions = new Map();
2272
- // T73-fix: waiters for "a closed-session record was just cleared" — the
2273
- // quiet session/follow hold (rewriteUpstream) ends its silence the moment
2274
- // the session it holds for is served again, so a re-enabled remote comes
2275
- // back with a fresh history instead of staying blind under its banner.
2276
- const closedClearWaiters = new Set();
2277
- // The last successfully fetched SERVER model catalog (T52-fix), kept for
2278
- // the offline merges of the `session/modelCatalog` route below — cleared
2279
- // on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
2280
- // own drop is the backstop) and never served for another server's id (see the
2281
- // route's doc). Only an in-memory value: the groups are display data and
2282
- // a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
2283
- // the modelSelection provider rewrites are unconditional again (the host's
2284
- // projection store is first-write-wins per seq, so a catalog-gated rewrite
2285
- // would poison the store — see virtualizeModelSelectionValue), and the
2286
- // `groups` membership set is gone with the gate.
2287
- let serverCatalogCache;
2288
- /**
2289
- * The serving generation, bumped on every pairing wall (`unpaired` /
2290
- * `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
2291
- * rose must not write its answer back — the wall's groups must not outlive
2292
- * it, and a late write would resurrect them under the offline merge below.
2293
- * Both cache writes (the proactive fetch here, the route's own fetch)
2294
- * snapshot the counter before invoking and compare before storing; a
2295
- * mismatched answer is silently dropped — it is not a fault, the next
2296
- * serving period fetches fresh anyway.
2297
- */
2298
- let catalogGeneration = 0;
2299
- const rememberServerCatalog = (identity, value, generation) => {
2300
- if (generation !== catalogGeneration)
2329
+ const waitAny = async () => {
2330
+ await new Promise((resolve) => {
2331
+ onlineWaiters.push(resolve);
2332
+ });
2333
+ };
2334
+ // The orchestrator's LOCAL truth, folded from every local frame — the one
2335
+ // source the canonical composition reads its local lists from (a slot's
2336
+ // merger keeps its own copy only for its discarded single-server frames).
2337
+ let localSeen = false;
2338
+ let localBaselineFrame = undefined;
2339
+ let localLists = { orderIds: [], archivedIds: [], pinnedIds: [] };
2340
+ const annotationOf = (client) => {
2341
+ const state = client.state;
2342
+ if (state === 'revoked')
2343
+ return 'revoked';
2344
+ if (state === 'unpaired')
2345
+ return 'unpaired';
2346
+ if (state !== 'online')
2347
+ return 'offline';
2348
+ const different = client.compat?.different;
2349
+ return different !== undefined && different.length > 0 ? 'mismatch' : 'none';
2350
+ };
2351
+ const workspaceMergerOf = (slot) => slot.kind === 'workspace' ? slot.merger : undefined;
2352
+ const pushOut = (frames) => {
2353
+ if (frames.length === 0)
2301
2354
  return;
2302
- serverCatalogCache = { identity, value };
2355
+ for (const frame of frames)
2356
+ channel.push(frame);
2357
+ if (isWorkspace) {
2358
+ const ids = virtualSessionIdsInWorkspaceFrames(frames);
2359
+ if (ids.length > 0)
2360
+ deps.summarySync.noteForwardedSessionIds(ids);
2361
+ }
2303
2362
  };
2304
- /** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
2305
- * the proactive pull that runs as soon as the relay serves — and once at
2306
- * install when the relay already serves (subscribe replays no state —
2307
- * relay-client.ts setState returns on a same-state write — so the
2308
- * subscription alone would miss an install-into-online). Failures are
2309
- * diagnostics-ring entries, nothing else (the route's own fetch is the
2310
- * user-visible path and answers local-only there). */
2311
- const fetchServerCatalog = (identity) => {
2312
- const generation = catalogGeneration;
2313
- void relay.invoke('session', 'modelCatalog', {}, undefined).then((value) => {
2314
- rememberServerCatalog(identity, value, generation);
2315
- }, (error) => {
2316
- recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal');
2317
- });
2363
+ const orderedRemoteParts = () => {
2364
+ const out = [];
2365
+ if (!isWorkspace)
2366
+ return out;
2367
+ for (const entry of pool.entries()) {
2368
+ if (entry.serverId === undefined)
2369
+ continue;
2370
+ const slot = slots.get(entry.serverId);
2371
+ const merger = slot !== undefined ? workspaceMergerOf(slot) : undefined;
2372
+ if (slot === undefined || merger === undefined)
2373
+ continue;
2374
+ out.push({ slot, remote: merger.parts().remote });
2375
+ }
2376
+ return out;
2318
2377
  };
2319
- // The catalog watcher (T52-fix2). The subscription fires on the real
2320
- // online transitions (first handshake, every recovery) — and since T59
2321
- // also on name-only notifications, which arrive with the state still
2322
- // `online` and are deduped by the same refreshKey/stateSeen gates above.
2323
- // The install-time check below covers an install into an already-online
2324
- // relay. `unpaired` / `revoked`
2325
- // drop the cache the same way the catalog route does lazily — the wall's
2326
- // groups must not outlive it — and bump the generation so an in-flight
2327
- // fetch cannot write the old catalog back (T52-fix3). Faults stay
2328
- // contained: the sync body cannot throw, and the fetch settles through
2329
- // .then/.catch above.
2330
- let offCatalogWatch;
2331
- try {
2332
- offCatalogWatch = relay.subscribe((state) => {
2333
- if (state === 'unpaired' || state === 'revoked') {
2334
- catalogGeneration += 1;
2335
- serverCatalogCache = undefined;
2336
- return;
2378
+ /** The canonical merged global frames over ALL current slots — pairing
2379
+ * order, local first. Only past the local baseline: before it the slots
2380
+ * cache their remote state and the frames would order workspaces the
2381
+ * local baseline would immediately erase. */
2382
+ const composedFrames = () => {
2383
+ if (!localSeen || !isWorkspace)
2384
+ return undefined;
2385
+ const parts = orderedRemoteParts();
2386
+ return composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote));
2387
+ };
2388
+ const pushCanonicalTrio = () => {
2389
+ const composed = composedFrames();
2390
+ if (composed !== undefined)
2391
+ pushOut([composed.order, composed.archived, composed.pinned]);
2392
+ };
2393
+ // -- current-session judging (T58/T62), across every slot ------------------
2394
+ let lastLang = resolveInterceptLang(deps.lang);
2395
+ const informCurrentSession = () => {
2396
+ const nextLang = resolveInterceptLang(deps.lang);
2397
+ if (nextLang !== lastLang) {
2398
+ lastLang = nextLang;
2399
+ if (isWorkspace) {
2400
+ let langUpserted = false;
2401
+ for (const slot of [...slots.values()]) {
2402
+ const merger = workspaceMergerOf(slot);
2403
+ if (merger === undefined)
2404
+ continue;
2405
+ const frames = merger.onStatusChanged();
2406
+ if (frames.length > 0) {
2407
+ pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'));
2408
+ langUpserted = true;
2409
+ }
2410
+ }
2411
+ if (langUpserted)
2412
+ pushCanonicalTrio();
2337
2413
  }
2338
- if (state !== 'online')
2339
- return;
2340
- const identity = relayIdentityOf(relay);
2341
- if (identity !== undefined)
2342
- fetchServerCatalog(identity);
2343
- });
2344
- // Install into an already-serving relay: subscribe replayed nothing, so
2345
- // the transition-based fetch would wait for the NEXT flap — fill the
2346
- // cache now instead (T52-fix3).
2347
- if (relay.state === 'online') {
2348
- const identity = relayIdentityOf(relay);
2349
- if (identity !== undefined)
2350
- fetchServerCatalog(identity);
2351
2414
  }
2352
- }
2353
- catch (error) {
2354
- // Subscription failed: the cache still fills whenever the UI opens a
2355
- // dropdown (the route's own fetch), only later.
2356
- log?.('client catalog watcher unavailable: %s', messageOf(error));
2357
- }
2358
- const registerClosed = (sessionId, reason) => {
2359
- closedSessions.delete(sessionId);
2360
- if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
2361
- const oldest = closedSessions.keys().next();
2362
- if (oldest.done !== true)
2363
- closedSessions.delete(oldest.value);
2415
+ const read = deps.getCurrentSession();
2416
+ if (read.kind === 'unavailable')
2417
+ return;
2418
+ let changed = false;
2419
+ for (const slot of [...slots.values()]) {
2420
+ if (slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined).length > 0)
2421
+ changed = true;
2364
2422
  }
2365
- closedSessions.set(sessionId, reason);
2423
+ if (!changed || !localSeen || !isWorkspace)
2424
+ return;
2425
+ const parts = orderedRemoteParts();
2426
+ pushOut([composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote)).archived]);
2366
2427
  };
2367
- const clearClosed = (virtuals) => {
2368
- let cleared = false;
2369
- for (const parts of virtuals) {
2370
- if (closedSessions.delete(toVirtual(parts.serverId, parts.id)))
2371
- cleared = true;
2428
+ const anyPendingHide = () => {
2429
+ for (const slot of [...slots.values()]) {
2430
+ if (slot.merger.hasPendingHide)
2431
+ return true;
2372
2432
  }
2373
- if (cleared)
2374
- for (const waiter of [...closedClearWaiters])
2375
- waiter();
2433
+ return false;
2376
2434
  };
2377
- let installed = true;
2378
- let selfCheck;
2379
- /**
2380
- * Reload overlay, for the record (CP4): the theoretical hazard is installing
2381
- * a NEW wrapper while an OLD one is still installed, so each captures the
2382
- * other and the older survives as a live layer. Cordis `fiber.restart()`
2383
- * disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
2384
- * restoring the saved property) and only then applies the new row — the
2385
- * overlay ordering never happens today, so there is nothing to absorb at
2386
- * install time. Detection is also not cheaply implementable: a reloaded row
2387
- * loads a FRESH module instance, so a stale wrapper shares no mark with the
2388
- * new one — telling "ours, from a dead generation" apart from another
2389
- * plugin's wrapper would take a process-wide registry, which the comment
2390
- * above argues is not worth it while the ordering cannot occur. The
2391
- * `installed === false → inert passthrough` rule below is the safety net
2392
- * for the ordering that CAN happen: a later plugin wrapping OVER us and
2393
- * keeping a reference past our uninstall.
2394
- *
2395
- * Save the CURRENT value per property (possibly another plugin's wrapper —
2396
- * shape note "own-property"), falling back to the prototype method only
2397
- * when the current value is not callable. Uninstall restores the saved
2398
- * own state verbatim: value if there was one, absence if not.
2399
- */
2400
- const saved = {};
2401
- const chainTarget = {};
2402
- for (const name of ['openWireStream', 'dispatchRpc']) {
2403
- const existed = Object.prototype.hasOwnProperty.call(gateway, name);
2404
- const value = existed ? gateway[name] : undefined;
2405
- saved[name] = { existed, value };
2406
- chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name];
2407
- }
2408
- // Filled in below, once the wrapper functions exist: uninstall may only
2409
- // touch a property that still holds OUR wrapper.
2410
- const wrappers = {};
2411
- const recordFailure = (endpoint, code) => {
2412
- if (failures.length >= MAX_FAILURES)
2413
- failures.shift();
2414
- failures.push({ time: new Date().toISOString(), endpoint, code });
2415
- // T42: validation-shaped refusals additionally land in the incompatible
2416
- // ring — every remote failure funnels through this one function, so the
2417
- // classification cannot drift between the invoke and stream routes.
2418
- if (!INCOMPATIBLE_CALL_CODES.has(code))
2435
+ let sessionPoll = undefined;
2436
+ const pollCurrentSession = () => {
2437
+ sessionPoll = undefined;
2438
+ if (!alive)
2419
2439
  return;
2420
- if (incompatible.length >= MAX_INCOMPATIBLE_CALLS)
2421
- incompatible.shift();
2422
- incompatible.push({ time: Date.now(), endpoint, code });
2440
+ if (anyPendingHide())
2441
+ informCurrentSession();
2442
+ if (alive && anyPendingHide())
2443
+ sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS);
2423
2444
  };
2424
- const failEnvelope = (endpoint, code, text) => {
2425
- recordFailure(endpoint, code);
2426
- // `details: {}` is load-bearing: the UI's server-response parser throws
2427
- // `invalid server-response failure` on a details-less error (and the
2428
- // gateway's rpcErrorSchema demands the same shape).
2429
- return { ok: false, error: { code, message: text, details: {} } };
2445
+ const ensureSessionPoll = () => {
2446
+ if (sessionPoll === undefined && alive && anyPendingHide()) {
2447
+ sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS);
2448
+ }
2430
2449
  };
2431
- function validate(endpoint, virtuals) {
2432
- const first = virtuals[0].serverId;
2433
- const current = getServerId();
2434
- if (current === undefined)
2435
- return { ok: false, code: 'remote-offline', message: '尚未连接到服务端' };
2436
- if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
2437
- return { ok: false, code: 'remote-mismatch', message: '此远程会话属于其他主服务端' };
2450
+ // -- slot lifecycle ----------------------------------------------------------
2451
+ const removeSlot = (serverId, slot) => {
2452
+ slots.delete(serverId);
2453
+ slot.removed = true;
2454
+ slot.offState();
2455
+ slot.controller?.abort();
2456
+ if (slot.inventory !== undefined) {
2457
+ slot.inventory.dispose();
2458
+ slot.inventory = undefined;
2438
2459
  }
2439
- if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
2440
- return { ok: false, code: 'remote-unsupported', message: '此功能暂不支持远程会话' };
2460
+ // Only THIS server's shown groups leave; the canonical trio then re-airs
2461
+ // the merged order/archived/pinned over the surviving slots (the
2462
+ // merger's own trailing local-only restores are superseded by it).
2463
+ if (!isWorkspace)
2464
+ return;
2465
+ const merger = slot.merger;
2466
+ const gone = merger.onRemoteGone();
2467
+ pushOut(gone.filter((frame) => isPlainObject(frame) && frame.type === 'remove'));
2468
+ pushCanonicalTrio();
2469
+ };
2470
+ const applySlotAnnotation = (slot, next) => {
2471
+ if (next === slot.annotation)
2472
+ return;
2473
+ slot.annotation = next;
2474
+ slot.merger.setStatus(next);
2475
+ if (!isWorkspace)
2476
+ return;
2477
+ const merger = slot.merger;
2478
+ const frames = merger.onStatusChanged();
2479
+ if (frames.length > 0) {
2480
+ pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'));
2481
+ pushCanonicalTrio();
2441
2482
  }
2442
- return { ok: true, serverId: first };
2443
- }
2444
- /**
2445
- * The T32 `$events/result` split: an answer whose eventId is VIRTUAL
2446
- * belongs to a forwarded remote event and must NOT reach the local
2447
- * gateway (its own `$events` never delivered it — the local dispatcher
2448
- * would answer with its `no active event stream` failure). The virtual
2449
- * id goes back to the original, the local payload's clientId (the local
2450
- * stream's own) is discarded — the relay server composes the gateway
2451
- * payload from ITS subscription's clientId — and the outcome rides
2452
- * verbatim. Anything else, including a malformed payload, is the local
2453
- * gateway's business.
2454
- */
2455
- function forwardEventResult(payload, signal, peer) {
2456
- const args = argsOf(payload);
2457
- const eventId = isPlainObject(args) ? args.eventId : undefined;
2458
- const virtual = fromVirtual(eventId);
2459
- if (virtual === undefined) {
2460
- return chainTarget.dispatchRpc.call(raw, '$events/result', payload, signal, peer);
2483
+ };
2484
+ const refreshOneViewName = (slot, nextName) => {
2485
+ slot.viewName = nextName;
2486
+ slot.merger.retarget({ serverId: slot.serverId, serverName: nextName });
2487
+ if (!isWorkspace)
2488
+ return;
2489
+ const merger = slot.merger;
2490
+ const frames = merger.onServerRenamed();
2491
+ if (frames.length > 0) {
2492
+ pushOut(frames);
2493
+ pushCanonicalTrio();
2461
2494
  }
2462
- const endpoint = '$events/result';
2463
- const current = getServerId();
2464
- // Offline (T34-fix): a refusal envelope would RESTART the UI's whole
2465
- // `$events` generation — the client face's answer() throws on a
2466
- // `!response.ok` body and the pump treats that as a delivery failure
2467
- // (RT dsh-api-gateway lib/client.js: `if (!response.ok) throw new
2495
+ };
2496
+ const refreshViewNames = () => {
2497
+ for (const entry of pool.entries()) {
2498
+ const slot = entry.serverId !== undefined ? slots.get(entry.serverId) : undefined;
2499
+ if (slot !== undefined && slot.viewName !== entry.displayName)
2500
+ refreshOneViewName(slot, entry.displayName);
2501
+ }
2502
+ };
2503
+ const pumpSlot = async (slot) => {
2504
+ // Whole body guarded: ANY escape stops THIS server's leg only.
2505
+ try {
2506
+ while (alive && !slot.removed) {
2507
+ if (slot.client.state !== 'online') {
2508
+ await waitAny();
2509
+ continue;
2510
+ }
2511
+ const controller = new AbortController();
2512
+ slot.controller = controller;
2513
+ try {
2514
+ const stream = slot.client.openStream(namespace, method, {}, controller.signal);
2515
+ let served = false;
2516
+ for await (const frame of stream) {
2517
+ // Post-abort lines the transport already decoded must not reach
2518
+ // the merger (same discipline as the single-server pumps).
2519
+ if (!alive || slot.removed || controller.signal.aborted)
2520
+ break;
2521
+ if (!served) {
2522
+ served = true;
2523
+ slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS;
2524
+ }
2525
+ informCurrentSession();
2526
+ const out = slot.merger.onRemote(frame);
2527
+ if (out.length > 0) {
2528
+ if (isWorkspace) {
2529
+ pushOut(out.filter((outFrame) => isPlainObject(outFrame) && (outFrame.type === 'upsert' || outFrame.type === 'remove')));
2530
+ pushCanonicalTrio();
2531
+ }
2532
+ else {
2533
+ pushOut(out);
2534
+ }
2535
+ }
2536
+ ensureSessionPoll();
2537
+ }
2538
+ }
2539
+ catch (error) {
2540
+ if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
2541
+ recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal');
2542
+ }
2543
+ }
2544
+ finally {
2545
+ if (slot.controller === controller)
2546
+ slot.controller = undefined;
2547
+ }
2548
+ if (!alive || slot.removed)
2549
+ return;
2550
+ slot.merger.onRemoteDown();
2551
+ if (slot.client.state === 'online') {
2552
+ await waitReopen(clock, waitAny, slot.reopenDelayMs);
2553
+ slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS);
2554
+ continue;
2555
+ }
2556
+ await waitAny();
2557
+ }
2558
+ }
2559
+ catch (error) {
2560
+ if (alive)
2561
+ onDiagnostic(`remote leg of ${slot.serverId} stopped: ${messageOf(error)}`);
2562
+ }
2563
+ };
2564
+ /** Re-read pool.entries(): create slots for new handshook servers (in
2565
+ * pairing order), refresh display names, remove slots whose server left.
2566
+ * Also heals a re-handshake that renamed the entry's serverId. */
2567
+ const scanSlots = () => {
2568
+ const seen = new Set();
2569
+ for (const entry of pool.entries()) {
2570
+ const serverId = entry.serverId;
2571
+ if (serverId === undefined)
2572
+ continue;
2573
+ seen.add(serverId);
2574
+ const existing = slots.get(serverId);
2575
+ if (existing !== undefined) {
2576
+ if (existing.viewName !== entry.displayName)
2577
+ refreshOneViewName(existing, entry.displayName);
2578
+ continue;
2579
+ }
2580
+ const inventory = isWorkspace && deps.createSessionInventorySlot !== undefined
2581
+ ? deps.createSessionInventorySlot(serverId)
2582
+ : undefined;
2583
+ const merger = isWorkspace
2584
+ ? createWorkspaceMerger({
2585
+ serverId,
2586
+ serverName: entry.displayName,
2587
+ onDiagnostic,
2588
+ now: clock.now,
2589
+ lang: deps.lang,
2590
+ ...(inventory !== undefined ? { onKnownSessions: (ids) => inventory.replace(ids) } : {}),
2591
+ })
2592
+ : createControlMerger({ serverId, serverName: entry.displayName, onDiagnostic });
2593
+ const slot = {
2594
+ kind: isWorkspace ? 'workspace' : 'control',
2595
+ serverId,
2596
+ entryId: entry.entryId,
2597
+ client: entry.client,
2598
+ merger,
2599
+ viewName: entry.displayName,
2600
+ controller: undefined,
2601
+ offState: () => { },
2602
+ annotation: annotationOf(entry.client),
2603
+ reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
2604
+ removed: false,
2605
+ inventory,
2606
+ };
2607
+ merger.setStatus(slot.annotation);
2608
+ slot.offState = slot.client.subscribe((state) => {
2609
+ try {
2610
+ // The set itself may have changed (a dedupe renamed the entry's
2611
+ // serverId, a rename moved the display name) — re-scan first.
2612
+ scanSlots();
2613
+ if (state === 'unpaired' || state === 'revoked') {
2614
+ applySlotAnnotation(slot, annotationOf(slot.client));
2615
+ slot.merger.onRemoteDown();
2616
+ slot.controller?.abort();
2617
+ return;
2618
+ }
2619
+ applySlotAnnotation(slot, annotationOf(slot.client));
2620
+ if (state === 'online')
2621
+ refreshViewNames();
2622
+ }
2623
+ catch (error) {
2624
+ log?.('client multi merge slot state handler failed on %s: %s', serverId, messageOf(error));
2625
+ }
2626
+ finally {
2627
+ flushWaiters();
2628
+ }
2629
+ });
2630
+ slots.set(serverId, slot);
2631
+ // A slot born mid-stream missed the local baseline — replay it (its
2632
+ // output is discarded; the replay only sets the merger's localSeen and
2633
+ // local lists). Runs BEFORE the pump starts, so the buffer is empty.
2634
+ if (localBaselineFrame !== undefined) {
2635
+ for (const out of slot.merger.onLocal(localBaselineFrame))
2636
+ void out;
2637
+ }
2638
+ void pumpSlot(slot);
2639
+ }
2640
+ for (const [serverId, slot] of [...slots]) {
2641
+ if (!seen.has(serverId))
2642
+ removeSlot(serverId, slot);
2643
+ }
2644
+ };
2645
+ // -- local frames -------------------------------------------------------------
2646
+ const pushLocalWorkspace = (frame) => {
2647
+ localLists = localWorkspaceLists(frame, localLists);
2648
+ // Every slot's merger must observe the local frames too — its localSeen
2649
+ // gate (nothing leaves its onRemote before the local baseline passed)
2650
+ // and its own local lists live there. Its OUTPUT is discarded: the
2651
+ // single-server global frames (its merged order/archived/pinned) are
2652
+ // superseded by the canonical composition over ALL slots.
2653
+ for (const slot of [...slots.values()]) {
2654
+ for (const out of slot.merger.onLocal(frame))
2655
+ void out;
2656
+ }
2657
+ if (!isPlainObject(frame)) {
2658
+ pushOut([frame]);
2659
+ return;
2660
+ }
2661
+ if (frame.type === 'baseline') {
2662
+ localSeen = true;
2663
+ localBaselineFrame = frame;
2664
+ const parts = orderedRemoteParts();
2665
+ const remotes = parts.map((part) => part.remote);
2666
+ const hasRemote = remotes.some((remote) => remote.orderIds.length + remote.archivedIds.length + remote.pinnedIds.length + remote.workspaces.length > 0);
2667
+ if (!hasRemote) {
2668
+ pushOut([frame]);
2669
+ return;
2670
+ }
2671
+ const value = isPlainObject(frame.value) ? frame.value : {};
2672
+ const items = Array.isArray(value.items) ? value.items : [];
2673
+ const composed = composeMergedWorkspaceFrames(localLists, remotes);
2674
+ pushOut([
2675
+ {
2676
+ ...frame,
2677
+ value: {
2678
+ ...value,
2679
+ items: [...items, ...composed.workspaces],
2680
+ archivedSessionIds: composed.archived.archivedSessionIds,
2681
+ pinnedSessionIds: composed.pinned.pinnedSessionIds,
2682
+ },
2683
+ },
2684
+ ...composed.workspaces.map((workspace) => ({ type: 'upsert', workspace })),
2685
+ ]);
2686
+ return;
2687
+ }
2688
+ const remotes = orderedRemoteParts().map((part) => part.remote);
2689
+ if (frame.type === 'order') {
2690
+ if (!remotes.some((remote) => remote.orderIds.length > 0)) {
2691
+ pushOut([frame]);
2692
+ return;
2693
+ }
2694
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).order]);
2695
+ return;
2696
+ }
2697
+ if (frame.type === 'archived') {
2698
+ if (!remotes.some((remote) => remote.archivedIds.length > 0)) {
2699
+ pushOut([frame]);
2700
+ return;
2701
+ }
2702
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).archived]);
2703
+ return;
2704
+ }
2705
+ if (frame.type === 'pinned') {
2706
+ if (!remotes.some((remote) => remote.pinnedIds.length > 0)) {
2707
+ pushOut([frame]);
2708
+ return;
2709
+ }
2710
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).pinned]);
2711
+ return;
2712
+ }
2713
+ // upsert / remove / anything else local: purely local facts.
2714
+ pushOut([frame]);
2715
+ };
2716
+ const pushLocalControl = (frame) => {
2717
+ if (isPlainObject(frame) && frame.type === 'baseline') {
2718
+ pushOut([frame]);
2719
+ // Each slot's buffered pre-baseline projections flush behind it (the
2720
+ // slots' own baseline copies are the SAME frame object — skip those).
2721
+ for (const slot of [...slots.values()]) {
2722
+ for (const out of slot.merger.onLocal(frame)) {
2723
+ if (out !== frame)
2724
+ pushOut([out]);
2725
+ }
2726
+ }
2727
+ return;
2728
+ }
2729
+ pushOut([frame]);
2730
+ for (const slot of [...slots.values()])
2731
+ slot.merger.onLocal(frame);
2732
+ };
2733
+ const offCurrentSession = deps.watchCurrentSession(informCurrentSession);
2734
+ const offUninstall = deps.watchUninstall(() => {
2735
+ try {
2736
+ const read = deps.getCurrentSession();
2737
+ for (const slot of [...slots.values()]) {
2738
+ slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined);
2739
+ applySlotAnnotation(slot, 'unpaired');
2740
+ slot.merger.onRemoteDown();
2741
+ slot.controller?.abort();
2742
+ }
2743
+ }
2744
+ catch {
2745
+ // ignore teardown errors
2746
+ }
2747
+ finally {
2748
+ flushWaiters();
2749
+ }
2750
+ });
2751
+ const offPool = deps.watchPool(() => {
2752
+ try {
2753
+ scanSlots();
2754
+ refreshViewNames();
2755
+ }
2756
+ catch (error) {
2757
+ log?.('client multi merge pool rescan failed on %s: %s', endpoint, messageOf(error));
2758
+ }
2759
+ finally {
2760
+ flushWaiters();
2761
+ }
2762
+ });
2763
+ const onExternalAbort = () => {
2764
+ alive = false;
2765
+ for (const slot of [...slots.values()])
2766
+ slot.controller?.abort();
2767
+ flushWaiters();
2768
+ channel.end();
2769
+ };
2770
+ if (signal !== undefined)
2771
+ signal.addEventListener('abort', onExternalAbort);
2772
+ scanSlots();
2773
+ informCurrentSession();
2774
+ const localIterator = local[Symbol.asyncIterator]();
2775
+ const pumpLocal = async () => {
2776
+ try {
2777
+ while (alive) {
2778
+ const result = await localIterator.next();
2779
+ if (result.done === true)
2780
+ break;
2781
+ informCurrentSession();
2782
+ if (isWorkspace)
2783
+ pushLocalWorkspace(result.value);
2784
+ else
2785
+ pushLocalControl(result.value);
2786
+ ensureSessionPoll();
2787
+ }
2788
+ }
2789
+ catch (error) {
2790
+ // The local stream is the one the UI opened: its failure is the
2791
+ // merged stream's failure (a remote death never is).
2792
+ if (alive)
2793
+ channel.fail(error);
2794
+ return;
2795
+ }
2796
+ if (!alive)
2797
+ return;
2798
+ alive = false;
2799
+ for (const slot of [...slots.values()])
2800
+ slot.controller?.abort();
2801
+ flushWaiters();
2802
+ channel.end();
2803
+ };
2804
+ void pumpLocal();
2805
+ try {
2806
+ while (true) {
2807
+ const result = await channel.next();
2808
+ if (result.done === true)
2809
+ break;
2810
+ yield result.value;
2811
+ }
2812
+ }
2813
+ finally {
2814
+ alive = false;
2815
+ if (sessionPoll !== undefined)
2816
+ clock.clearTimeout(sessionPoll);
2817
+ offCurrentSession();
2818
+ offUninstall();
2819
+ offPool();
2820
+ if (signal !== undefined)
2821
+ signal.removeEventListener('abort', onExternalAbort);
2822
+ for (const slot of [...slots.values()]) {
2823
+ slot.removed = true;
2824
+ slot.offState();
2825
+ slot.controller?.abort();
2826
+ slot.inventory?.dispose();
2827
+ }
2828
+ slots.clear();
2829
+ flushWaiters();
2830
+ void Promise.resolve(localIterator.return?.(undefined)).catch(() => { });
2831
+ }
2832
+ }
2833
+ /**
2834
+ * The pool-mode merged `$events` stream: one `$zr/events` leg per pooled
2835
+ * server, each with its own orphan-close record. The T52 catalog refresh
2836
+ * fires whenever any slot's serving identity appears, changes, or leaves;
2837
+ * the T63/T65 summary sync and the T75 status sync fetch from the OWNING
2838
+ * server of each id (virtual ids carry the serverId), per slot on its own
2839
+ * serving periods.
2840
+ */
2841
+ async function* mergedEventsStreamMulti(deps) {
2842
+ const { local, pool, signal, recordFailure, log, clock } = deps;
2843
+ if (signal?.aborted === true)
2844
+ return;
2845
+ const channel = createFrameChannel();
2846
+ let alive = true;
2847
+ const slots = new Map();
2848
+ const onlineWaiters = [];
2849
+ const flushWaiters = () => {
2850
+ for (const wake of onlineWaiters.splice(0))
2851
+ wake();
2852
+ };
2853
+ const waitAny = async () => {
2854
+ await new Promise((resolve) => {
2855
+ onlineWaiters.push(resolve);
2856
+ });
2857
+ };
2858
+ let localOpened = false;
2859
+ let deferredRefresh = false;
2860
+ const pushRefresh = () => {
2861
+ if (!localOpened) {
2862
+ deferredRefresh = true;
2863
+ return;
2864
+ }
2865
+ channel.push(catalogRefreshFrame());
2866
+ };
2867
+ const noteSlotIdentity = (slot) => {
2868
+ const key = slot.client.state === 'online' ? `${slot.serverId}\u0000${slot.displayName}` : undefined;
2869
+ if (key === slot.notedIdentity)
2870
+ return;
2871
+ slot.notedIdentity = key;
2872
+ if (key !== undefined)
2873
+ pushRefresh();
2874
+ };
2875
+ // -- T63/T65/T75 bookkeeping: global maps over VIRTUAL ids (globally
2876
+ // unique across servers), per-slot fetch rounds. Same discipline as the
2877
+ // single-server stream — see mergedEventsStream for the full story.
2878
+ const announced = new Set();
2879
+ const askAttempts = new Map();
2880
+ const askedForGood = new Set();
2881
+ const retryAt = new Map();
2882
+ const MAX_ASK_ATTEMPTS = 3;
2883
+ const ASK_RETRY_FIRST_MS = 2_000;
2884
+ const ASK_RETRY_SECOND_MS = 10_000;
2885
+ let retryTimer = undefined;
2886
+ let midFlightBatches = [];
2887
+ let askingNow = [];
2888
+ let summarySyncInFlight = false;
2889
+ let deferredSummarySync = false;
2890
+ const latestLiveStatus = new Map();
2891
+ let statusSeq = 0;
2892
+ const liveStatusSeq = new Map();
2893
+ const scheduleRetry = () => {
2894
+ if (retryTimer !== undefined || retryAt.size === 0)
2895
+ return;
2896
+ const earliest = Math.min(...retryAt.values());
2897
+ retryTimer = clock.setTimeout(() => {
2898
+ retryTimer = undefined;
2899
+ if (!alive)
2900
+ return;
2901
+ const nowMs = clock.now();
2902
+ const due = [];
2903
+ for (const [id, at] of retryAt) {
2904
+ if (at <= nowMs) {
2905
+ retryAt.delete(id);
2906
+ due.push(id);
2907
+ }
2908
+ }
2909
+ if (due.length > 0)
2910
+ runSummarySync(due);
2911
+ }, Math.max(0, earliest - clock.now()));
2912
+ };
2913
+ const runSummarySync = (triggerIds = []) => {
2914
+ if (!alive)
2915
+ return;
2916
+ if (!localOpened) {
2917
+ deferredSummarySync = true;
2918
+ return;
2919
+ }
2920
+ if (summarySyncInFlight) {
2921
+ midFlightBatches.push(...triggerIds);
2922
+ return;
2923
+ }
2924
+ const rounds = [];
2925
+ if (triggerIds.length === 0) {
2926
+ for (const slot of [...slots.values()]) {
2927
+ if (slot.client.state === 'online')
2928
+ rounds.push({ slot, ids: [] });
2929
+ }
2930
+ }
2931
+ else {
2932
+ for (const id of triggerIds) {
2933
+ const parts = fromVirtual(id);
2934
+ if (parts === undefined)
2935
+ continue;
2936
+ const slot = slots.get(parts.serverId);
2937
+ if (slot === undefined || slot.client.state !== 'online')
2938
+ continue;
2939
+ let round = rounds.find((candidate) => candidate.slot === slot);
2940
+ if (round === undefined) {
2941
+ round = { slot, ids: [] };
2942
+ rounds.push(round);
2943
+ }
2944
+ round.ids.push(id);
2945
+ }
2946
+ }
2947
+ if (rounds.length === 0)
2948
+ return;
2949
+ summarySyncInFlight = true;
2950
+ askingNow = [...triggerIds];
2951
+ for (const round of rounds) {
2952
+ for (const id of round.ids)
2953
+ askAttempts.set(id, (askAttempts.get(id) ?? 0) + 1);
2954
+ }
2955
+ void Promise.all(rounds.map((round) => round.slot.client
2956
+ .invoke('session', 'list', { _request: {} }, undefined)
2957
+ .then((value) => ({ round, value, error: undefined }), (error) => ({ round, value: undefined, error })))).then((results) => {
2958
+ summarySyncInFlight = false;
2959
+ if (!alive)
2960
+ return;
2961
+ askingNow = [];
2962
+ for (const result of results) {
2963
+ if (result.error !== undefined) {
2964
+ recordFailure('session/list', result.error instanceof RelayError ? result.error.code : 'internal');
2965
+ continue;
2966
+ }
2967
+ const frames = sessionSummaryAddedFrames(result.value, result.round.slot.serverId);
2968
+ const returned = new Set();
2969
+ for (const frame of frames) {
2970
+ const row = frame.args[0];
2971
+ const rowId = isPlainObject(row) && typeof row.sessionId === 'string' ? row.sessionId : undefined;
2972
+ if (rowId === undefined)
2973
+ continue;
2974
+ returned.add(rowId);
2975
+ if (announced.has(rowId))
2976
+ continue;
2977
+ announced.add(rowId);
2978
+ channel.push(frame);
2979
+ const liveRunning = latestLiveStatus.get(rowId);
2980
+ if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
2981
+ channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] });
2982
+ }
2983
+ }
2984
+ for (const id of result.round.ids) {
2985
+ if (returned.has(id)) {
2986
+ retryAt.delete(id);
2987
+ continue;
2988
+ }
2989
+ const attempts = askAttempts.get(id) ?? 0;
2990
+ if (attempts >= MAX_ASK_ATTEMPTS)
2991
+ askedForGood.add(id);
2992
+ else
2993
+ retryAt.set(id, clock.now() + (attempts <= 1 ? ASK_RETRY_FIRST_MS : ASK_RETRY_SECOND_MS));
2994
+ }
2995
+ }
2996
+ scheduleRetry();
2997
+ const queued = midFlightBatches;
2998
+ midFlightBatches = [];
2999
+ if (queued.length > 0)
3000
+ runSummarySync(queued);
3001
+ });
3002
+ };
3003
+ const handleForwardedBatch = (ids) => {
3004
+ if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id)))
3005
+ return;
3006
+ runSummarySync(ids);
3007
+ };
3008
+ /** T75 per slot: re-push every row's running flag from a fresh snapshot
3009
+ * of THIS server when its serving period starts. */
3010
+ const runStatusSync = (slot) => {
3011
+ if (!alive)
3012
+ return;
3013
+ if (!localOpened) {
3014
+ slot.statusDeferred = true;
3015
+ return;
3016
+ }
3017
+ if (slot.statusInFlight) {
3018
+ slot.statusPending = true;
3019
+ return;
3020
+ }
3021
+ if (slot.client.state !== 'online')
3022
+ return;
3023
+ slot.statusInFlight = true;
3024
+ // The race guard arms here (see the single-server stream): only frames
3025
+ // arriving after this point outrank the snapshot — older records have a
3026
+ // lower seq than startSeq and never block.
3027
+ const startSeq = ++statusSeq;
3028
+ void slot.client.invoke('session', 'list', { _request: {} }, undefined).then((value) => {
3029
+ slot.statusInFlight = false;
3030
+ if (!alive)
3031
+ return;
3032
+ const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : [];
3033
+ for (const row of rows) {
3034
+ if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '')
3035
+ continue;
3036
+ if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq)
3037
+ continue;
3038
+ channel.push({
3039
+ type: 'emit',
3040
+ event: 'api-session/status',
3041
+ args: [toVirtual(slot.serverId, row.sessionId), row.running === true],
3042
+ });
3043
+ }
3044
+ if (slot.statusPending) {
3045
+ slot.statusPending = false;
3046
+ runStatusSync(slot);
3047
+ }
3048
+ }, (error) => {
3049
+ slot.statusInFlight = false;
3050
+ if (!alive)
3051
+ return;
3052
+ recordFailure('session/list', error instanceof RelayError ? error.code : 'internal');
3053
+ if (slot.statusPending) {
3054
+ slot.statusPending = false;
3055
+ runStatusSync(slot);
3056
+ }
3057
+ });
3058
+ };
3059
+ /** One slot's fresh serving period: the ask bookkeeping gets another
3060
+ * chance, and this server's rows are re-synced (statuses first). */
3061
+ const noteFirstOnline = (slot) => {
3062
+ askedForGood.clear();
3063
+ askAttempts.clear();
3064
+ retryAt.clear();
3065
+ latestLiveStatus.clear();
3066
+ if (retryTimer !== undefined) {
3067
+ clock.clearTimeout(retryTimer);
3068
+ retryTimer = undefined;
3069
+ }
3070
+ runStatusSync(slot);
3071
+ runSummarySync();
3072
+ };
3073
+ const pumpSlot = async (slot) => {
3074
+ try {
3075
+ while (alive && !slot.removed) {
3076
+ if (slot.client.state !== 'online') {
3077
+ await waitAny();
3078
+ continue;
3079
+ }
3080
+ const controller = new AbortController();
3081
+ slot.controller = controller;
3082
+ try {
3083
+ const stream = slot.client.openStream('$zr', 'events', {}, controller.signal);
3084
+ let served = false;
3085
+ for await (const frame of stream) {
3086
+ if (!alive || slot.removed || controller.signal.aborted)
3087
+ break;
3088
+ if (!served) {
3089
+ served = true;
3090
+ slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS;
3091
+ }
3092
+ const rewritten = rewriteRemoteEventFrame(frame, slot.serverId);
3093
+ if (rewritten === null)
3094
+ continue;
3095
+ // T75 race guard + PB-26a live status, exactly as the single
3096
+ // server stream keeps them — keyed by ids that are already
3097
+ // globally unique (virtual or original-then-virtualized).
3098
+ if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
3099
+ const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined;
3100
+ if (typeof rowId === 'string' && rowId !== '')
3101
+ liveStatusSeq.set(rowId, ++statusSeq);
3102
+ const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined;
3103
+ const virtualId = args?.[0];
3104
+ const running = args?.[1];
3105
+ if (typeof virtualId === 'string' && typeof running === 'boolean') {
3106
+ latestLiveStatus.set(virtualId, running);
3107
+ if (!announced.has(virtualId))
3108
+ handleForwardedBatch([virtualId]);
3109
+ }
3110
+ }
3111
+ const eventId = isPlainObject(rewritten) && typeof rewritten.eventId === 'string' ? rewritten.eventId : undefined;
3112
+ if (eventId !== undefined) {
3113
+ if (isPlainObject(frame) && frame.type === 'waterfall') {
3114
+ if (slot.shownEvents.has(eventId))
3115
+ continue;
3116
+ slot.shownEvents.add(eventId);
3117
+ }
3118
+ else if (isPlainObject(frame) && frame.type === 'cancel') {
3119
+ slot.shownEvents.delete(eventId);
3120
+ }
3121
+ }
3122
+ channel.push(rewritten);
3123
+ }
3124
+ }
3125
+ catch (error) {
3126
+ if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
3127
+ recordFailure('$zr/events', error instanceof RelayError ? error.code : 'internal');
3128
+ }
3129
+ }
3130
+ finally {
3131
+ if (slot.controller === controller)
3132
+ slot.controller = undefined;
3133
+ }
3134
+ // THIS leg is over — close only ITS prompts. A disconnect is not a
3135
+ // verdict; the reopen re-delivers still-pending events under the
3136
+ // SAME ids (the server prefixes them per subscription, so the
3137
+ // virtual ids differ — DSH shows them fresh, the old cancels already
3138
+ // closed the stale prompts).
3139
+ for (const eventId of slot.shownEvents)
3140
+ channel.push({ type: 'cancel', eventId });
3141
+ slot.shownEvents = new Set();
3142
+ if (!alive || slot.removed)
3143
+ return;
3144
+ if (slot.client.state === 'online') {
3145
+ await waitReopen(clock, waitAny, slot.reopenDelayMs);
3146
+ slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS);
3147
+ continue;
3148
+ }
3149
+ await waitAny();
3150
+ }
3151
+ }
3152
+ catch (error) {
3153
+ if (alive)
3154
+ log?.('client events remote leg of %s stopped: %s', slot.serverId, messageOf(error));
3155
+ }
3156
+ };
3157
+ const scanSlots = () => {
3158
+ const seen = new Set();
3159
+ for (const entry of pool.entries()) {
3160
+ const serverId = entry.serverId;
3161
+ if (serverId === undefined)
3162
+ continue;
3163
+ seen.add(serverId);
3164
+ const existing = slots.get(serverId);
3165
+ if (existing !== undefined) {
3166
+ if (existing.displayName !== entry.displayName) {
3167
+ existing.displayName = entry.displayName;
3168
+ noteSlotIdentity(existing);
3169
+ }
3170
+ continue;
3171
+ }
3172
+ const slot = {
3173
+ serverId,
3174
+ client: entry.client,
3175
+ controller: undefined,
3176
+ offState: () => { },
3177
+ shownEvents: new Set(),
3178
+ reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
3179
+ removed: false,
3180
+ displayName: entry.displayName,
3181
+ notedIdentity: undefined,
3182
+ stateSeen: undefined,
3183
+ statusInFlight: false,
3184
+ statusPending: false,
3185
+ statusDeferred: false,
3186
+ };
3187
+ slot.offState = slot.client.subscribe((state) => {
3188
+ try {
3189
+ const firstOnline = state === 'online' && slot.stateSeen !== 'online';
3190
+ slot.stateSeen = state;
3191
+ if (state === 'unpaired' || state === 'revoked') {
3192
+ slot.controller?.abort();
3193
+ noteSlotIdentity(slot);
3194
+ return;
3195
+ }
3196
+ noteSlotIdentity(slot);
3197
+ if (state === 'online' && firstOnline)
3198
+ noteFirstOnline(slot);
3199
+ }
3200
+ catch (error) {
3201
+ log?.('client events merge slot state handler failed on %s: %s', serverId, messageOf(error));
3202
+ }
3203
+ finally {
3204
+ flushWaiters();
3205
+ }
3206
+ });
3207
+ slots.set(serverId, slot);
3208
+ void pumpSlot(slot);
3209
+ // Install into an already-serving server: subscribe replayed nothing.
3210
+ if (slot.client.state === 'online') {
3211
+ slot.stateSeen = 'online';
3212
+ noteSlotIdentity(slot);
3213
+ noteFirstOnline(slot);
3214
+ }
3215
+ }
3216
+ for (const [serverId, slot] of [...slots]) {
3217
+ if (!seen.has(serverId)) {
3218
+ slots.delete(serverId);
3219
+ slot.removed = true;
3220
+ slot.offState();
3221
+ slot.controller?.abort();
3222
+ // Only THIS server's pending prompts get the synthesized cancels.
3223
+ for (const eventId of slot.shownEvents)
3224
+ channel.push({ type: 'cancel', eventId });
3225
+ slot.shownEvents.clear();
3226
+ }
3227
+ }
3228
+ };
3229
+ const offSyncRequest = deps.summarySync.onForwardedSessionIds(handleForwardedBatch);
3230
+ const offPool = deps.watchPool(() => {
3231
+ try {
3232
+ scanSlots();
3233
+ }
3234
+ catch (error) {
3235
+ log?.('client events merge pool rescan failed: %s', messageOf(error));
3236
+ }
3237
+ finally {
3238
+ flushWaiters();
3239
+ }
3240
+ });
3241
+ const onExternalAbort = () => {
3242
+ alive = false;
3243
+ for (const slot of [...slots.values()])
3244
+ slot.controller?.abort();
3245
+ flushWaiters();
3246
+ channel.end();
3247
+ };
3248
+ if (signal !== undefined)
3249
+ signal.addEventListener('abort', onExternalAbort);
3250
+ scanSlots();
3251
+ const localIterator = local[Symbol.asyncIterator]();
3252
+ const pumpLocal = async () => {
3253
+ try {
3254
+ while (alive) {
3255
+ const result = await localIterator.next();
3256
+ if (result.done === true)
3257
+ break;
3258
+ channel.push(result.value);
3259
+ if (!localOpened) {
3260
+ localOpened = true;
3261
+ if (deferredRefresh) {
3262
+ deferredRefresh = false;
3263
+ channel.push(catalogRefreshFrame());
3264
+ }
3265
+ const pendingBatch = deps.summarySync.consumePendingRequest();
3266
+ if (deferredSummarySync || pendingBatch.length > 0) {
3267
+ deferredSummarySync = false;
3268
+ runSummarySync(pendingBatch);
3269
+ }
3270
+ for (const slot of [...slots.values()]) {
3271
+ if (slot.statusDeferred) {
3272
+ slot.statusDeferred = false;
3273
+ runStatusSync(slot);
3274
+ }
3275
+ }
3276
+ }
3277
+ }
3278
+ }
3279
+ catch (error) {
3280
+ if (alive)
3281
+ channel.fail(error);
3282
+ return;
3283
+ }
3284
+ if (!alive)
3285
+ return;
3286
+ alive = false;
3287
+ for (const slot of [...slots.values()])
3288
+ slot.controller?.abort();
3289
+ flushWaiters();
3290
+ channel.end();
3291
+ };
3292
+ void pumpLocal();
3293
+ try {
3294
+ while (true) {
3295
+ const result = await channel.next();
3296
+ if (result.done === true)
3297
+ break;
3298
+ yield result.value;
3299
+ }
3300
+ }
3301
+ finally {
3302
+ alive = false;
3303
+ if (retryTimer !== undefined)
3304
+ clock.clearTimeout(retryTimer);
3305
+ offSyncRequest();
3306
+ offPool();
3307
+ if (signal !== undefined)
3308
+ signal.removeEventListener('abort', onExternalAbort);
3309
+ for (const slot of [...slots.values()]) {
3310
+ slot.removed = true;
3311
+ slot.offState();
3312
+ slot.controller?.abort();
3313
+ }
3314
+ slots.clear();
3315
+ flushWaiters();
3316
+ void Promise.resolve(localIterator.return?.(undefined)).catch(() => { });
3317
+ }
3318
+ }
3319
+ /**
3320
+ * Install the two own-property wrappers on the raw gateway. Assumes
3321
+ * {@link checkGatewayShape} passed (the wiring gates on it) — this function
3322
+ * records the verdict but does not re-gate, so it stays usable in tests
3323
+ * that install over deliberately odd shapes.
3324
+ */
3325
+ /**
3326
+ * P55-2: whether `filePath` lives at or under `cwd` — the /api/file
3327
+ * attribution test. Both paths are compared backslash-folded to `/` (a
3328
+ * server-Windows cwd meets a server-Windows image path, and the Markdown
3329
+ * renderer may hand either separator over), trailing separators of `cwd`
3330
+ * are dropped, and the match is exact-or-subpath (`cwd + '/'` prefix — a
3331
+ * directory merely PREFIXED by the cwd string is not a match). Windows
3332
+ * drive-rooted pairs compare case-insensitively WHOLE (`C:\Users` vs
3333
+ * `c:\users` — the drive letter is the visible case hazard, but NTFS is
3334
+ * case-insensitive end to end, and a missed match costs a permanently
3335
+ * broken image while an over-match needs two same-server cwds differing
3336
+ * only by case); everything else stays case-sensitive (POSIX is).
3337
+ * Deliberately NOT node:path — the two ends may run on different platforms,
3338
+ * so the rule is pure string arithmetic over the server's paths.
3339
+ */
3340
+ export function pathMatchesCwd(filePath, cwd) {
3341
+ const slashify = (value) => value.replaceAll('\\', '/');
3342
+ const file = slashify(filePath);
3343
+ let root = slashify(cwd);
3344
+ while (root.length > 1 && root.endsWith('/'))
3345
+ root = root.slice(0, -1);
3346
+ if (root === '')
3347
+ return false;
3348
+ const matches = (f, r) => f === r || f.startsWith(`${r}/`);
3349
+ if (matches(file, root))
3350
+ return true;
3351
+ if (/^[a-z]:/i.test(root) && /^[a-z]:/i.test(file))
3352
+ return matches(file.toLowerCase(), root.toLowerCase());
3353
+ return false;
3354
+ }
3355
+ export function installIntercept(options) {
3356
+ const { raw, log } = options;
3357
+ const clock = options.clock ?? defaultClock;
3358
+ // MS-3: exactly one mode — the pool when given, the single row client
3359
+ // otherwise. Installing with neither is a wiring bug; refuse loudly at
3360
+ // install instead of crashing on the first remote call.
3361
+ const pool = options.pool;
3362
+ const relay = options.relay;
3363
+ const getServerId = options.getServerId;
3364
+ if (pool === undefined && (relay === undefined || getServerId === undefined)) {
3365
+ throw new Error('installIntercept requires either `relay` + `getServerId` (single server) or `pool`');
3366
+ }
3367
+ // The single-server facts, proven present by the check above. The
3368
+ // single-server code paths read THESE (never the optional originals), so
3369
+ // the old behavior stays byte-identical without a narrowing dance.
3370
+ const singleRelay = relay;
3371
+ const singleGetServerId = getServerId;
3372
+ // T63: the summary-sync hub shared by the merged streams — the workspace
3373
+ // route nudges, the events leg fetches-and-announces. One per install: it
3374
+ // outlives individual stream generations so a nudge from a workspace leg
3375
+ // reaches whichever $events generation is open.
3376
+ const summarySync = createSessionSummarySync();
3377
+ // PB-25b: the inventory slot factory (the wiring's union feed) — passed
3378
+ // down to every merged stream, which owns one slot for its lifetime.
3379
+ const createSessionInventorySlot = options.createSessionInventorySlot;
3380
+ // MS-3: the pool-event hub behind handle.notePoolEvent() — the wiring
3381
+ // forwards the pool's onStateChange/onServerRemoved callbacks here, and
3382
+ // every live merged stream (plus the pool-mode catalog watcher below)
3383
+ // re-scans on the broadcast.
3384
+ const poolListeners = new Set();
3385
+ const watchPool = (fn) => {
3386
+ poolListeners.add(fn);
3387
+ return () => {
3388
+ poolListeners.delete(fn);
3389
+ };
3390
+ };
3391
+ // T62: the current-session getter and the live-merged-stream listener
3392
+ // registry behind handle.currentSessionChanged().
3393
+ const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }));
3394
+ const currentSessionListeners = new Set();
3395
+ const watchCurrentSession = (fn) => {
3396
+ currentSessionListeners.add(fn);
3397
+ return () => { currentSessionListeners.delete(fn); };
3398
+ };
3399
+ const uninstallListeners = new Set();
3400
+ const watchUninstall = (fn) => {
3401
+ uninstallListeners.add(fn);
3402
+ return () => { uninstallListeners.delete(fn); };
3403
+ };
3404
+ const gateway = raw;
3405
+ const shape = checkGatewayShape(raw);
3406
+ const counters = { openWireStream: 0, dispatchRpc: 0 };
3407
+ const failures = [];
3408
+ const incompatible = [];
3409
+ // The closed-session registry (T34): insertion-ordered, capped, virtual-id
3410
+ // keyed. Entries live until THEIR session proves reachable again (a
3411
+ // succeeding call for it) — a reconnect clears nothing (T34-fix): a link
3412
+ // that flapped under a still-standing closure would make the banner flicker
3413
+ // away and back.
3414
+ const closedSessions = new Map();
3415
+ // T73-fix: waiters for "a closed-session record was just cleared" — the
3416
+ // quiet session/follow hold (rewriteUpstream) ends its silence the moment
3417
+ // the session it holds for is served again, so a re-enabled remote comes
3418
+ // back with a fresh history instead of staying blind under its banner.
3419
+ const closedClearWaiters = new Set();
3420
+ const remoteSessionCwds = new Map();
3421
+ const activeRemoteFollows = new Map();
3422
+ let lastFollowedSession;
3423
+ const MAX_REMOTE_SESSION_CWDS = 200;
3424
+ const recordRemoteSessionCwd = (serverId, rawSessionId, cwd) => {
3425
+ if (typeof rawSessionId !== 'string' || rawSessionId === '')
3426
+ return;
3427
+ if (typeof cwd !== 'string' || cwd === '')
3428
+ return;
3429
+ const key = toVirtual(serverId, rawSessionId);
3430
+ // Re-insert to refresh the recency order, then cap like `closedSessions`.
3431
+ remoteSessionCwds.delete(key);
3432
+ if (remoteSessionCwds.size >= MAX_REMOTE_SESSION_CWDS) {
3433
+ const oldest = remoteSessionCwds.keys().next();
3434
+ if (oldest.done !== true)
3435
+ remoteSessionCwds.delete(oldest.value);
3436
+ }
3437
+ remoteSessionCwds.set(key, { serverId, sessionId: rawSessionId, cwd });
3438
+ };
3439
+ /** Record every cwd a remote `session/list` result carries (both merge
3440
+ * modes call this on the RAW remote value, before ids go virtual). */
3441
+ const noteRemoteSessionListCwds = (remoteResult, serverId) => {
3442
+ if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items))
3443
+ return;
3444
+ for (const item of remoteResult.items) {
3445
+ if (!isPlainObject(item))
3446
+ continue;
3447
+ const id = typeof item.sessionId === 'string' && item.sessionId !== ''
3448
+ ? item.sessionId
3449
+ : typeof item.id === 'string' && item.id !== ''
3450
+ ? item.id
3451
+ : undefined;
3452
+ if (id === undefined)
3453
+ continue;
3454
+ recordRemoteSessionCwd(serverId, id, item.cwd);
3455
+ }
3456
+ };
3457
+ /**
3458
+ * The resolution the /api/file wrap buys with one call. Steps, in order:
3459
+ * A) the ACTIVE remote session — the browser-reported current session
3460
+ * (unless this process's newest follow went LOCAL, which is the newer
3461
+ * fact), else the newest follow, else the newest live remote follow;
3462
+ * B) a precise cwd match — the active session's own cwd first, then any
3463
+ * known cwd the path falls under (a session with a LIVE follow preferred,
3464
+ * else the most recently refreshed match);
3465
+ * C) no cwd claim anywhere → the active remote (so a just-opened page's
3466
+ * first image resolves with zero cwd knowledge), or `undefined` when no
3467
+ * remote session is active — a local session's images stay local.
3468
+ */
3469
+ const resolveRemoteFileScope = (rawPath) => {
3470
+ if (typeof rawPath !== 'string' || rawPath === '')
3471
+ return undefined;
3472
+ let activeRemote;
3473
+ const current = getCurrentSession();
3474
+ if (current.kind === 'open') {
3475
+ const parts = fromVirtual(current.sessionId);
3476
+ if (parts !== undefined && lastFollowedSession?.kind !== 'local') {
3477
+ activeRemote = { serverId: parts.serverId, sessionId: parts.id };
3478
+ }
3479
+ }
3480
+ if (activeRemote === undefined && lastFollowedSession?.kind === 'remote') {
3481
+ activeRemote = { serverId: lastFollowedSession.serverId, sessionId: lastFollowedSession.sessionId };
3482
+ }
3483
+ if (activeRemote === undefined && activeRemoteFollows.size > 0) {
3484
+ const values = [...activeRemoteFollows.values()];
3485
+ const last = values[values.length - 1];
3486
+ if (last !== undefined)
3487
+ activeRemote = { ...last };
3488
+ }
3489
+ if (activeRemote !== undefined) {
3490
+ const own = remoteSessionCwds.get(toVirtual(activeRemote.serverId, activeRemote.sessionId));
3491
+ if (own !== undefined && pathMatchesCwd(rawPath, own.cwd)) {
3492
+ return { serverId: own.serverId, sessionId: own.sessionId };
3493
+ }
3494
+ }
3495
+ const followedVirtuals = new Set([...activeRemoteFollows.values()].map((entry) => toVirtual(entry.serverId, entry.sessionId)));
3496
+ let latestMatch;
3497
+ let followedMatch;
3498
+ for (const entry of remoteSessionCwds.values()) {
3499
+ if (!pathMatchesCwd(rawPath, entry.cwd))
3500
+ continue;
3501
+ latestMatch = entry;
3502
+ if (followedVirtuals.has(toVirtual(entry.serverId, entry.sessionId)))
3503
+ followedMatch = entry;
3504
+ }
3505
+ if (followedMatch !== undefined)
3506
+ return { serverId: followedMatch.serverId, sessionId: followedMatch.sessionId };
3507
+ if (latestMatch !== undefined)
3508
+ return { serverId: latestMatch.serverId, sessionId: latestMatch.sessionId };
3509
+ return activeRemote;
3510
+ };
3511
+ // The last successfully fetched SERVER model catalog (T52-fix), kept for
3512
+ // the offline merges of the `session/modelCatalog` route below — cleared
3513
+ // on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
3514
+ // own drop is the backstop) and never served for another server's id (see the
3515
+ // route's doc). Only an in-memory value: the groups are display data and
3516
+ // a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
3517
+ // the modelSelection provider rewrites are unconditional again (the host's
3518
+ // projection store is first-write-wins per seq, so a catalog-gated rewrite
3519
+ // would poison the store — see virtualizeModelSelectionValue), and the
3520
+ // `groups` membership set is gone with the gate.
3521
+ let serverCatalogCache;
3522
+ /**
3523
+ * The serving generation, bumped on every pairing wall (`unpaired` /
3524
+ * `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
3525
+ * rose must not write its answer back — the wall's groups must not outlive
3526
+ * it, and a late write would resurrect them under the offline merge below.
3527
+ * Both cache writes (the proactive fetch here, the route's own fetch)
3528
+ * snapshot the counter before invoking and compare before storing; a
3529
+ * mismatched answer is silently dropped — it is not a fault, the next
3530
+ * serving period fetches fresh anyway.
3531
+ */
3532
+ let catalogGeneration = 0;
3533
+ const rememberServerCatalog = (identity, value, generation) => {
3534
+ if (generation !== catalogGeneration)
3535
+ return;
3536
+ serverCatalogCache = { identity, value };
3537
+ };
3538
+ /** MS-3 pool mode: the per-server form of the same cache — one entry per
3539
+ * known `serverId`, keyed by it. An offline entry keeps serving its cache
3540
+ * (the groups must not go blank mid-outage); an entry that LEAVES the pool
3541
+ * takes its cache with it (its groups must not outlive the pairing). */
3542
+ const serverCatalogCaches = new Map();
3543
+ const catalogGenerations = new Map();
3544
+ /** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
3545
+ * the proactive pull that runs as soon as the relay serves — and once at
3546
+ * install when the relay already serves (subscribe replays no state —
3547
+ * relay-client.ts setState returns on a same-state write — so the
3548
+ * subscription alone would miss an install-into-online). Failures are
3549
+ * diagnostics-ring entries, nothing else (the route's own fetch is the
3550
+ * user-visible path and answers local-only there). */
3551
+ const fetchServerCatalog = (identity) => {
3552
+ const generation = catalogGeneration;
3553
+ void singleRelay.invoke('session', 'modelCatalog', {}, undefined).then((value) => {
3554
+ rememberServerCatalog(identity, value, generation);
3555
+ }, (error) => {
3556
+ recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal');
3557
+ });
3558
+ };
3559
+ /** MS-3 pool mode: the per-server proactive fetch. The identity's display
3560
+ * name is the POOL's (the `-1`/`-2` disambiguated one) — it titles the
3561
+ * merged catalog's virtual groups. Generation-guarded per server, like
3562
+ * the single-server fetch above. */
3563
+ const fetchServerCatalogFor = (entry) => {
3564
+ const serverId = entry.serverId;
3565
+ const generation = catalogGenerations.get(serverId) ?? 0;
3566
+ void entry.client.invoke('session', 'modelCatalog', {}, undefined).then((value) => {
3567
+ if ((catalogGenerations.get(serverId) ?? 0) !== generation)
3568
+ return;
3569
+ serverCatalogCaches.set(serverId, { identity: { serverId, serverName: entry.displayName }, value });
3570
+ }, (error) => {
3571
+ recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal');
3572
+ });
3573
+ };
3574
+ // The catalog watcher (T52-fix2). The subscription fires on the real
3575
+ // online transitions (first handshake, every recovery) — and since T59
3576
+ // also on name-only notifications, which arrive with the state still
3577
+ // `online` and are deduped by the same refreshKey/stateSeen gates above.
3578
+ // The install-time check below covers an install into an already-online
3579
+ // relay. `unpaired` / `revoked`
3580
+ // drop the cache the same way the catalog route does lazily — the wall's
3581
+ // groups must not outlive it — and bump the generation so an in-flight
3582
+ // fetch cannot write the old catalog back (T52-fix3). Faults stay
3583
+ // contained: the sync body cannot throw, and the fetch settles through
3584
+ // .then/.catch above.
3585
+ let offCatalogWatch;
3586
+ // MS-3 pool mode: one client subscription per pool entry (the map below),
3587
+ // created and retired by the watchPool rescan. MS-5: uninstall tears them
3588
+ // ALL down — `offCatalogWatch` only unsubscribes the rescan itself, and a
3589
+ // left-behind per-entry subscription would keep firing into this dead
3590
+ // generation's caches on every later state change.
3591
+ const catalogWatched = new Map();
3592
+ if (pool === undefined) {
3593
+ try {
3594
+ offCatalogWatch = singleRelay.subscribe((state) => {
3595
+ if (state === 'unpaired' || state === 'revoked') {
3596
+ catalogGeneration += 1;
3597
+ serverCatalogCache = undefined;
3598
+ return;
3599
+ }
3600
+ if (state !== 'online')
3601
+ return;
3602
+ const identity = relayIdentityOf(singleRelay);
3603
+ if (identity !== undefined)
3604
+ fetchServerCatalog(identity);
3605
+ });
3606
+ // Install into an already-serving relay: subscribe replayed nothing, so
3607
+ // the transition-based fetch would wait for the NEXT flap — fill the
3608
+ // cache now instead (T52-fix3).
3609
+ if (singleRelay.state === 'online') {
3610
+ const identity = relayIdentityOf(singleRelay);
3611
+ if (identity !== undefined)
3612
+ fetchServerCatalog(identity);
3613
+ }
3614
+ }
3615
+ catch (error) {
3616
+ // Subscription failed: the cache still fills whenever the UI opens a
3617
+ // dropdown (the route's own fetch), only later.
3618
+ log?.('client catalog watcher unavailable: %s', messageOf(error));
3619
+ }
3620
+ }
3621
+ else {
3622
+ // MS-3 pool mode: one watcher per entry, rescanned on every pool event.
3623
+ // `unpaired` / `revoked` drop THAT server's cache and bump its
3624
+ // generation; `online` proactively refetches it (an entry appearing
3625
+ // already online is fetched at attach, same as the single-server
3626
+ // install-into-online above).
3627
+ offCatalogWatch = watchPool(() => {
3628
+ const seen = new Set();
3629
+ for (const entry of pool.entries()) {
3630
+ seen.add(entry.entryId);
3631
+ if (catalogWatched.has(entry.entryId))
3632
+ continue;
3633
+ const unsubscribe = entry.client.subscribe((state) => {
3634
+ // The entry's serverId is read LIVE — a re-handshake may have
3635
+ // renamed it since this listener attached.
3636
+ const live = pool.entries().find((candidate) => candidate.entryId === entry.entryId);
3637
+ const serverId = live?.serverId;
3638
+ if (serverId === undefined)
3639
+ return;
3640
+ if (state === 'unpaired' || state === 'revoked') {
3641
+ catalogGenerations.set(serverId, (catalogGenerations.get(serverId) ?? 0) + 1);
3642
+ serverCatalogCaches.delete(serverId);
3643
+ return;
3644
+ }
3645
+ if (state !== 'online')
3646
+ return;
3647
+ const current = live ?? entry;
3648
+ if (current.serverId !== undefined) {
3649
+ fetchServerCatalogFor({ serverId: current.serverId, displayName: current.displayName, client: current.client });
3650
+ }
3651
+ });
3652
+ catalogWatched.set(entry.entryId, unsubscribe);
3653
+ if (entry.client.state === 'online' && entry.serverId !== undefined) {
3654
+ fetchServerCatalogFor({ serverId: entry.serverId, displayName: entry.displayName, client: entry.client });
3655
+ }
3656
+ }
3657
+ for (const [entryId, unsubscribe] of [...catalogWatched]) {
3658
+ if (!seen.has(entryId)) {
3659
+ unsubscribe();
3660
+ catalogWatched.delete(entryId);
3661
+ }
3662
+ }
3663
+ });
3664
+ // Run the initial scan through the hub so the same rescan logic runs.
3665
+ for (const listener of [...poolListeners])
3666
+ listener();
3667
+ }
3668
+ const registerClosed = (sessionId, reason) => {
3669
+ closedSessions.delete(sessionId);
3670
+ if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
3671
+ const oldest = closedSessions.keys().next();
3672
+ if (oldest.done !== true)
3673
+ closedSessions.delete(oldest.value);
3674
+ }
3675
+ closedSessions.set(sessionId, reason);
3676
+ };
3677
+ const clearClosed = (virtuals) => {
3678
+ let cleared = false;
3679
+ for (const parts of virtuals) {
3680
+ if (closedSessions.delete(toVirtual(parts.serverId, parts.id)))
3681
+ cleared = true;
3682
+ }
3683
+ if (cleared)
3684
+ for (const waiter of [...closedClearWaiters])
3685
+ waiter();
3686
+ };
3687
+ let installed = true;
3688
+ let selfCheck;
3689
+ /**
3690
+ * Reload overlay, for the record (CP4): the theoretical hazard is installing
3691
+ * a NEW wrapper while an OLD one is still installed, so each captures the
3692
+ * other and the older survives as a live layer. Cordis `fiber.restart()`
3693
+ * disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
3694
+ * restoring the saved property) and only then applies the new row — the
3695
+ * overlay ordering never happens today, so there is nothing to absorb at
3696
+ * install time. Detection is also not cheaply implementable: a reloaded row
3697
+ * loads a FRESH module instance, so a stale wrapper shares no mark with the
3698
+ * new one — telling "ours, from a dead generation" apart from another
3699
+ * plugin's wrapper would take a process-wide registry, which the comment
3700
+ * above argues is not worth it while the ordering cannot occur. The
3701
+ * `installed === false → inert passthrough` rule below is the safety net
3702
+ * for the ordering that CAN happen: a later plugin wrapping OVER us and
3703
+ * keeping a reference past our uninstall.
3704
+ *
3705
+ * Save the CURRENT value per property (possibly another plugin's wrapper —
3706
+ * shape note "own-property"), falling back to the prototype method only
3707
+ * when the current value is not callable. Uninstall restores the saved
3708
+ * own state verbatim: value if there was one, absence if not.
3709
+ */
3710
+ const saved = {};
3711
+ const chainTarget = {};
3712
+ for (const name of ['openWireStream', 'dispatchRpc']) {
3713
+ const existed = Object.prototype.hasOwnProperty.call(gateway, name);
3714
+ const value = existed ? gateway[name] : undefined;
3715
+ saved[name] = { existed, value };
3716
+ chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name];
3717
+ }
3718
+ // Filled in below, once the wrapper functions exist: uninstall may only
3719
+ // touch a property that still holds OUR wrapper.
3720
+ const wrappers = {};
3721
+ const recordFailure = (endpoint, code) => {
3722
+ if (failures.length >= MAX_FAILURES)
3723
+ failures.shift();
3724
+ failures.push({ time: new Date().toISOString(), endpoint, code });
3725
+ // T42: validation-shaped refusals additionally land in the incompatible
3726
+ // ring — every remote failure funnels through this one function, so the
3727
+ // classification cannot drift between the invoke and stream routes.
3728
+ if (!INCOMPATIBLE_CALL_CODES.has(code))
3729
+ return;
3730
+ if (incompatible.length >= MAX_INCOMPATIBLE_CALLS)
3731
+ incompatible.shift();
3732
+ incompatible.push({ time: Date.now(), endpoint, code });
3733
+ };
3734
+ const failEnvelope = (endpoint, code, text) => {
3735
+ recordFailure(endpoint, code);
3736
+ // `details: {}` is load-bearing: the UI's server-response parser throws
3737
+ // `invalid server-response failure` on a details-less error (and the
3738
+ // gateway's rpcErrorSchema demands the same shape).
3739
+ return { ok: false, error: { code, message: text, details: {} } };
3740
+ };
3741
+ /**
3742
+ * The shared validation both routes apply before forwarding — now (MS-3)
3743
+ * resolving the TARGET SERVER as well as the call's shape. The single
3744
+ * `relay` mode keeps the original verdict order byte-for-byte (offline →
3745
+ * mismatch → unsupported); pool mode routes by the virtual id's serverId:
3746
+ * no handshook server in the pool answers `remote-offline`, a handshook
3747
+ * pool without the target server answers `remote-mismatch`, and the
3748
+ * matched entry's client is the forwarding leg. A refusal is recorded
3749
+ * once, here, by the caller passing it through {@link failEnvelope}.
3750
+ */
3751
+ const copy = () => INTERCEPT_COPY[resolveInterceptLang(options.lang)];
3752
+ function validate(endpoint, virtuals) {
3753
+ const first = virtuals[0].serverId;
3754
+ const c = copy();
3755
+ if (pool === undefined) {
3756
+ const current = singleGetServerId();
3757
+ if (current === undefined)
3758
+ return { ok: false, code: 'remote-offline', message: c.notConnected };
3759
+ if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
3760
+ return { ok: false, code: 'remote-mismatch', message: c.mismatch };
3761
+ }
3762
+ if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
3763
+ return { ok: false, code: 'remote-unsupported', message: c.unsupported };
3764
+ }
3765
+ return { ok: true, client: singleRelay, serverId: first };
3766
+ }
3767
+ if (virtuals.some((parts) => parts.serverId !== first)) {
3768
+ return { ok: false, code: 'remote-mismatch', message: c.mismatch };
3769
+ }
3770
+ const known = pool.entries().filter((entry) => entry.serverId !== undefined);
3771
+ if (known.length === 0)
3772
+ return { ok: false, code: 'remote-offline', message: c.notConnected };
3773
+ const target = known.find((entry) => entry.serverId === first);
3774
+ if (target === undefined)
3775
+ return { ok: false, code: 'remote-mismatch', message: c.mismatch };
3776
+ if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
3777
+ return { ok: false, code: 'remote-unsupported', message: c.unsupported };
3778
+ }
3779
+ return { ok: true, client: target.client, serverId: first };
3780
+ }
3781
+ /**
3782
+ * The T32 `$events/result` split: an answer whose eventId is VIRTUAL
3783
+ * belongs to a forwarded remote event and must NOT reach the local
3784
+ * gateway (its own `$events` never delivered it — the local dispatcher
3785
+ * would answer with its `no active event stream` failure). The virtual
3786
+ * id goes back to the original, the local payload's clientId (the local
3787
+ * stream's own) is discarded — the relay server composes the gateway
3788
+ * payload from ITS subscription's clientId — and the outcome rides
3789
+ * verbatim. Anything else, including a malformed payload, is the local
3790
+ * gateway's business.
3791
+ */
3792
+ function forwardEventResult(payload, signal, peer) {
3793
+ const args = argsOf(payload);
3794
+ const eventId = isPlainObject(args) ? args.eventId : undefined;
3795
+ const virtual = fromVirtual(eventId);
3796
+ if (virtual === undefined) {
3797
+ return chainTarget.dispatchRpc.call(raw, '$events/result', payload, signal, peer);
3798
+ }
3799
+ const endpoint = '$events/result';
3800
+ const route = (() => {
3801
+ if (pool === undefined) {
3802
+ const current = singleGetServerId();
3803
+ if (current === undefined || singleRelay.state !== 'online')
3804
+ return 'offline';
3805
+ if (virtual.serverId !== current)
3806
+ return 'mismatch';
3807
+ return { client: singleRelay };
3808
+ }
3809
+ const target = pool.getByServerId(virtual.serverId);
3810
+ if (target === undefined)
3811
+ return 'missing';
3812
+ if (target.client.state !== 'online')
3813
+ return 'offline';
3814
+ return { client: target.client };
3815
+ })();
3816
+ // Offline (T34-fix): a refusal envelope would RESTART the UI's whole
3817
+ // `$events` generation — the client face's answer() throws on a
3818
+ // `!response.ok` body and the pump treats that as a delivery failure
3819
+ // (RT dsh-api-gateway lib/client.js: `if (!response.ok) throw new
2468
3820
  // Error(response.error.message)` feeding `failed.abort(error)`), and the
2469
3821
  // synthesized cancels the dying leg already sent have closed these
2470
3822
  // prompts anyway. Answer the silent ok DSH itself gives a stale result,
2471
3823
  // and record the refusal in the diagnostics ring instead — the
2472
- // non-online write-refusal message, so the banner's word matches.
2473
- if (current === undefined || relay.state !== 'online') {
2474
- failEnvelope(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE);
3824
+ // non-online write-refusal message, so the banner's word matches. In
3825
+ // pool mode a leg the pool no longer serves (`missing` — unpaired away,
3826
+ // deduped, never paired) reads the same way: nothing can settle those
3827
+ // events anymore, so the silent ok wins there too. The single-server
3828
+ // mismatch keeps its original REFUSAL envelope (wrong server is a
3829
+ // different fact from a leg that just left).
3830
+ if (route === 'offline') {
3831
+ failEnvelope(endpoint, 'remote-offline', copy().writeOffline);
3832
+ return { ok: true, value: undefined };
3833
+ }
3834
+ if (route === 'missing') {
3835
+ failEnvelope(endpoint, 'remote-mismatch', copy().mismatch);
2475
3836
  return { ok: true, value: undefined };
2476
3837
  }
2477
- if (virtual.serverId !== current)
2478
- return failEnvelope(endpoint, 'remote-mismatch', '此远程会话属于其他主服务端');
3838
+ if (route === 'mismatch')
3839
+ return failEnvelope(endpoint, 'remote-mismatch', copy().mismatch);
3840
+ const client = route.client;
2479
3841
  return (async () => {
2480
3842
  try {
2481
- await relay.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal);
3843
+ await client.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal);
2482
3844
  return { ok: true, value: undefined };
2483
3845
  }
2484
3846
  catch (error) {
@@ -2515,11 +3877,13 @@ export function installIntercept(options) {
2515
3877
  // A WRITE while the relay is not serving (T34, whitelist rule per
2516
3878
  // T34-fix): refused here, never sent — the call could only fail out
2517
3879
  // there, and the read paths answer for themselves (forwarded as today,
2518
- // failing with their own transport error).
2519
- if (isRemoteWrite(endpoint) && relay.state !== 'online') {
2520
- return failEnvelope(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE);
3880
+ // failing with their own transport error). Pool mode (MS-3): the verdict
3881
+ // names the TARGET leg — its state is the one that counts.
3882
+ if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
3883
+ return failEnvelope(endpoint, 'remote-offline', copy().writeOffline);
2521
3884
  }
2522
3885
  const serverId = verdict.serverId;
3886
+ const client = verdict.client;
2523
3887
  const slash = endpoint.indexOf('/');
2524
3888
  const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash);
2525
3889
  const method = slash === -1 ? '' : endpoint.slice(slash + 1);
@@ -2546,9 +3910,9 @@ export function installIntercept(options) {
2546
3910
  const references = restoreSessionReferences(clone, namespace, method, serverId);
2547
3911
  clone = references.args;
2548
3912
  if (!references.ok) {
2549
- return failEnvelope(endpoint, 'remote-unsupported', '引用的会话不在服务端上,无法转发');
3913
+ return failEnvelope(endpoint, 'remote-unsupported', copy().referenceNotOnServer);
2550
3914
  }
2551
- const value = await relay.invoke(namespace, method, clone, signal);
3915
+ const value = await client.invoke(namespace, method, clone, signal);
2552
3916
  // The session answered: any closed-session entry for it is stale
2553
3917
  // (re-shared and served again) — the banner may go.
2554
3918
  clearClosed(virtuals);
@@ -2578,7 +3942,7 @@ export function installIntercept(options) {
2578
3942
  recordFailure(endpoint, code);
2579
3943
  // A call on a closed session reads like the banner, not like the
2580
3944
  // relay's bare code (T73).
2581
- const message = code === 'not-shared' ? CLOSED_MESSAGE : messageOf(error);
3945
+ const message = code === 'not-shared' ? copy().closed : messageOf(error);
2582
3946
  return { ok: false, error: { code, message, details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } };
2583
3947
  }
2584
3948
  }
@@ -2601,20 +3965,20 @@ export function installIntercept(options) {
2601
3965
  * can never reach us (subscribe's contract keeps listener faults contained
2602
3966
  * on the client side).
2603
3967
  */
2604
- function waitForOnline(signal) {
3968
+ function waitForOnline(client, signal) {
2605
3969
  return new Promise((resolve) => {
2606
3970
  if (signal?.aborted === true) {
2607
3971
  resolve('aborted');
2608
3972
  return;
2609
3973
  }
2610
- if (relay.state === 'online') {
3974
+ if (client.state === 'online') {
2611
3975
  resolve('online');
2612
3976
  return;
2613
3977
  }
2614
3978
  // The wall may already be down when the wait starts (the leg died and
2615
3979
  // the state flipped before this call) — same verdict as below.
2616
- if (relay.state === 'revoked' || relay.state === 'unpaired') {
2617
- resolve(relay.state);
3980
+ if (client.state === 'revoked' || client.state === 'unpaired') {
3981
+ resolve(client.state);
2618
3982
  return;
2619
3983
  }
2620
3984
  let off;
@@ -2627,7 +3991,7 @@ export function installIntercept(options) {
2627
3991
  resolve(verdict);
2628
3992
  };
2629
3993
  const onAbort = () => settle('aborted');
2630
- off = relay.subscribe((state) => {
3994
+ off = client.subscribe((state) => {
2631
3995
  if (state === 'online')
2632
3996
  settle('online');
2633
3997
  else if (state === 'revoked' || state === 'unpaired')
@@ -2660,16 +4024,16 @@ export function installIntercept(options) {
2660
4024
  * current backoff step. `aborted` only on caller abort; `revoked` /
2661
4025
  * `unpaired` when the pairing wall came down (CP5).
2662
4026
  */
2663
- async function waitServeAgain(signal, backoffMs) {
2664
- if (relay.state === 'online') {
4027
+ async function waitServeAgain(client, signal, backoffMs) {
4028
+ if (client.state === 'online') {
2665
4029
  await delayWait(signal, backoffMs);
2666
4030
  if (signal?.aborted === true)
2667
4031
  return 'aborted';
2668
- if (relay.state !== 'online')
2669
- return waitForOnline(signal);
4032
+ if (client.state !== 'online')
4033
+ return waitForOnline(client, signal);
2670
4034
  return 'online';
2671
4035
  }
2672
- return waitForOnline(signal);
4036
+ return waitForOnline(client, signal);
2673
4037
  }
2674
4038
  /**
2675
4039
  * Pump one session-level remote stream into the UI (CP4, SPEC story 54
@@ -2711,7 +4075,7 @@ export function installIntercept(options) {
2711
4075
  * history stream after its first frame it ends nothing at all — the
2712
4076
  * stream goes silent until the page closes it (T73, at the catch below).
2713
4077
  */
2714
- async function* rewriteUpstream(endpoint, upstream, serverId, claimed, signal, reopen) {
4078
+ async function* rewriteUpstream(endpoint, client, upstream, serverId, claimed, signal, reopen) {
2715
4079
  let first = true;
2716
4080
  let current = upstream;
2717
4081
  // The in-place reopen backoff (CP4, the merged pumps' discipline): starts
@@ -2732,6 +4096,12 @@ export function installIntercept(options) {
2732
4096
  // closed-session entry for it is stale (T34).
2733
4097
  clearClosed(claimed);
2734
4098
  }
4099
+ // P55-2: the snapshot's header carries the session identity and its
4100
+ // cwd in ORIGINAL ids — record before the rewrite below virtualizes
4101
+ // them, so /api/file can attribute the session's own image paths.
4102
+ if (endpoint === 'session/follow' && isPlainObject(frame) && frame.type === 'snapshot' && isPlainObject(frame.header)) {
4103
+ recordRemoteSessionCwd(serverId, frame.header.id, frame.header.cwd);
4104
+ }
2735
4105
  yield rewriteFrame(endpoint, frame, serverId);
2736
4106
  }
2737
4107
  return;
@@ -2793,7 +4163,7 @@ export function installIntercept(options) {
2793
4163
  closedClearWaiters.delete(onCleared);
2794
4164
  return;
2795
4165
  }
2796
- throw new CodedStreamError(code, CLOSED_MESSAGE);
4166
+ throw new CodedStreamError(code, copy().closed);
2797
4167
  }
2798
4168
  recordFailure(endpoint, code);
2799
4169
  // A link-down fact (CP4), scoped by CP5: only the carrier-retry
@@ -2806,7 +4176,7 @@ export function installIntercept(options) {
2806
4176
  if (!SESSION_STREAM_HOLD_ENDPOINTS.has(endpoint)) {
2807
4177
  throw new CodedStreamError(code, messageOf(error));
2808
4178
  }
2809
- const serve = await waitServeAgain(signal, reopenDelayMs);
4179
+ const serve = await waitServeAgain(client, signal, reopenDelayMs);
2810
4180
  if (serve === 'aborted')
2811
4181
  return;
2812
4182
  // The pairing wall came down while held (CP5): no ladder climbs back
@@ -2816,7 +4186,8 @@ export function installIntercept(options) {
2816
4186
  // would freeze the panel with no verdict and no wake-up.
2817
4187
  if (serve !== 'online') {
2818
4188
  recordFailure(endpoint, serve);
2819
- throw new CodedStreamError(serve, serve === 'revoked' ? '配对令牌已被吊销,请在设置页重新配对' : '尚未与服务端配对,请在设置页完成配对');
4189
+ const c = copy();
4190
+ throw new CodedStreamError(serve, serve === 'revoked' ? c.revoked : c.unpaired);
2820
4191
  }
2821
4192
  if (callerAborted())
2822
4193
  return;
@@ -2833,6 +4204,11 @@ export function installIntercept(options) {
2833
4204
  * virtualized session ids. Paged requests, an offline relay, a non-ok local
2834
4205
  * envelope and a failed remote call all answer the local result untouched —
2835
4206
  * only a remote failure is recorded.
4207
+ *
4208
+ * MS-3 pool mode: EVERY online server's first page is fetched
4209
+ * concurrently and folded in `pool.entries()` order (pairing order), one
4210
+ * `mergeSessionList` per server. A failing leg is recorded and skipped —
4211
+ * the other servers' rows still merge.
2836
4212
  */
2837
4213
  async function mergedSessionListCall(endpoint, payload, args, signal, peer) {
2838
4214
  const localEnvelope = (await chainTarget.dispatchRpc.call(raw, endpoint, payload, signal, peer));
@@ -2843,11 +4219,30 @@ export function installIntercept(options) {
2843
4219
  const request = isPlainObject(args) ? args._request : undefined;
2844
4220
  if (isPlainObject(request) && request.cursor !== undefined)
2845
4221
  return localEnvelope;
2846
- const identity = relayIdentityOf(relay);
2847
- if (identity === undefined || relay.state !== 'online')
4222
+ if (pool !== undefined) {
4223
+ const online = pool.entries().filter((entry) => entry.serverId !== undefined && entry.client.state === 'online');
4224
+ if (online.length === 0)
4225
+ return localEnvelope;
4226
+ const settled = await Promise.all(online.map((entry) => entry.client.invoke('session', 'list', isPlainObject(args) ? args : {}, signal).then((value) => ({ ok: true, value, serverId: entry.serverId }), (error) => ({ ok: false, error, serverId: entry.serverId }))));
4227
+ let value = localEnvelope.value;
4228
+ for (const result of settled) {
4229
+ if (!result.ok) {
4230
+ recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal');
4231
+ continue;
4232
+ }
4233
+ // P55-2: the RAW remote rows (original ids) feed the /api/file cwd
4234
+ // registry before the merge virtualizes them.
4235
+ noteRemoteSessionListCwds(result.value, result.serverId);
4236
+ value = mergeSessionList(value, result.value, result.serverId);
4237
+ }
4238
+ return { ok: true, value };
4239
+ }
4240
+ const identity = relayIdentityOf(singleRelay);
4241
+ if (identity === undefined || singleRelay.state !== 'online')
2848
4242
  return localEnvelope;
2849
4243
  try {
2850
- const remote = await relay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal);
4244
+ const remote = await singleRelay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal);
4245
+ noteRemoteSessionListCwds(remote, identity.serverId);
2851
4246
  return { ok: true, value: mergeSessionList(localEnvelope.value, remote, identity.serverId) };
2852
4247
  }
2853
4248
  catch (error) {
@@ -2880,8 +4275,48 @@ export function installIntercept(options) {
2880
4275
  // A local failure is the answer — there is nothing to merge into.
2881
4276
  if (!isPlainObject(localEnvelope) || localEnvelope.ok !== true)
2882
4277
  return localEnvelope;
2883
- const identity = relayIdentityOf(relay);
2884
- if (identity !== undefined && relay.state === 'online') {
4278
+ if (pool !== undefined) {
4279
+ // MS-3: every PAIRED server contributes its own groups, folded in
4280
+ // pairing order; each server's last fetched catalog is cached by its
4281
+ // serverId so an offline server keeps its groups while the online ones
4282
+ // refetch. A cache whose server left the pool dies with the entry.
4283
+ const entries = pool.entries().filter((entry) => entry.serverId !== undefined);
4284
+ if (entries.length === 0)
4285
+ return localEnvelope;
4286
+ const online = entries.filter((entry) => entry.client.state === 'online');
4287
+ const settled = online.length === 0
4288
+ ? []
4289
+ : await Promise.all(online.map((entry) => entry.client.invoke('session', 'modelCatalog', {}, signal).then((value) => ({ ok: true, value, serverId: entry.serverId }), (error) => ({ ok: false, error, serverId: entry.serverId }))));
4290
+ const resultsByServer = new Map(settled.map((result) => [result.serverId, result]));
4291
+ let acc = localEnvelope.value;
4292
+ for (const entry of entries) {
4293
+ const serverId = entry.serverId;
4294
+ const identity = { serverId, serverName: entry.displayName };
4295
+ const result = resultsByServer.get(serverId);
4296
+ if (result === undefined) {
4297
+ const cached = serverCatalogCaches.get(serverId);
4298
+ if (cached !== undefined)
4299
+ acc = mergeModelCatalogs(acc, cached.value, cached.identity);
4300
+ continue;
4301
+ }
4302
+ if (!result.ok) {
4303
+ recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal');
4304
+ const cached = serverCatalogCaches.get(serverId);
4305
+ if (cached !== undefined)
4306
+ acc = mergeModelCatalogs(acc, cached.value, cached.identity);
4307
+ continue;
4308
+ }
4309
+ serverCatalogCaches.set(serverId, { identity, value: result.value });
4310
+ acc = mergeModelCatalogs(acc, result.value, identity);
4311
+ }
4312
+ for (const serverId of [...serverCatalogCaches.keys()]) {
4313
+ if (!entries.some((entry) => entry.serverId === serverId))
4314
+ serverCatalogCaches.delete(serverId);
4315
+ }
4316
+ return { ok: true, value: acc };
4317
+ }
4318
+ const identity = relayIdentityOf(singleRelay);
4319
+ if (identity !== undefined && singleRelay.state === 'online') {
2885
4320
  try {
2886
4321
  // The method has NO parameters (RT lib/typert.remote-client.js:1019) —
2887
4322
  // the relay route allows it field-less (relay-access.ts), filters the
@@ -2892,7 +4327,7 @@ export function installIntercept(options) {
2892
4327
  // the counter and the answer never re-enters the cache — the caller
2893
4328
  // still gets its (live) answer, it asked while the link served.
2894
4329
  const generation = catalogGeneration;
2895
- const remote = await relay.invoke('session', 'modelCatalog', {}, signal);
4330
+ const remote = await singleRelay.invoke('session', 'modelCatalog', {}, signal);
2896
4331
  rememberServerCatalog(identity, remote, generation);
2897
4332
  return { ok: true, value: mergeModelCatalogs(localEnvelope.value, remote, identity) };
2898
4333
  }
@@ -2903,7 +4338,7 @@ export function installIntercept(options) {
2903
4338
  }
2904
4339
  // Not serving normally. A pairing wall is a verdict about the server
2905
4340
  // itself — its groups must not outlive it.
2906
- if (relay.state === 'unpaired' || relay.state === 'revoked') {
4341
+ if (singleRelay.state === 'unpaired' || singleRelay.state === 'revoked') {
2907
4342
  serverCatalogCache = undefined;
2908
4343
  return localEnvelope;
2909
4344
  }
@@ -2964,17 +4399,29 @@ export function installIntercept(options) {
2964
4399
  const providerParts = fromVirtual(provider);
2965
4400
  if (sessionParts === undefined && providerParts === undefined)
2966
4401
  return undefined;
4402
+ const c = copy();
2967
4403
  if (sessionParts === undefined) {
2968
- return { code: 'remote-unsupported', message: '该模型分组属于远程服务端,无法在本机会话中使用' };
4404
+ return { code: 'remote-unsupported', message: c.modelGroupRemote };
2969
4405
  }
2970
- if (getServerId() !== sessionParts.serverId)
4406
+ // MS-3: the session's server is looked up in the POOL (by the virtual
4407
+ // id's serverId); a pool without that server falls through to the
4408
+ // generic path's verdict (offline / mismatch). The message names the
4409
+ // pool's display name — the same `-1`/`-2` disambiguated one the merged
4410
+ // catalog groups are titled with.
4411
+ const displayName = (() => {
4412
+ if (pool !== undefined)
4413
+ return pool.getByServerId(sessionParts.serverId)?.displayName;
4414
+ if (singleGetServerId() !== sessionParts.serverId)
4415
+ return undefined;
4416
+ return relayIdentityOf(singleRelay)?.serverName;
4417
+ })();
4418
+ if (displayName === undefined)
2971
4419
  return undefined;
2972
4420
  if (providerParts === undefined) {
2973
- const serverName = relayIdentityOf(relay)?.serverName ?? '服务端';
2974
- return { code: 'remote-unsupported', message: `请选择「${serverName} · …」分组里的模型` };
4421
+ return { code: 'remote-unsupported', message: c.modelGroupPickServer(displayName) };
2975
4422
  }
2976
4423
  if (providerParts.serverId !== sessionParts.serverId) {
2977
- return { code: 'remote-mismatch', message: '此模型分组属于其他主服务端' };
4424
+ return { code: 'remote-mismatch', message: c.modelGroupMismatch };
2978
4425
  }
2979
4426
  return { args: { ...args, request: { ...request, provider: providerParts.id } } };
2980
4427
  }
@@ -3046,13 +4493,30 @@ export function installIntercept(options) {
3046
4493
  const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
3047
4494
  if (!isAsyncIterable(local))
3048
4495
  return local;
3049
- return mergedEventsStream({ local, relay, signal, recordFailure, log, clock, summarySync });
4496
+ if (pool !== undefined) {
4497
+ return mergedEventsStreamMulti({ local, pool, signal, recordFailure, log, clock, summarySync, watchPool });
4498
+ }
4499
+ return mergedEventsStream({ local, relay: singleRelay, signal, recordFailure, log, clock, summarySync });
3050
4500
  })();
3051
4501
  }
3052
4502
  const args = argsOf(payload);
3053
4503
  const fields = CLIENT_METHOD_FIELDS[endpoint];
3054
4504
  const virtuals = collectVirtuals(fields, args);
3055
4505
  if (virtuals.length === 0) {
4506
+ // P55-2: a LOCAL session/follow (a well-formed session address whose id
4507
+ // is not virtual) is the newest fact about which page the user is on —
4508
+ // recorded at the same instant the wrap is reached, so a later image
4509
+ // request never resolves against the remote session just left.
4510
+ if (endpoint === 'session/follow') {
4511
+ const address = isPlainObject(args) && isPlainObject(args.request) ? args.request.address : undefined;
4512
+ if (isPlainObject(address) &&
4513
+ address.kind === 'session' &&
4514
+ typeof address.sessionId === 'string' &&
4515
+ address.sessionId !== '' &&
4516
+ !isVirtual(address.sessionId)) {
4517
+ lastFollowedSession = { kind: 'local' };
4518
+ }
4519
+ }
3056
4520
  if (endpoint === 'workspace/follow' || endpoint === 'session/control') {
3057
4521
  // The startup self-check probe wants the LOCAL baseline only — no
3058
4522
  // remote leg, no merge (its payload is registered in probePayloads).
@@ -3073,12 +4537,32 @@ export function installIntercept(options) {
3073
4537
  const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
3074
4538
  if (!isAsyncIterable(local))
3075
4539
  return local;
4540
+ if (pool !== undefined) {
4541
+ return mergedGlobalStreamMulti({
4542
+ endpoint,
4543
+ namespace,
4544
+ method,
4545
+ local,
4546
+ pool,
4547
+ signal,
4548
+ recordFailure,
4549
+ log,
4550
+ clock,
4551
+ getCurrentSession,
4552
+ watchCurrentSession,
4553
+ watchUninstall,
4554
+ summarySync,
4555
+ createSessionInventorySlot,
4556
+ watchPool,
4557
+ lang: options.lang,
4558
+ });
4559
+ }
3076
4560
  return mergedGlobalStream({
3077
4561
  endpoint,
3078
4562
  namespace,
3079
4563
  method,
3080
4564
  local,
3081
- relay,
4565
+ relay: singleRelay,
3082
4566
  signal,
3083
4567
  recordFailure,
3084
4568
  log,
@@ -3087,30 +4571,50 @@ export function installIntercept(options) {
3087
4571
  watchCurrentSession,
3088
4572
  watchUninstall,
3089
4573
  summarySync,
3090
- createSessionInventorySlot,
4574
+ lang: options.lang,
4575
+ ...(createSessionInventorySlot !== undefined
4576
+ ? { createSessionInventorySlot: () => createSessionInventorySlot(undefined) }
4577
+ : {}),
3091
4578
  });
3092
4579
  })();
3093
4580
  }
3094
4581
  return chainTarget.openWireStream.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow);
3095
4582
  }
3096
4583
  const verdict = validate(endpoint, virtuals);
3097
- if (!verdict.ok)
4584
+ if (!verdict.ok) {
4585
+ // TA-2: a refusal must not leak the host's UplinkInbox — half-close it
4586
+ // before the coded throw, the same courtesy the forwarded path's own
4587
+ // failure paths extend below.
4588
+ releaseUplink(uplink);
3098
4589
  throwRecorded(endpoint, verdict.code, verdict.message);
4590
+ }
3099
4591
  // A WRITE stream while the relay is not serving (CP4, the invoke route's
3100
- // T34 rule applied to the stream route): `terminal/follow` is the one
3101
- // write-shaped STREAM in the table — its attachment takes over the
3102
- // terminal's input control — so opening it into a dead link must refuse
3103
- // here, exactly like every other mutation.
3104
- if (isRemoteWrite(endpoint) && relay.state !== 'online') {
3105
- throwRecorded(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE);
4592
+ // T34 rule applied to the stream route): the write-shaped STREAMS of the
4593
+ // table — `terminal/follow`, and its 0.2.2 duplex successor
4594
+ // `terminal/attach` (REMOTE_UPLINK_STREAM_METHODS) — take over the
4595
+ // terminal's input control, so opening them into a dead link must refuse
4596
+ // here, exactly like every other mutation. MS-3: the TARGET leg's state
4597
+ // is the one that counts.
4598
+ if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
4599
+ releaseUplink(uplink);
4600
+ throwRecorded(endpoint, 'remote-offline', copy().writeOffline);
3106
4601
  }
3107
4602
  // The multiplexed wire channel ALWAYS hands us an UplinkInbox here —
3108
- // never undefined. Remote streams consume no uplink items, so the uplink
3109
- // is half-closed and the stream forwarded (the host's own move for its
3110
- // `$events` subscription, gateway releaseUplink). Rejecting uplinks
3111
- // would reject every remote stream the UI opens.
3112
- releaseUplink(uplink);
4603
+ // never undefined. One-directional remote streams consume no uplink
4604
+ // items, so the uplink is half-closed and the stream forwarded (the
4605
+ // host's own move for its `$events` subscription, gateway releaseUplink).
4606
+ // TA-2: a DUPLEX member of REMOTE_UPLINK_STREAM_METHODS keeps its inbox —
4607
+ // the host's TerminalInput write/resize commands ride it, so the iterable
4608
+ // and the mux's uplink window (shape-checked, garbage dropped) travel to
4609
+ // the relay client's pump instead; the pump releases the iterable itself
4610
+ // when the stream ends. Rejecting uplinks would reject every remote
4611
+ // stream the UI opens.
4612
+ const forwardsUplink = REMOTE_UPLINK_STREAM_METHODS.has(endpoint) && isAsyncIterable(uplink);
4613
+ if (!forwardsUplink)
4614
+ releaseUplink(uplink);
4615
+ const normalizedWindow = forwardsUplink ? normalizeUplinkWindow(uplinkWindow) : undefined;
3113
4616
  const serverId = verdict.serverId;
4617
+ const client = verdict.client;
3114
4618
  const slash = endpoint.indexOf('/');
3115
4619
  const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash);
3116
4620
  const method = slash === -1 ? '' : endpoint.slice(slash + 1);
@@ -3126,9 +4630,18 @@ export function installIntercept(options) {
3126
4630
  // through the outage and reopens the leg when the relay serves again
3127
4631
  // (CP4); every other endpoint's first read fails out with the original
3128
4632
  // code (CP5 scope, the pre-CP4 behavior).
3129
- upstream = relay.openStream(namespace, method, clone, signal);
4633
+ upstream = forwardsUplink
4634
+ ? client.openStream(namespace, method, clone, signal, {
4635
+ uplink: uplink,
4636
+ ...(normalizedWindow !== undefined ? { uplinkWindow: normalizedWindow } : {}),
4637
+ })
4638
+ : client.openStream(namespace, method, clone, signal);
3130
4639
  }
3131
4640
  catch (error) {
4641
+ // TA-2: a synchronous openStream failure (an unpaired relay) strands
4642
+ // the forwarded inbox — the pump never started, so half-close it here.
4643
+ if (forwardsUplink)
4644
+ releaseUplink(uplink);
3132
4645
  const code = error instanceof RelayError ? error.code : 'internal';
3133
4646
  throwRecorded(endpoint, code, messageOf(error));
3134
4647
  }
@@ -3141,7 +4654,27 @@ export function installIntercept(options) {
3141
4654
  // the endpoints OUTSIDE the list end with the original code at once,
3142
4655
  // their consumers rendering a clean end as a terminal (CP5,
3143
4656
  // SESSION_STREAM_HOLD_ENDPOINTS).
3144
- return rewriteUpstream(endpoint, upstream, serverId, virtuals, signal, () => relay.openStream(namespace, method, clone, signal));
4657
+ const held = rewriteUpstream(endpoint, client, upstream, serverId, virtuals, signal, () => client.openStream(namespace, method, clone, signal));
4658
+ // P55-2: a remote session/follow is recorded the instant the wrap is
4659
+ // reached (before any frame, before any browser report) — the first
4660
+ // <img> of the page cannot race a 1s poll. The follow stays in
4661
+ // `activeRemoteFollows` for the generator's whole life and leaves in its
4662
+ // finally, so the map holds exactly the LIVE follows.
4663
+ if (endpoint === 'session/follow') {
4664
+ const claimed = virtuals[0];
4665
+ lastFollowedSession = { kind: 'remote', serverId: claimed.serverId, sessionId: claimed.id };
4666
+ const token = Symbol();
4667
+ activeRemoteFollows.set(token, { serverId: claimed.serverId, sessionId: claimed.id });
4668
+ return (async function* () {
4669
+ try {
4670
+ yield* held;
4671
+ }
4672
+ finally {
4673
+ activeRemoteFollows.delete(token);
4674
+ }
4675
+ })();
4676
+ }
4677
+ return held;
3145
4678
  };
3146
4679
  gateway.openWireStream = wrappedOpen;
3147
4680
  gateway.dispatchRpc = wrappedDispatch;
@@ -3153,12 +4686,24 @@ export function installIntercept(options) {
3153
4686
  if (!installed)
3154
4687
  return;
3155
4688
  installed = false;
4689
+ // P55-2: the attribution state dies with the wrap — the fetch-route
4690
+ // half reads the wiring's live `interceptHandle` slot, which the
4691
+ // uninstall clears, so this is hygiene; a stale map must never
4692
+ // outlive the interception that fed it.
4693
+ remoteSessionCwds.clear();
4694
+ activeRemoteFollows.clear();
4695
+ lastFollowedSession = undefined;
3156
4696
  for (const listener of [...uninstallListeners])
3157
4697
  listener();
3158
4698
  // The catalog watcher dies with the wrap (T52-fix2): a late online
3159
- // transition after this point belongs to another generation.
4699
+ // transition after this point belongs to another generation. MS-5: in
4700
+ // pool mode that includes every per-entry client subscription the
4701
+ // rescan created — the rescan's own unsubscribe is not enough.
3160
4702
  offCatalogWatch?.();
3161
4703
  offCatalogWatch = undefined;
4704
+ for (const unsubscribe of catalogWatched.values())
4705
+ unsubscribe();
4706
+ catalogWatched.clear();
3162
4707
  for (const name of ['openWireStream', 'dispatchRpc']) {
3163
4708
  // Only replace/delete while the property still holds OUR wrapper.
3164
4709
  // A later plugin may have wrapped OVER us: its wrapper captured this
@@ -3198,6 +4743,14 @@ export function installIntercept(options) {
3198
4743
  if (virtualIds.length > 0)
3199
4744
  summarySync.noteForwardedSessionIds(virtualIds);
3200
4745
  },
4746
+ notePoolEvent() {
4747
+ // MS-3: broadcast to every pool watcher — the live merged streams'
4748
+ // re-scans and the pool-mode catalog watcher (already registered as
4749
+ // one of the listeners in pool mode). No-op in single-server mode.
4750
+ for (const listener of [...poolListeners])
4751
+ listener();
4752
+ },
4753
+ resolveRemoteFileScope,
3201
4754
  };
3202
4755
  }
3203
4756
  /**