dsh-zen-remote 2.3.10 → 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 +44 -72
  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 +573 -366
  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 +575 -370
  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/src/intercept.ts CHANGED
@@ -81,20 +81,23 @@
81
81
 
82
82
  import { RelayError, defaultClock } from './relay-client.js'
83
83
  import type { RelayClient, RelayClock, RelayState } from './relay-client.js'
84
+ import type { RelayPool } from './relay-pool.js'
84
85
  import { checkGatewayShape } from './intercept-shape.js'
85
86
  import type { GatewayShapeCheck } from './intercept-shape.js'
86
87
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
87
88
  import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary, type ResultAttachment } from './invoke-binary.js'
88
89
  import {
90
+ composeMergedWorkspaceFrames,
89
91
  createControlMerger,
90
92
  createWorkspaceMerger,
93
+ localWorkspaceLists,
91
94
  mergeModelCatalogs,
92
95
  mergeSessionList,
93
96
  sessionSummaryAddedFrames,
94
97
  virtualSessionIdsInWorkspaceFrames,
95
98
  virtualizeModelSelectionValue,
96
99
  } from './merge-streams.js'
97
- import type { ControlMerger, MergerAnnotation, MergerIdentity, WorkspaceMerger } from './merge-streams.js'
100
+ import type { ControlMerger, MergerAnnotation, MergerIdentity, MergerLang, RemoteWorkspaceContribution, WorkspaceMerger } from './merge-streams.js'
98
101
 
99
102
  /** The session-locating argument fields, as registered per method. Identical
100
103
  * in name and meaning to relay-access.ts's `SessionField`; a field named
@@ -203,6 +206,11 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
203
206
  'terminal/rename': ['agentId'],
204
207
  'terminal/close': ['agentId'],
205
208
  'terminal/follow': ['agentId'],
209
+ // TA-1: the 0.2.2 duplex stream (follow+write+resize merged), located by
210
+ // the same agentId. TA-2 wired the interceptor half: the host's uplink
211
+ // inbox and its window travel to the relay's openStream options (see
212
+ // REMOTE_UPLINK_STREAM_METHODS) instead of being half-closed.
213
+ 'terminal/attach': ['agentId'],
206
214
  // the retain/list half locates by a plain top-level json sessionId
207
215
  'terminal/list': ['sessionId'],
208
216
  'terminal/retain': ['sessionId'],
@@ -344,12 +352,54 @@ export function isRemoteWrite(endpoint: string): boolean {
344
352
  return !REMOTE_READ_METHODS.has(endpoint)
345
353
  }
346
354
 
347
- /** The refusal a write gets while the relay is not serving (T34). */
348
- const WRITE_OFFLINE_MESSAGE = '服务端离线,远程会话暂时只读'
355
+ /**
356
+ * Stream endpoints whose caller-supplied `uplink` iterable and
357
+ * `uplinkWindow` travel to the relay instead of being half-closed locally
358
+ * with `releaseUplink`. DSH 0.2.2+ replaced `terminal/follow|write|resize`
359
+ * with the duplex `terminal/attach` stream (`TerminalInput` write/resize
360
+ * commands ride the uplink; `ack` frames ride the downlink). The relay
361
+ * client's pump (relay-client.ts) is the other half: it forwards the host's
362
+ * frames through the server's `/v1/stream-uplink` channel and releases the
363
+ * host inbox itself when the stream ends — the interceptor only hands the
364
+ * iterable over, it never half-closes a member of this set.
365
+ */
366
+ export const REMOTE_UPLINK_STREAM_METHODS: ReadonlySet<string> = new Set(['terminal/attach'])
367
+
368
+ export type InterceptLang = MergerLang
369
+
370
+ const INTERCEPT_COPY = {
371
+ zh: {
372
+ writeOffline: '服务端离线,远程会话暂时只读',
373
+ closed: '远程已关闭',
374
+ notConnected: '尚未连接到服务端',
375
+ mismatch: '此远程会话属于其他主服务端',
376
+ unsupported: '此功能暂不支持远程会话',
377
+ referenceNotOnServer: '引用的会话不在服务端上,无法转发',
378
+ revoked: '配对令牌已被吊销,请在设置页重新配对',
379
+ unpaired: '尚未与服务端配对,请在设置页完成配对',
380
+ modelGroupRemote: '该模型分组属于远程服务端,无法在本机会话中使用',
381
+ modelGroupPickServer: (displayName: string) => `请选择「${displayName} · …」分组里的模型`,
382
+ modelGroupMismatch: '此模型分组属于其他主服务端',
383
+ },
384
+ en: {
385
+ writeOffline: 'Server offline — remote session is temporarily read-only',
386
+ closed: 'Remote closed',
387
+ notConnected: 'Not connected to the server yet',
388
+ mismatch: 'This remote session belongs to another server',
389
+ unsupported: 'This feature is not supported in remote sessions yet',
390
+ referenceNotOnServer: 'Referenced session is not on the server and cannot be forwarded',
391
+ revoked: 'Pairing token was revoked — please pair again in Settings',
392
+ unpaired: 'Not paired with the server yet — please pair in Settings',
393
+ modelGroupRemote: 'This model group belongs to a remote server and cannot be used in a local session',
394
+ modelGroupPickServer: (displayName: string) => `Select a model under "${displayName} · …"`,
395
+ modelGroupMismatch: 'This model group belongs to another server',
396
+ },
397
+ } as const
349
398
 
350
- /** What a call or stream on a closed remote session answers in place of the
351
- * relay's own text (T73) — the banner already says why. */
352
- const CLOSED_MESSAGE = '远程已关闭'
399
+ function resolveInterceptLang(lang: InterceptLang | (() => InterceptLang) | undefined): InterceptLang {
400
+ const value = typeof lang === 'function' ? lang() : lang
401
+ return value === 'en' ? 'en' : 'zh'
402
+ }
353
403
 
354
404
  /**
355
405
  * The RelayError codes that mean "the LINK went down under a live stream"
@@ -518,6 +568,26 @@ function releaseUplink(uplink: unknown): void {
518
568
  }
519
569
  }
520
570
 
571
+ /**
572
+ * The shape check one duplex stream's `uplinkWindow` must pass before it
573
+ * travels (TA-2) — the same rule the server re-applies on `/v1/stream`
574
+ * (relay-server.ts): positive safe-integer `maxBytes`, non-negative
575
+ * safe-integer `frameOverheadBytes`. Anything else — an older host's
576
+ * `undefined`, a non-object, a fractional or negative budget — comes back
577
+ * `undefined` and the stream forwards with the relay's own default window:
578
+ * the numbers are the host's contract with itself, garbage is dropped, never
579
+ * refused.
580
+ */
581
+ function normalizeUplinkWindow(raw: unknown): { maxBytes: number; frameOverheadBytes: number } | undefined {
582
+ if (!isPlainObject(raw)) return undefined
583
+ const { maxBytes, frameOverheadBytes } = raw
584
+ if (typeof maxBytes !== 'number' || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) return undefined
585
+ if (typeof frameOverheadBytes !== 'number' || !Number.isSafeInteger(frameOverheadBytes) || frameOverheadBytes < 0) {
586
+ return undefined
587
+ }
588
+ return { maxBytes, frameOverheadBytes }
589
+ }
590
+
521
591
  /** One remote-call failure in the diagnostics ring. */
522
592
  export interface InterceptFailureRecord {
523
593
  /** ISO timestamp of the moment the failure was recorded. */
@@ -622,12 +692,21 @@ export interface InstallInterceptOptions {
622
692
  /** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not
623
693
  * the cordis proxy — own properties on the proxy are ignored by everyone. */
624
694
  raw: object
625
- /** The client relay client (T23a) the calls travel through. */
626
- relay: RelayClient
627
- /** The CURRENT handshake's server id, read live per call; `undefined`
628
- * (never handshook) makes every remote call a `remote-offline` — a
629
- * different fact from pointing at the wrong server (`remote-mismatch`). */
630
- getServerId: () => string | undefined
695
+ /** SINGLE-SERVER mode (the original wiring, kept for compatibility): the
696
+ * one client relay client the calls travel through. Either this +
697
+ * {@link getServerId}, or {@link pool} — never required together. */
698
+ relay?: RelayClient
699
+ /** SINGLE-SERVER mode: the CURRENT handshake's server id, read live per
700
+ * call; `undefined` (never handshook) makes every remote call a
701
+ * `remote-offline` — a different fact from pointing at the wrong server
702
+ * (`remote-mismatch`). */
703
+ getServerId?: () => string | undefined
704
+ /** MS-3 POOL mode: the multi-server relay pool (up to
705
+ * `MAX_PAIRED_SERVERS` paired servers). When present it replaces the
706
+ * single-relay routing: every call/stream/event routes by the virtual
707
+ * id's `serverId` to the matching pool entry, and the three global reads
708
+ * plus the model catalog merge ALL paired servers in pairing order. */
709
+ pool?: RelayPool
631
710
  /** Progress logging, wired to the context logger by index.ts. */
632
711
  log?: (format: string, ...args: unknown[]) => void
633
712
  /** Clock/timers for the merged streams' reopen backoff (CP4); defaults to
@@ -645,10 +724,15 @@ export interface InstallInterceptOptions {
645
724
  /** PB-25b: the inventory slot factory — one slot per merged global stream
646
725
  * (only the workspace merger ever reports; the control merger has no
647
726
  * session inventory). The wiring unions the live slots' shares into the
648
- * set the plugin-route rewrite reads. Absent: no shares are ever reported
649
- * and the plugin-route value scan stays exactly the request-local
650
- * behavior. */
651
- createSessionInventorySlot?: () => SessionInventorySlot
727
+ * set the plugin-route rewrite consults. MS-3: the factory receives the
728
+ * slot's `serverId`, so a pool wiring can keep per-server inventories
729
+ * (the plain single-server wiring can ignore the argument). Absent: no
730
+ * shares are ever reported and the plugin-route value scan stays exactly
731
+ * the request-local behavior. */
732
+ createSessionInventorySlot?: (serverId?: string) => SessionInventorySlot
733
+ /** Language for error messages and sidebar group suffixes (`'zh'` default,
734
+ * `'en'` English). Accepts a getter for live resolution. */
735
+ lang?: InterceptLang | (() => InterceptLang) | undefined
652
736
  }
653
737
 
654
738
  export interface InterceptHandle {
@@ -677,6 +761,20 @@ export interface InterceptHandle {
677
761
  * entries are dropped; a batch with nothing left is a no-op. Reaches every
678
762
  * live merged `$events` stream, and the pending slot when none is open. */
679
763
  nudgeSummarySync(ids: readonly string[]): void
764
+ /** MS-3 (pool mode only meaningful, harmless otherwise): the pool's entry
765
+ * set or any entry's identity moved — the wiring forwards the pool's
766
+ * `onServerRemoved` / `onStateChange` callbacks here. Every live merged
767
+ * stream re-scans `pool.entries()` NOW: slots for removed entries leave
768
+ * (only THAT server's shown groups and pending events are torn down),
769
+ * slots for new entries start, and renames re-title the groups. */
770
+ notePoolEvent(): void
771
+ /** P55-2: resolve which remote session (serverId + real sessionId) should
772
+ * serve a path-only `/api/file?path=...` request, or `undefined` when the
773
+ * local route should handle it. Read by the fetch-route wrap (its media
774
+ * mount only exists while the wiring hands THIS handle's method over), so
775
+ * a Markdown image of a remote session's reply loads from the server's
776
+ * disk instead of 404ing on the sub-client's own. */
777
+ resolveRemoteFileScope(path: string): { serverId: string; sessionId: string } | undefined
680
778
  }
681
779
 
682
780
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -1369,6 +1467,7 @@ interface MergedGlobalStreamDeps {
1369
1467
  * {@link SessionInventorySlot}). One slot per merged stream, disposed with
1370
1468
  * it; absent when the wiring passed no factory. */
1371
1469
  createSessionInventorySlot?: () => SessionInventorySlot
1470
+ lang?: InterceptLang | (() => InterceptLang) | undefined
1372
1471
  }
1373
1472
 
1374
1473
  /**
@@ -1413,6 +1512,7 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1413
1512
  serverName: initial?.serverName ?? '',
1414
1513
  onDiagnostic,
1415
1514
  now: clock.now,
1515
+ lang: deps.lang,
1416
1516
  ...(sessionSlot !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => sessionSlot.replace(ids) } : {}),
1417
1517
  })
1418
1518
 
@@ -1470,7 +1570,13 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1470
1570
  // away, and a navigation writes no relay frames. An `unavailable` read is
1471
1571
  // skipped entirely: the merger falls back to the conservative tombstone
1472
1572
  // behavior then.
1573
+ let lastLang: InterceptLang = resolveInterceptLang(deps.lang)
1473
1574
  const informCurrentSession = (): void => {
1575
+ const nextLang = resolveInterceptLang(deps.lang)
1576
+ if (nextLang !== lastLang) {
1577
+ lastLang = nextLang
1578
+ for (const frame of merger.onStatusChanged()) channel.push(frame)
1579
+ }
1474
1580
  const read = deps.getCurrentSession()
1475
1581
  if (read.kind === 'unavailable') return
1476
1582
  for (const frame of merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)) channel.push(frame)
@@ -2495,241 +2601,1490 @@ async function* mergedEventsStream(deps: MergedEventsStreamDeps): AsyncGenerator
2495
2601
  }
2496
2602
  }
2497
2603
 
2604
+ // ---- MS-3: the pool-mode merged streams ------------------------------------
2605
+ //
2606
+ // The pool-mode counterparts of the two merged streams above. They keep the
2607
+ // SAME outer contract (the local stream is the base; a remote death emits
2608
+ // nothing; the hold/reopen discipline) but run ONE leg per pooled server:
2609
+ // every slot is keyed by its handshake `serverId`, the slot set is
2610
+ // re-scanned on every pool event and every slot's own state change, and the
2611
+ // canonical merged frames compose the slots' contributions in PAIRING order
2612
+ // (pool.entries() order — the connect order never reorders the sidebar).
2613
+ // A removed entry tears down only ITS slot: only that server's shown
2614
+ // workspace groups leave the sidebar and only that server's pending
2615
+ // approvals get the synthesized cancels.
2616
+
2617
+ interface PoolGlobalSlot {
2618
+ kind: 'workspace' | 'control'
2619
+ serverId: string
2620
+ entryId: string
2621
+ client: RelayClient
2622
+ merger: WorkspaceMerger | ControlMerger
2623
+ /** The pool display name this slot's merger is currently titled with. */
2624
+ viewName: string
2625
+ controller: AbortController | undefined
2626
+ offState: () => void
2627
+ annotation: MergerAnnotation
2628
+ reopenDelayMs: number
2629
+ removed: boolean
2630
+ /** PB-25b, pool form: one inventory slot per WORKSPACE server, disposed
2631
+ * with the slot (the single-server stream's slot dies with the stream). */
2632
+ inventory: SessionInventorySlot | undefined
2633
+ }
2634
+
2635
+ interface MergedGlobalStreamMultiDeps {
2636
+ endpoint: 'workspace/follow' | 'session/control'
2637
+ namespace: string
2638
+ method: string
2639
+ local: AsyncIterable<unknown>
2640
+ pool: RelayPool
2641
+ signal: AbortSignal | undefined
2642
+ recordFailure: (endpoint: string, code: string) => void
2643
+ log: ((format: string, ...args: unknown[]) => void) | undefined
2644
+ clock: RelayClock
2645
+ getCurrentSession: () => CurrentSessionRead
2646
+ watchCurrentSession(fn: () => void): () => void
2647
+ watchUninstall(fn: () => void): () => void
2648
+ summarySync: SessionSummarySync
2649
+ createSessionInventorySlot?: (serverId?: string) => SessionInventorySlot
2650
+ /** Subscribe to the wiring's pool-event broadcasts (handle.notePoolEvent).
2651
+ * Every broadcast re-scans the slot set. */
2652
+ watchPool(fn: () => void): () => void
2653
+ lang?: InterceptLang | (() => InterceptLang) | undefined
2654
+ }
2655
+
2498
2656
  /**
2499
- * Install the two own-property wrappers on the raw gateway. Assumes
2500
- * {@link checkGatewayShape} passed (the wiring gates on it) — this function
2501
- * records the verdict but does not re-gate, so it stays usable in tests
2502
- * that install over deliberately odd shapes.
2657
+ * The pool-mode merged global stream. Per server slot the same rules as the
2658
+ * single-server merged stream apply; the slots' outputs differ only in the
2659
+ * GLOBAL frames (`order` / `archived` / `pinned` and the baseline's merged
2660
+ * lists), which are never taken from a slot's own merger — they are
2661
+ * composed canonically from every slot's contribution in pairing order
2662
+ * (composeMergedWorkspaceFrames). Per-workspace frames (upserts and
2663
+ * removes) are slot-local facts and pass as the merger produced them.
2503
2664
  */
2504
- export function installIntercept(options: InstallInterceptOptions): InterceptHandle {
2505
- const { raw, relay, getServerId, log } = options
2506
- const clock = options.clock ?? defaultClock
2507
- // T63: the summary-sync hub shared by the merged streams — the workspace
2508
- // route nudges, the events leg fetches-and-announces. One per install: it
2509
- // outlives individual stream generations so a nudge from a workspace leg
2510
- // reaches whichever $events generation is open.
2511
- const summarySync = createSessionSummarySync()
2512
- // PB-25b: the inventory slot factory (the wiring's union feed) — passed
2513
- // down to every merged stream, which owns one slot for its lifetime.
2514
- const createSessionInventorySlot = options.createSessionInventorySlot
2515
- // T62: the current-session getter and the live-merged-stream listener
2516
- // registry behind handle.currentSessionChanged().
2517
- const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
2518
- const currentSessionListeners = new Set<() => void>()
2519
- const watchCurrentSession = (fn: () => void): (() => void) => {
2520
- currentSessionListeners.add(fn)
2521
- return () => { currentSessionListeners.delete(fn) }
2665
+ async function* mergedGlobalStreamMulti(deps: MergedGlobalStreamMultiDeps): AsyncGenerator<unknown, void, undefined> {
2666
+ const { endpoint, namespace, method, local, pool, signal, recordFailure, log, clock } = deps
2667
+ if (signal?.aborted === true) return
2668
+ const isWorkspace = endpoint === 'workspace/follow'
2669
+ const onDiagnostic = (message: string): void => {
2670
+ log?.('client multi merge dropped a frame on %s: %s', endpoint, message)
2522
2671
  }
2523
- const uninstallListeners = new Set<() => void>()
2524
- const watchUninstall = (fn: () => void): (() => void) => {
2525
- uninstallListeners.add(fn)
2526
- return () => { uninstallListeners.delete(fn) }
2672
+ const channel = createFrameChannel()
2673
+ let alive = true
2674
+ const slots = new Map<string, PoolGlobalSlot>()
2675
+ const onlineWaiters: (() => void)[] = []
2676
+ const flushWaiters = (): void => {
2677
+ for (const wake of onlineWaiters.splice(0)) wake()
2527
2678
  }
2528
- const gateway = raw as Record<string | symbol, unknown>
2529
- const shape = checkGatewayShape(raw)
2530
- const counters = { openWireStream: 0, dispatchRpc: 0 }
2531
- const failures: InterceptFailureRecord[] = []
2532
- const incompatible: IncompatibleCallRecord[] = []
2533
- // The closed-session registry (T34): insertion-ordered, capped, virtual-id
2534
- // keyed. Entries live until THEIR session proves reachable again (a
2535
- // succeeding call for it) — a reconnect clears nothing (T34-fix): a link
2536
- // that flapped under a still-standing closure would make the banner flicker
2537
- // away and back.
2538
- const closedSessions = new Map<string, ClosedSessionRecord['reason']>()
2539
- // T73-fix: waiters for "a closed-session record was just cleared" — the
2540
- // quiet session/follow hold (rewriteUpstream) ends its silence the moment
2541
- // the session it holds for is served again, so a re-enabled remote comes
2542
- // back with a fresh history instead of staying blind under its banner.
2543
- const closedClearWaiters = new Set<() => void>()
2544
- // The last successfully fetched SERVER model catalog (T52-fix), kept for
2545
- // the offline merges of the `session/modelCatalog` route below — cleared
2546
- // on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
2547
- // own drop is the backstop) and never served for another server's id (see the
2548
- // route's doc). Only an in-memory value: the groups are display data and
2549
- // a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
2550
- // the modelSelection provider rewrites are unconditional again (the host's
2551
- // projection store is first-write-wins per seq, so a catalog-gated rewrite
2552
- // would poison the store — see virtualizeModelSelectionValue), and the
2553
- // `groups` membership set is gone with the gate.
2554
- let serverCatalogCache: { identity: MergerIdentity; value: unknown } | undefined
2555
- /**
2556
- * The serving generation, bumped on every pairing wall (`unpaired` /
2557
- * `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
2558
- * rose must not write its answer back — the wall's groups must not outlive
2559
- * it, and a late write would resurrect them under the offline merge below.
2560
- * Both cache writes (the proactive fetch here, the route's own fetch)
2561
- * snapshot the counter before invoking and compare before storing; a
2562
- * mismatched answer is silently dropped — it is not a fault, the next
2563
- * serving period fetches fresh anyway.
2564
- */
2565
- let catalogGeneration = 0
2566
- const rememberServerCatalog = (identity: MergerIdentity, value: unknown, generation: number): void => {
2567
- if (generation !== catalogGeneration) return
2568
- serverCatalogCache = { identity, value }
2679
+ const waitAny = async (): Promise<void> => {
2680
+ await new Promise<void>((resolve) => {
2681
+ onlineWaiters.push(resolve)
2682
+ })
2569
2683
  }
2570
- /** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
2571
- * the proactive pull that runs as soon as the relay serves — and once at
2572
- * install when the relay already serves (subscribe replays no state —
2573
- * relay-client.ts setState returns on a same-state write — so the
2574
- * subscription alone would miss an install-into-online). Failures are
2575
- * diagnostics-ring entries, nothing else (the route's own fetch is the
2576
- * user-visible path and answers local-only there). */
2577
- const fetchServerCatalog = (identity: MergerIdentity): void => {
2578
- const generation = catalogGeneration
2579
- void relay.invoke('session', 'modelCatalog', {}, undefined).then(
2580
- (value) => {
2581
- rememberServerCatalog(identity, value, generation)
2582
- },
2583
- (error: unknown) => {
2584
- recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
2585
- },
2586
- )
2684
+ // The orchestrator's LOCAL truth, folded from every local frame — the one
2685
+ // source the canonical composition reads its local lists from (a slot's
2686
+ // merger keeps its own copy only for its discarded single-server frames).
2687
+ let localSeen = false
2688
+ let localBaselineFrame: unknown = undefined
2689
+ let localLists = { orderIds: [], archivedIds: [], pinnedIds: [] } as { orderIds: string[]; archivedIds: string[]; pinnedIds: string[] }
2690
+
2691
+ const annotationOf = (client: RelayClient): MergerAnnotation => {
2692
+ const state = client.state
2693
+ if (state === 'revoked') return 'revoked'
2694
+ if (state === 'unpaired') return 'unpaired'
2695
+ if (state !== 'online') return 'offline'
2696
+ const different = client.compat?.different
2697
+ return different !== undefined && different.length > 0 ? 'mismatch' : 'none'
2587
2698
  }
2588
- // The catalog watcher (T52-fix2). The subscription fires on the real
2589
- // online transitions (first handshake, every recovery) — and since T59
2590
- // also on name-only notifications, which arrive with the state still
2591
- // `online` and are deduped by the same refreshKey/stateSeen gates above.
2592
- // The install-time check below covers an install into an already-online
2593
- // relay. `unpaired` / `revoked`
2594
- // drop the cache the same way the catalog route does lazily — the wall's
2595
- // groups must not outlive it — and bump the generation so an in-flight
2596
- // fetch cannot write the old catalog back (T52-fix3). Faults stay
2597
- // contained: the sync body cannot throw, and the fetch settles through
2598
- // .then/.catch above.
2599
- let offCatalogWatch: (() => void) | undefined
2600
- try {
2601
- offCatalogWatch = relay.subscribe((state) => {
2602
- if (state === 'unpaired' || state === 'revoked') {
2603
- catalogGeneration += 1
2604
- serverCatalogCache = undefined
2605
- return
2606
- }
2607
- if (state !== 'online') return
2608
- const identity = relayIdentityOf(relay)
2609
- if (identity !== undefined) fetchServerCatalog(identity)
2610
- })
2611
- // Install into an already-serving relay: subscribe replayed nothing, so
2612
- // the transition-based fetch would wait for the NEXT flap — fill the
2613
- // cache now instead (T52-fix3).
2614
- if (relay.state === 'online') {
2615
- const identity = relayIdentityOf(relay)
2616
- if (identity !== undefined) fetchServerCatalog(identity)
2699
+ const workspaceMergerOf = (slot: PoolGlobalSlot): WorkspaceMerger | undefined =>
2700
+ slot.kind === 'workspace' ? (slot.merger as WorkspaceMerger) : undefined
2701
+
2702
+ const pushOut = (frames: readonly unknown[]): void => {
2703
+ if (frames.length === 0) return
2704
+ for (const frame of frames) channel.push(frame)
2705
+ if (isWorkspace) {
2706
+ const ids = virtualSessionIdsInWorkspaceFrames(frames)
2707
+ if (ids.length > 0) deps.summarySync.noteForwardedSessionIds(ids)
2617
2708
  }
2618
- } catch (error) {
2619
- // Subscription failed: the cache still fills whenever the UI opens a
2620
- // dropdown (the route's own fetch), only later.
2621
- log?.('client catalog watcher unavailable: %s', messageOf(error))
2622
2709
  }
2623
- const registerClosed = (sessionId: string, reason: ClosedSessionRecord['reason']): void => {
2624
- closedSessions.delete(sessionId)
2625
- if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
2626
- const oldest = closedSessions.keys().next()
2627
- if (oldest.done !== true) closedSessions.delete(oldest.value)
2710
+ const orderedRemoteParts = (): { slot: PoolGlobalSlot; remote: RemoteWorkspaceContribution }[] => {
2711
+ const out: { slot: PoolGlobalSlot; remote: RemoteWorkspaceContribution }[] = []
2712
+ if (!isWorkspace) return out
2713
+ for (const entry of pool.entries()) {
2714
+ if (entry.serverId === undefined) continue
2715
+ const slot = slots.get(entry.serverId)
2716
+ const merger = slot !== undefined ? workspaceMergerOf(slot) : undefined
2717
+ if (slot === undefined || merger === undefined) continue
2718
+ out.push({ slot, remote: merger.parts().remote })
2628
2719
  }
2629
- closedSessions.set(sessionId, reason)
2720
+ return out
2630
2721
  }
2631
- const clearClosed = (virtuals: VirtualParts[]): void => {
2632
- let cleared = false
2633
- for (const parts of virtuals) {
2634
- if (closedSessions.delete(toVirtual(parts.serverId, parts.id))) cleared = true
2635
- }
2636
- if (cleared) for (const waiter of [...closedClearWaiters]) waiter()
2722
+ /** The canonical merged global frames over ALL current slots — pairing
2723
+ * order, local first. Only past the local baseline: before it the slots
2724
+ * cache their remote state and the frames would order workspaces the
2725
+ * local baseline would immediately erase. */
2726
+ const composedFrames = (): { order: unknown; archived: unknown; pinned: unknown } | undefined => {
2727
+ if (!localSeen || !isWorkspace) return undefined
2728
+ const parts = orderedRemoteParts()
2729
+ return composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote))
2637
2730
  }
2638
- let installed = true
2639
- let selfCheck: SelfCheckResult | undefined
2640
-
2641
- /**
2642
- * Reload overlay, for the record (CP4): the theoretical hazard is installing
2643
- * a NEW wrapper while an OLD one is still installed, so each captures the
2644
- * other and the older survives as a live layer. Cordis `fiber.restart()`
2645
- * disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
2646
- * restoring the saved property) and only then applies the new row — the
2647
- * overlay ordering never happens today, so there is nothing to absorb at
2648
- * install time. Detection is also not cheaply implementable: a reloaded row
2649
- * loads a FRESH module instance, so a stale wrapper shares no mark with the
2650
- * new one — telling "ours, from a dead generation" apart from another
2651
- * plugin's wrapper would take a process-wide registry, which the comment
2652
- * above argues is not worth it while the ordering cannot occur. The
2653
- * `installed === false → inert passthrough` rule below is the safety net
2654
- * for the ordering that CAN happen: a later plugin wrapping OVER us and
2655
- * keeping a reference past our uninstall.
2656
- *
2657
- * Save the CURRENT value per property (possibly another plugin's wrapper —
2658
- * shape note "own-property"), falling back to the prototype method only
2659
- * when the current value is not callable. Uninstall restores the saved
2660
- * own state verbatim: value if there was one, absence if not.
2661
- */
2662
- const saved: Record<string, { existed: boolean; value: unknown }> = {}
2663
- const chainTarget: Record<string, unknown> = {}
2664
- for (const name of ['openWireStream', 'dispatchRpc']) {
2665
- const existed = Object.prototype.hasOwnProperty.call(gateway, name)
2666
- const value = existed ? gateway[name] : undefined
2667
- saved[name] = { existed, value }
2668
- chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name]
2731
+ const pushCanonicalTrio = (): void => {
2732
+ const composed = composedFrames()
2733
+ if (composed !== undefined) pushOut([composed.order, composed.archived, composed.pinned])
2669
2734
  }
2670
- // Filled in below, once the wrapper functions exist: uninstall may only
2671
- // touch a property that still holds OUR wrapper.
2672
- const wrappers: Record<string, unknown> = {}
2673
2735
 
2674
- const recordFailure = (endpoint: string, code: string): void => {
2675
- if (failures.length >= MAX_FAILURES) failures.shift()
2676
- failures.push({ time: new Date().toISOString(), endpoint, code })
2677
- // T42: validation-shaped refusals additionally land in the incompatible
2678
- // ring — every remote failure funnels through this one function, so the
2679
- // classification cannot drift between the invoke and stream routes.
2680
- if (!INCOMPATIBLE_CALL_CODES.has(code)) return
2681
- if (incompatible.length >= MAX_INCOMPATIBLE_CALLS) incompatible.shift()
2682
- incompatible.push({ time: Date.now(), endpoint, code })
2736
+ // -- current-session judging (T58/T62), across every slot ------------------
2737
+ let lastLang: InterceptLang = resolveInterceptLang(deps.lang)
2738
+ const informCurrentSession = (): void => {
2739
+ const nextLang = resolveInterceptLang(deps.lang)
2740
+ if (nextLang !== lastLang) {
2741
+ lastLang = nextLang
2742
+ if (isWorkspace) {
2743
+ let langUpserted = false
2744
+ for (const slot of [...slots.values()]) {
2745
+ const merger = workspaceMergerOf(slot)
2746
+ if (merger === undefined) continue
2747
+ const frames = merger.onStatusChanged()
2748
+ if (frames.length > 0) {
2749
+ pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'))
2750
+ langUpserted = true
2751
+ }
2752
+ }
2753
+ if (langUpserted) pushCanonicalTrio()
2754
+ }
2755
+ }
2756
+ const read = deps.getCurrentSession()
2757
+ if (read.kind === 'unavailable') return
2758
+ let changed = false
2759
+ for (const slot of [...slots.values()]) {
2760
+ if (slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined).length > 0) changed = true
2761
+ }
2762
+ if (!changed || !localSeen || !isWorkspace) return
2763
+ const parts = orderedRemoteParts()
2764
+ pushOut([composeMergedWorkspaceFrames(localLists, parts.map((part) => part.remote)).archived])
2683
2765
  }
2684
-
2685
- const failEnvelope = (
2686
- endpoint: string,
2687
- code: string,
2688
- text: string,
2689
- ): { ok: false; error: { code: string; message: string; details: Record<string, unknown> } } => {
2690
- recordFailure(endpoint, code)
2691
- // `details: {}` is load-bearing: the UI's server-response parser throws
2692
- // `invalid server-response failure` on a details-less error (and the
2693
- // gateway's rpcErrorSchema demands the same shape).
2694
- return { ok: false, error: { code, message: text, details: {} } }
2766
+ const anyPendingHide = (): boolean => {
2767
+ for (const slot of [...slots.values()]) {
2768
+ if (slot.merger.hasPendingHide) return true
2769
+ }
2770
+ return false
2771
+ }
2772
+ let sessionPoll: unknown = undefined
2773
+ const pollCurrentSession = (): void => {
2774
+ sessionPoll = undefined
2775
+ if (!alive) return
2776
+ if (anyPendingHide()) informCurrentSession()
2777
+ if (alive && anyPendingHide()) sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS)
2778
+ }
2779
+ const ensureSessionPoll = (): void => {
2780
+ if (sessionPoll === undefined && alive && anyPendingHide()) {
2781
+ sessionPoll = clock.setTimeout(pollCurrentSession, CURRENT_SESSION_POLL_MS)
2782
+ }
2695
2783
  }
2696
2784
 
2697
- /**
2698
- * The shared validation both routes apply before forwarding: the relay
2699
- * client must have completed a handshake (else `remote-offline` — nothing
2700
- * is connected yet, which is not the same as being pointed at the wrong
2701
- * server), every virtual id in the call must belong to ONE server, and to
2702
- * the server the handshake named (`remote-mismatch`); the method must be
2703
- * in the table (`remote-unsupported`). A refusal is recorded once, here,
2704
- * by the caller passing it through {@link failEnvelope}.
2705
- */
2706
- type Verdict = { ok: true; serverId: string } | { ok: false; code: string; message: string }
2707
- function validate(endpoint: string, virtuals: VirtualParts[]): Verdict {
2708
- const first = virtuals[0].serverId
2709
- const current = getServerId()
2710
- if (current === undefined) return { ok: false, code: 'remote-offline', message: '尚未连接到服务端' }
2711
- if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
2712
- return { ok: false, code: 'remote-mismatch', message: '此远程会话属于其他主服务端' }
2785
+ // -- slot lifecycle ----------------------------------------------------------
2786
+ const removeSlot = (serverId: string, slot: PoolGlobalSlot): void => {
2787
+ slots.delete(serverId)
2788
+ slot.removed = true
2789
+ slot.offState()
2790
+ slot.controller?.abort()
2791
+ if (slot.inventory !== undefined) {
2792
+ slot.inventory.dispose()
2793
+ slot.inventory = undefined
2713
2794
  }
2714
- if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
2715
- return { ok: false, code: 'remote-unsupported', message: '此功能暂不支持远程会话' }
2795
+ // Only THIS server's shown groups leave; the canonical trio then re-airs
2796
+ // the merged order/archived/pinned over the surviving slots (the
2797
+ // merger's own trailing local-only restores are superseded by it).
2798
+ if (!isWorkspace) return
2799
+ const merger = slot.merger as WorkspaceMerger
2800
+ const gone = merger.onRemoteGone()
2801
+ pushOut(gone.filter((frame) => isPlainObject(frame) && frame.type === 'remove'))
2802
+ pushCanonicalTrio()
2803
+ }
2804
+ const applySlotAnnotation = (slot: PoolGlobalSlot, next: MergerAnnotation): void => {
2805
+ if (next === slot.annotation) return
2806
+ slot.annotation = next
2807
+ slot.merger.setStatus(next)
2808
+ if (!isWorkspace) return
2809
+ const merger = slot.merger as WorkspaceMerger
2810
+ const frames = merger.onStatusChanged()
2811
+ if (frames.length > 0) {
2812
+ pushOut(frames.filter((frame) => isPlainObject(frame) && frame.type === 'upsert'))
2813
+ pushCanonicalTrio()
2814
+ }
2815
+ }
2816
+ const refreshOneViewName = (slot: PoolGlobalSlot, nextName: string): void => {
2817
+ slot.viewName = nextName
2818
+ slot.merger.retarget({ serverId: slot.serverId, serverName: nextName })
2819
+ if (!isWorkspace) return
2820
+ const merger = slot.merger as WorkspaceMerger
2821
+ const frames = merger.onServerRenamed()
2822
+ if (frames.length > 0) {
2823
+ pushOut(frames)
2824
+ pushCanonicalTrio()
2825
+ }
2826
+ }
2827
+ const refreshViewNames = (): void => {
2828
+ for (const entry of pool.entries()) {
2829
+ const slot = entry.serverId !== undefined ? slots.get(entry.serverId) : undefined
2830
+ if (slot !== undefined && slot.viewName !== entry.displayName) refreshOneViewName(slot, entry.displayName)
2716
2831
  }
2717
- return { ok: true, serverId: first }
2718
2832
  }
2719
2833
 
2720
- /**
2721
- * The T32 `$events/result` split: an answer whose eventId is VIRTUAL
2722
- * belongs to a forwarded remote event and must NOT reach the local
2723
- * gateway (its own `$events` never delivered it — the local dispatcher
2724
- * would answer with its `no active event stream` failure). The virtual
2725
- * id goes back to the original, the local payload's clientId (the local
2726
- * stream's own) is discarded — the relay server composes the gateway
2727
- * payload from ITS subscription's clientId — and the outcome rides
2728
- * verbatim. Anything else, including a malformed payload, is the local
2729
- * gateway's business.
2730
- */
2731
- function forwardEventResult(payload: unknown, signal: AbortSignal | undefined, peer: unknown): unknown {
2732
- const args = argsOf(payload)
2834
+ const pumpSlot = async (slot: PoolGlobalSlot): Promise<void> => {
2835
+ // Whole body guarded: ANY escape stops THIS server's leg only.
2836
+ try {
2837
+ while (alive && !slot.removed) {
2838
+ if (slot.client.state !== 'online') {
2839
+ await waitAny()
2840
+ continue
2841
+ }
2842
+ const controller = new AbortController()
2843
+ slot.controller = controller
2844
+ try {
2845
+ const stream = slot.client.openStream(namespace, method, {}, controller.signal)
2846
+ let served = false
2847
+ for await (const frame of stream) {
2848
+ // Post-abort lines the transport already decoded must not reach
2849
+ // the merger (same discipline as the single-server pumps).
2850
+ if (!alive || slot.removed || controller.signal.aborted) break
2851
+ if (!served) {
2852
+ served = true
2853
+ slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS
2854
+ }
2855
+ informCurrentSession()
2856
+ const out = slot.merger.onRemote(frame)
2857
+ if (out.length > 0) {
2858
+ if (isWorkspace) {
2859
+ pushOut(out.filter((outFrame) => isPlainObject(outFrame) && (outFrame.type === 'upsert' || outFrame.type === 'remove')))
2860
+ pushCanonicalTrio()
2861
+ } else {
2862
+ pushOut(out)
2863
+ }
2864
+ }
2865
+ ensureSessionPoll()
2866
+ }
2867
+ } catch (error) {
2868
+ if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
2869
+ recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal')
2870
+ }
2871
+ } finally {
2872
+ if (slot.controller === controller) slot.controller = undefined
2873
+ }
2874
+ if (!alive || slot.removed) return
2875
+ slot.merger.onRemoteDown()
2876
+ if (slot.client.state === 'online') {
2877
+ await waitReopen(clock, waitAny, slot.reopenDelayMs)
2878
+ slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS)
2879
+ continue
2880
+ }
2881
+ await waitAny()
2882
+ }
2883
+ } catch (error) {
2884
+ if (alive) onDiagnostic(`remote leg of ${slot.serverId} stopped: ${messageOf(error)}`)
2885
+ }
2886
+ }
2887
+
2888
+ /** Re-read pool.entries(): create slots for new handshook servers (in
2889
+ * pairing order), refresh display names, remove slots whose server left.
2890
+ * Also heals a re-handshake that renamed the entry's serverId. */
2891
+ const scanSlots = (): void => {
2892
+ const seen = new Set<string>()
2893
+ for (const entry of pool.entries()) {
2894
+ const serverId = entry.serverId
2895
+ if (serverId === undefined) continue
2896
+ seen.add(serverId)
2897
+ const existing = slots.get(serverId)
2898
+ if (existing !== undefined) {
2899
+ if (existing.viewName !== entry.displayName) refreshOneViewName(existing, entry.displayName)
2900
+ continue
2901
+ }
2902
+ const inventory = isWorkspace && deps.createSessionInventorySlot !== undefined
2903
+ ? deps.createSessionInventorySlot(serverId)
2904
+ : undefined
2905
+ const merger = isWorkspace
2906
+ ? createWorkspaceMerger({
2907
+ serverId,
2908
+ serverName: entry.displayName,
2909
+ onDiagnostic,
2910
+ now: clock.now,
2911
+ lang: deps.lang,
2912
+ ...(inventory !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => inventory.replace(ids) } : {}),
2913
+ })
2914
+ : createControlMerger({ serverId, serverName: entry.displayName, onDiagnostic })
2915
+ const slot: PoolGlobalSlot = {
2916
+ kind: isWorkspace ? 'workspace' : 'control',
2917
+ serverId,
2918
+ entryId: entry.entryId,
2919
+ client: entry.client,
2920
+ merger,
2921
+ viewName: entry.displayName,
2922
+ controller: undefined,
2923
+ offState: () => {},
2924
+ annotation: annotationOf(entry.client),
2925
+ reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
2926
+ removed: false,
2927
+ inventory,
2928
+ }
2929
+ merger.setStatus(slot.annotation)
2930
+ slot.offState = slot.client.subscribe((state) => {
2931
+ try {
2932
+ // The set itself may have changed (a dedupe renamed the entry's
2933
+ // serverId, a rename moved the display name) — re-scan first.
2934
+ scanSlots()
2935
+ if (state === 'unpaired' || state === 'revoked') {
2936
+ applySlotAnnotation(slot, annotationOf(slot.client))
2937
+ slot.merger.onRemoteDown()
2938
+ slot.controller?.abort()
2939
+ return
2940
+ }
2941
+ applySlotAnnotation(slot, annotationOf(slot.client))
2942
+ if (state === 'online') refreshViewNames()
2943
+ } catch (error) {
2944
+ log?.('client multi merge slot state handler failed on %s: %s', serverId, messageOf(error))
2945
+ } finally {
2946
+ flushWaiters()
2947
+ }
2948
+ })
2949
+ slots.set(serverId, slot)
2950
+ // A slot born mid-stream missed the local baseline — replay it (its
2951
+ // output is discarded; the replay only sets the merger's localSeen and
2952
+ // local lists). Runs BEFORE the pump starts, so the buffer is empty.
2953
+ if (localBaselineFrame !== undefined) {
2954
+ for (const out of slot.merger.onLocal(localBaselineFrame)) void out
2955
+ }
2956
+ void pumpSlot(slot)
2957
+ }
2958
+ for (const [serverId, slot] of [...slots]) {
2959
+ if (!seen.has(serverId)) removeSlot(serverId, slot)
2960
+ }
2961
+ }
2962
+
2963
+ // -- local frames -------------------------------------------------------------
2964
+ const pushLocalWorkspace = (frame: unknown): void => {
2965
+ localLists = localWorkspaceLists(frame, localLists)
2966
+ // Every slot's merger must observe the local frames too — its localSeen
2967
+ // gate (nothing leaves its onRemote before the local baseline passed)
2968
+ // and its own local lists live there. Its OUTPUT is discarded: the
2969
+ // single-server global frames (its merged order/archived/pinned) are
2970
+ // superseded by the canonical composition over ALL slots.
2971
+ for (const slot of [...slots.values()]) {
2972
+ for (const out of slot.merger.onLocal(frame)) void out
2973
+ }
2974
+ if (!isPlainObject(frame)) {
2975
+ pushOut([frame])
2976
+ return
2977
+ }
2978
+ if (frame.type === 'baseline') {
2979
+ localSeen = true
2980
+ localBaselineFrame = frame
2981
+ const parts = orderedRemoteParts()
2982
+ const remotes = parts.map((part) => part.remote)
2983
+ const hasRemote = remotes.some(
2984
+ (remote) => remote.orderIds.length + remote.archivedIds.length + remote.pinnedIds.length + remote.workspaces.length > 0,
2985
+ )
2986
+ if (!hasRemote) {
2987
+ pushOut([frame])
2988
+ return
2989
+ }
2990
+ const value = isPlainObject(frame.value) ? frame.value : {}
2991
+ const items = Array.isArray(value.items) ? value.items : []
2992
+ const composed = composeMergedWorkspaceFrames(localLists, remotes)
2993
+ pushOut([
2994
+ {
2995
+ ...frame,
2996
+ value: {
2997
+ ...value,
2998
+ items: [...items, ...composed.workspaces],
2999
+ archivedSessionIds: composed.archived.archivedSessionIds,
3000
+ pinnedSessionIds: composed.pinned.pinnedSessionIds,
3001
+ },
3002
+ },
3003
+ ...composed.workspaces.map((workspace) => ({ type: 'upsert', workspace })),
3004
+ ])
3005
+ return
3006
+ }
3007
+ const remotes = orderedRemoteParts().map((part) => part.remote)
3008
+ if (frame.type === 'order') {
3009
+ if (!remotes.some((remote) => remote.orderIds.length > 0)) {
3010
+ pushOut([frame])
3011
+ return
3012
+ }
3013
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).order])
3014
+ return
3015
+ }
3016
+ if (frame.type === 'archived') {
3017
+ if (!remotes.some((remote) => remote.archivedIds.length > 0)) {
3018
+ pushOut([frame])
3019
+ return
3020
+ }
3021
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).archived])
3022
+ return
3023
+ }
3024
+ if (frame.type === 'pinned') {
3025
+ if (!remotes.some((remote) => remote.pinnedIds.length > 0)) {
3026
+ pushOut([frame])
3027
+ return
3028
+ }
3029
+ pushOut([composeMergedWorkspaceFrames(localLists, remotes).pinned])
3030
+ return
3031
+ }
3032
+ // upsert / remove / anything else local: purely local facts.
3033
+ pushOut([frame])
3034
+ }
3035
+ const pushLocalControl = (frame: unknown): void => {
3036
+ if (isPlainObject(frame) && frame.type === 'baseline') {
3037
+ pushOut([frame])
3038
+ // Each slot's buffered pre-baseline projections flush behind it (the
3039
+ // slots' own baseline copies are the SAME frame object — skip those).
3040
+ for (const slot of [...slots.values()]) {
3041
+ for (const out of slot.merger.onLocal(frame)) {
3042
+ if (out !== frame) pushOut([out])
3043
+ }
3044
+ }
3045
+ return
3046
+ }
3047
+ pushOut([frame])
3048
+ for (const slot of [...slots.values()]) slot.merger.onLocal(frame)
3049
+ }
3050
+
3051
+ const offCurrentSession = deps.watchCurrentSession(informCurrentSession)
3052
+ const offUninstall = deps.watchUninstall(() => {
3053
+ try {
3054
+ const read = deps.getCurrentSession()
3055
+ for (const slot of [...slots.values()]) {
3056
+ slot.merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)
3057
+ applySlotAnnotation(slot, 'unpaired')
3058
+ slot.merger.onRemoteDown()
3059
+ slot.controller?.abort()
3060
+ }
3061
+ } catch {
3062
+ // ignore teardown errors
3063
+ } finally {
3064
+ flushWaiters()
3065
+ }
3066
+ })
3067
+ const offPool = deps.watchPool(() => {
3068
+ try {
3069
+ scanSlots()
3070
+ refreshViewNames()
3071
+ } catch (error) {
3072
+ log?.('client multi merge pool rescan failed on %s: %s', endpoint, messageOf(error))
3073
+ } finally {
3074
+ flushWaiters()
3075
+ }
3076
+ })
3077
+
3078
+ const onExternalAbort = (): void => {
3079
+ alive = false
3080
+ for (const slot of [...slots.values()]) slot.controller?.abort()
3081
+ flushWaiters()
3082
+ channel.end()
3083
+ }
3084
+ if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
3085
+
3086
+ scanSlots()
3087
+ informCurrentSession()
3088
+
3089
+ const localIterator = local[Symbol.asyncIterator]()
3090
+ const pumpLocal = async (): Promise<void> => {
3091
+ try {
3092
+ while (alive) {
3093
+ const result = await localIterator.next()
3094
+ if (result.done === true) break
3095
+ informCurrentSession()
3096
+ if (isWorkspace) pushLocalWorkspace(result.value)
3097
+ else pushLocalControl(result.value)
3098
+ ensureSessionPoll()
3099
+ }
3100
+ } catch (error) {
3101
+ // The local stream is the one the UI opened: its failure is the
3102
+ // merged stream's failure (a remote death never is).
3103
+ if (alive) channel.fail(error)
3104
+ return
3105
+ }
3106
+ if (!alive) return
3107
+ alive = false
3108
+ for (const slot of [...slots.values()]) slot.controller?.abort()
3109
+ flushWaiters()
3110
+ channel.end()
3111
+ }
3112
+
3113
+ void pumpLocal()
3114
+ try {
3115
+ while (true) {
3116
+ const result = await channel.next()
3117
+ if (result.done === true) break
3118
+ yield result.value
3119
+ }
3120
+ } finally {
3121
+ alive = false
3122
+ if (sessionPoll !== undefined) clock.clearTimeout(sessionPoll)
3123
+ offCurrentSession()
3124
+ offUninstall()
3125
+ offPool()
3126
+ if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
3127
+ for (const slot of [...slots.values()]) {
3128
+ slot.removed = true
3129
+ slot.offState()
3130
+ slot.controller?.abort()
3131
+ slot.inventory?.dispose()
3132
+ }
3133
+ slots.clear()
3134
+ flushWaiters()
3135
+ void Promise.resolve(localIterator.return?.(undefined)).catch(() => {})
3136
+ }
3137
+ }
3138
+
3139
+ interface PoolEventSlot {
3140
+ serverId: string
3141
+ client: RelayClient
3142
+ controller: AbortController | undefined
3143
+ offState: () => void
3144
+ /** This server's shown-and-uncanceled virtual eventIds — the orphan-close
3145
+ * record. PER SLOT by design: a leg dying closes only its own prompts. */
3146
+ shownEvents: Set<string>
3147
+ reopenDelayMs: number
3148
+ removed: boolean
3149
+ displayName: string
3150
+ /** T52 refresh key for this slot (its identity while serving). */
3151
+ notedIdentity: string | undefined
3152
+ stateSeen: RelayState | undefined
3153
+ statusInFlight: boolean
3154
+ statusPending: boolean
3155
+ statusDeferred: boolean
3156
+ }
3157
+
3158
+ interface MergedEventsStreamMultiDeps {
3159
+ local: AsyncIterable<unknown>
3160
+ pool: RelayPool
3161
+ signal: AbortSignal | undefined
3162
+ recordFailure: (endpoint: string, code: string) => void
3163
+ log: ((format: string, ...args: unknown[]) => void) | undefined
3164
+ clock: RelayClock
3165
+ summarySync: SessionSummarySync
3166
+ watchPool(fn: () => void): () => void
3167
+ }
3168
+
3169
+ /**
3170
+ * The pool-mode merged `$events` stream: one `$zr/events` leg per pooled
3171
+ * server, each with its own orphan-close record. The T52 catalog refresh
3172
+ * fires whenever any slot's serving identity appears, changes, or leaves;
3173
+ * the T63/T65 summary sync and the T75 status sync fetch from the OWNING
3174
+ * server of each id (virtual ids carry the serverId), per slot on its own
3175
+ * serving periods.
3176
+ */
3177
+ async function* mergedEventsStreamMulti(deps: MergedEventsStreamMultiDeps): AsyncGenerator<unknown, void, undefined> {
3178
+ const { local, pool, signal, recordFailure, log, clock } = deps
3179
+ if (signal?.aborted === true) return
3180
+ const channel = createFrameChannel()
3181
+ let alive = true
3182
+ const slots = new Map<string, PoolEventSlot>()
3183
+ const onlineWaiters: (() => void)[] = []
3184
+ const flushWaiters = (): void => {
3185
+ for (const wake of onlineWaiters.splice(0)) wake()
3186
+ }
3187
+ const waitAny = async (): Promise<void> => {
3188
+ await new Promise<void>((resolve) => {
3189
+ onlineWaiters.push(resolve)
3190
+ })
3191
+ }
3192
+ let localOpened = false
3193
+ let deferredRefresh = false
3194
+ const pushRefresh = (): void => {
3195
+ if (!localOpened) {
3196
+ deferredRefresh = true
3197
+ return
3198
+ }
3199
+ channel.push(catalogRefreshFrame())
3200
+ }
3201
+ const noteSlotIdentity = (slot: PoolEventSlot): void => {
3202
+ const key = slot.client.state === 'online' ? `${slot.serverId}\u0000${slot.displayName}` : undefined
3203
+ if (key === slot.notedIdentity) return
3204
+ slot.notedIdentity = key
3205
+ if (key !== undefined) pushRefresh()
3206
+ }
3207
+
3208
+ // -- T63/T65/T75 bookkeeping: global maps over VIRTUAL ids (globally
3209
+ // unique across servers), per-slot fetch rounds. Same discipline as the
3210
+ // single-server stream — see mergedEventsStream for the full story.
3211
+ const announced = new Set<string>()
3212
+ const askAttempts = new Map<string, number>()
3213
+ const askedForGood = new Set<string>()
3214
+ const retryAt = new Map<string, number>()
3215
+ const MAX_ASK_ATTEMPTS = 3
3216
+ const ASK_RETRY_FIRST_MS = 2_000
3217
+ const ASK_RETRY_SECOND_MS = 10_000
3218
+ let retryTimer: unknown = undefined
3219
+ let midFlightBatches: string[] = []
3220
+ let askingNow: string[] = []
3221
+ let summarySyncInFlight = false
3222
+ let deferredSummarySync = false
3223
+ const latestLiveStatus = new Map<string, boolean>()
3224
+ let statusSeq = 0
3225
+ const liveStatusSeq = new Map<string, number>()
3226
+
3227
+ const scheduleRetry = (): void => {
3228
+ if (retryTimer !== undefined || retryAt.size === 0) return
3229
+ const earliest = Math.min(...retryAt.values())
3230
+ retryTimer = clock.setTimeout(() => {
3231
+ retryTimer = undefined
3232
+ if (!alive) return
3233
+ const nowMs = clock.now()
3234
+ const due: string[] = []
3235
+ for (const [id, at] of retryAt) {
3236
+ if (at <= nowMs) {
3237
+ retryAt.delete(id)
3238
+ due.push(id)
3239
+ }
3240
+ }
3241
+ if (due.length > 0) runSummarySync(due)
3242
+ }, Math.max(0, earliest - clock.now()))
3243
+ }
3244
+ const runSummarySync = (triggerIds: readonly string[] = []): void => {
3245
+ if (!alive) return
3246
+ if (!localOpened) {
3247
+ deferredSummarySync = true
3248
+ return
3249
+ }
3250
+ if (summarySyncInFlight) {
3251
+ midFlightBatches.push(...triggerIds)
3252
+ return
3253
+ }
3254
+ const rounds: { slot: PoolEventSlot; ids: string[] }[] = []
3255
+ if (triggerIds.length === 0) {
3256
+ for (const slot of [...slots.values()]) {
3257
+ if (slot.client.state === 'online') rounds.push({ slot, ids: [] })
3258
+ }
3259
+ } else {
3260
+ for (const id of triggerIds) {
3261
+ const parts = fromVirtual(id)
3262
+ if (parts === undefined) continue
3263
+ const slot = slots.get(parts.serverId)
3264
+ if (slot === undefined || slot.client.state !== 'online') continue
3265
+ let round = rounds.find((candidate) => candidate.slot === slot)
3266
+ if (round === undefined) {
3267
+ round = { slot, ids: [] }
3268
+ rounds.push(round)
3269
+ }
3270
+ round.ids.push(id)
3271
+ }
3272
+ }
3273
+ if (rounds.length === 0) return
3274
+ summarySyncInFlight = true
3275
+ askingNow = [...triggerIds]
3276
+ for (const round of rounds) {
3277
+ for (const id of round.ids) askAttempts.set(id, (askAttempts.get(id) ?? 0) + 1)
3278
+ }
3279
+ void Promise.all(
3280
+ rounds.map((round) =>
3281
+ round.slot.client
3282
+ .invoke('session', 'list', { _request: {} }, undefined)
3283
+ .then(
3284
+ (value) => ({ round, value, error: undefined as unknown }),
3285
+ (error: unknown) => ({ round, value: undefined, error }),
3286
+ ),
3287
+ ),
3288
+ ).then((results) => {
3289
+ summarySyncInFlight = false
3290
+ if (!alive) return
3291
+ askingNow = []
3292
+ for (const result of results) {
3293
+ if (result.error !== undefined) {
3294
+ recordFailure('session/list', result.error instanceof RelayError ? result.error.code : 'internal')
3295
+ continue
3296
+ }
3297
+ const frames = sessionSummaryAddedFrames(result.value, result.round.slot.serverId)
3298
+ const returned = new Set<string>()
3299
+ for (const frame of frames) {
3300
+ const row = (frame as { args: unknown[] }).args[0]
3301
+ const rowId = isPlainObject(row) && typeof row.sessionId === 'string' ? row.sessionId : undefined
3302
+ if (rowId === undefined) continue
3303
+ returned.add(rowId)
3304
+ if (announced.has(rowId)) continue
3305
+ announced.add(rowId)
3306
+ channel.push(frame)
3307
+ const liveRunning = latestLiveStatus.get(rowId)
3308
+ if (liveRunning !== undefined && liveRunning !== (isPlainObject(row) && row.running === true)) {
3309
+ channel.push({ type: 'emit', event: 'api-session/status', args: [rowId, liveRunning] })
3310
+ }
3311
+ }
3312
+ for (const id of result.round.ids) {
3313
+ if (returned.has(id)) {
3314
+ retryAt.delete(id)
3315
+ continue
3316
+ }
3317
+ const attempts = askAttempts.get(id) ?? 0
3318
+ if (attempts >= MAX_ASK_ATTEMPTS) askedForGood.add(id)
3319
+ else retryAt.set(id, clock.now() + (attempts <= 1 ? ASK_RETRY_FIRST_MS : ASK_RETRY_SECOND_MS))
3320
+ }
3321
+ }
3322
+ scheduleRetry()
3323
+ const queued = midFlightBatches
3324
+ midFlightBatches = []
3325
+ if (queued.length > 0) runSummarySync(queued)
3326
+ })
3327
+ }
3328
+ const handleForwardedBatch = (ids: readonly string[]): void => {
3329
+ if (!ids.some((id) => !announced.has(id) && !askedForGood.has(id) && !retryAt.has(id))) return
3330
+ runSummarySync(ids)
3331
+ }
3332
+ /** T75 per slot: re-push every row's running flag from a fresh snapshot
3333
+ * of THIS server when its serving period starts. */
3334
+ const runStatusSync = (slot: PoolEventSlot): void => {
3335
+ if (!alive) return
3336
+ if (!localOpened) {
3337
+ slot.statusDeferred = true
3338
+ return
3339
+ }
3340
+ if (slot.statusInFlight) {
3341
+ slot.statusPending = true
3342
+ return
3343
+ }
3344
+ if (slot.client.state !== 'online') return
3345
+ slot.statusInFlight = true
3346
+ // The race guard arms here (see the single-server stream): only frames
3347
+ // arriving after this point outrank the snapshot — older records have a
3348
+ // lower seq than startSeq and never block.
3349
+ const startSeq = ++statusSeq
3350
+ void slot.client.invoke('session', 'list', { _request: {} }, undefined).then(
3351
+ (value) => {
3352
+ slot.statusInFlight = false
3353
+ if (!alive) return
3354
+ const rows = isPlainObject(value) && Array.isArray(value.items) ? value.items : []
3355
+ for (const row of rows) {
3356
+ if (!isPlainObject(row) || typeof row.sessionId !== 'string' || row.sessionId === '') continue
3357
+ if ((liveStatusSeq.get(row.sessionId) ?? 0) > startSeq) continue
3358
+ channel.push({
3359
+ type: 'emit',
3360
+ event: 'api-session/status',
3361
+ args: [toVirtual(slot.serverId, row.sessionId), row.running === true],
3362
+ })
3363
+ }
3364
+ if (slot.statusPending) {
3365
+ slot.statusPending = false
3366
+ runStatusSync(slot)
3367
+ }
3368
+ },
3369
+ (error: unknown) => {
3370
+ slot.statusInFlight = false
3371
+ if (!alive) return
3372
+ recordFailure('session/list', error instanceof RelayError ? error.code : 'internal')
3373
+ if (slot.statusPending) {
3374
+ slot.statusPending = false
3375
+ runStatusSync(slot)
3376
+ }
3377
+ },
3378
+ )
3379
+ }
3380
+ /** One slot's fresh serving period: the ask bookkeeping gets another
3381
+ * chance, and this server's rows are re-synced (statuses first). */
3382
+ const noteFirstOnline = (slot: PoolEventSlot): void => {
3383
+ askedForGood.clear()
3384
+ askAttempts.clear()
3385
+ retryAt.clear()
3386
+ latestLiveStatus.clear()
3387
+ if (retryTimer !== undefined) {
3388
+ clock.clearTimeout(retryTimer)
3389
+ retryTimer = undefined
3390
+ }
3391
+ runStatusSync(slot)
3392
+ runSummarySync()
3393
+ }
3394
+
3395
+ const pumpSlot = async (slot: PoolEventSlot): Promise<void> => {
3396
+ try {
3397
+ while (alive && !slot.removed) {
3398
+ if (slot.client.state !== 'online') {
3399
+ await waitAny()
3400
+ continue
3401
+ }
3402
+ const controller = new AbortController()
3403
+ slot.controller = controller
3404
+ try {
3405
+ const stream = slot.client.openStream('$zr', 'events', {}, controller.signal)
3406
+ let served = false
3407
+ for await (const frame of stream) {
3408
+ if (!alive || slot.removed || controller.signal.aborted) break
3409
+ if (!served) {
3410
+ served = true
3411
+ slot.reopenDelayMs = REMOTE_REOPEN_FIRST_MS
3412
+ }
3413
+ const rewritten = rewriteRemoteEventFrame(frame, slot.serverId)
3414
+ if (rewritten === null) continue
3415
+ // T75 race guard + PB-26a live status, exactly as the single
3416
+ // server stream keeps them — keyed by ids that are already
3417
+ // globally unique (virtual or original-then-virtualized).
3418
+ if (isPlainObject(frame) && frame.type === 'emit' && frame.event === 'api-session/status') {
3419
+ const rowId = Array.isArray(frame.args) ? frame.args[0] : undefined
3420
+ if (typeof rowId === 'string' && rowId !== '') liveStatusSeq.set(rowId, ++statusSeq)
3421
+ const args = isPlainObject(rewritten) && Array.isArray(rewritten.args) ? rewritten.args : undefined
3422
+ const virtualId = args?.[0]
3423
+ const running = args?.[1]
3424
+ if (typeof virtualId === 'string' && typeof running === 'boolean') {
3425
+ latestLiveStatus.set(virtualId, running)
3426
+ if (!announced.has(virtualId)) handleForwardedBatch([virtualId])
3427
+ }
3428
+ }
3429
+ const eventId =
3430
+ isPlainObject(rewritten) && typeof rewritten.eventId === 'string' ? rewritten.eventId : undefined
3431
+ if (eventId !== undefined) {
3432
+ if (isPlainObject(frame) && frame.type === 'waterfall') {
3433
+ if (slot.shownEvents.has(eventId)) continue
3434
+ slot.shownEvents.add(eventId)
3435
+ } else if (isPlainObject(frame) && frame.type === 'cancel') {
3436
+ slot.shownEvents.delete(eventId)
3437
+ }
3438
+ }
3439
+ channel.push(rewritten)
3440
+ }
3441
+ } catch (error) {
3442
+ if (alive && !slot.removed && !controller.signal.aborted && signal?.aborted !== true) {
3443
+ recordFailure('$zr/events', error instanceof RelayError ? error.code : 'internal')
3444
+ }
3445
+ } finally {
3446
+ if (slot.controller === controller) slot.controller = undefined
3447
+ }
3448
+ // THIS leg is over — close only ITS prompts. A disconnect is not a
3449
+ // verdict; the reopen re-delivers still-pending events under the
3450
+ // SAME ids (the server prefixes them per subscription, so the
3451
+ // virtual ids differ — DSH shows them fresh, the old cancels already
3452
+ // closed the stale prompts).
3453
+ for (const eventId of slot.shownEvents) channel.push({ type: 'cancel', eventId })
3454
+ slot.shownEvents = new Set()
3455
+ if (!alive || slot.removed) return
3456
+ if (slot.client.state === 'online') {
3457
+ await waitReopen(clock, waitAny, slot.reopenDelayMs)
3458
+ slot.reopenDelayMs = Math.min(slot.reopenDelayMs * 2, REMOTE_REOPEN_MAX_MS)
3459
+ continue
3460
+ }
3461
+ await waitAny()
3462
+ }
3463
+ } catch (error) {
3464
+ if (alive) log?.('client events remote leg of %s stopped: %s', slot.serverId, messageOf(error))
3465
+ }
3466
+ }
3467
+
3468
+ const scanSlots = (): void => {
3469
+ const seen = new Set<string>()
3470
+ for (const entry of pool.entries()) {
3471
+ const serverId = entry.serverId
3472
+ if (serverId === undefined) continue
3473
+ seen.add(serverId)
3474
+ const existing = slots.get(serverId)
3475
+ if (existing !== undefined) {
3476
+ if (existing.displayName !== entry.displayName) {
3477
+ existing.displayName = entry.displayName
3478
+ noteSlotIdentity(existing)
3479
+ }
3480
+ continue
3481
+ }
3482
+ const slot: PoolEventSlot = {
3483
+ serverId,
3484
+ client: entry.client,
3485
+ controller: undefined,
3486
+ offState: () => {},
3487
+ shownEvents: new Set(),
3488
+ reopenDelayMs: REMOTE_REOPEN_FIRST_MS,
3489
+ removed: false,
3490
+ displayName: entry.displayName,
3491
+ notedIdentity: undefined,
3492
+ stateSeen: undefined,
3493
+ statusInFlight: false,
3494
+ statusPending: false,
3495
+ statusDeferred: false,
3496
+ }
3497
+ slot.offState = slot.client.subscribe((state) => {
3498
+ try {
3499
+ const firstOnline = state === 'online' && slot.stateSeen !== 'online'
3500
+ slot.stateSeen = state
3501
+ if (state === 'unpaired' || state === 'revoked') {
3502
+ slot.controller?.abort()
3503
+ noteSlotIdentity(slot)
3504
+ return
3505
+ }
3506
+ noteSlotIdentity(slot)
3507
+ if (state === 'online' && firstOnline) noteFirstOnline(slot)
3508
+ } catch (error) {
3509
+ log?.('client events merge slot state handler failed on %s: %s', serverId, messageOf(error))
3510
+ } finally {
3511
+ flushWaiters()
3512
+ }
3513
+ })
3514
+ slots.set(serverId, slot)
3515
+ void pumpSlot(slot)
3516
+ // Install into an already-serving server: subscribe replayed nothing.
3517
+ if (slot.client.state === 'online') {
3518
+ slot.stateSeen = 'online'
3519
+ noteSlotIdentity(slot)
3520
+ noteFirstOnline(slot)
3521
+ }
3522
+ }
3523
+ for (const [serverId, slot] of [...slots]) {
3524
+ if (!seen.has(serverId)) {
3525
+ slots.delete(serverId)
3526
+ slot.removed = true
3527
+ slot.offState()
3528
+ slot.controller?.abort()
3529
+ // Only THIS server's pending prompts get the synthesized cancels.
3530
+ for (const eventId of slot.shownEvents) channel.push({ type: 'cancel', eventId })
3531
+ slot.shownEvents.clear()
3532
+ }
3533
+ }
3534
+ }
3535
+
3536
+ const offSyncRequest = deps.summarySync.onForwardedSessionIds(handleForwardedBatch)
3537
+ const offPool = deps.watchPool(() => {
3538
+ try {
3539
+ scanSlots()
3540
+ } catch (error) {
3541
+ log?.('client events merge pool rescan failed: %s', messageOf(error))
3542
+ } finally {
3543
+ flushWaiters()
3544
+ }
3545
+ })
3546
+
3547
+ const onExternalAbort = (): void => {
3548
+ alive = false
3549
+ for (const slot of [...slots.values()]) slot.controller?.abort()
3550
+ flushWaiters()
3551
+ channel.end()
3552
+ }
3553
+ if (signal !== undefined) signal.addEventListener('abort', onExternalAbort)
3554
+
3555
+ scanSlots()
3556
+
3557
+ const localIterator = local[Symbol.asyncIterator]()
3558
+ const pumpLocal = async (): Promise<void> => {
3559
+ try {
3560
+ while (alive) {
3561
+ const result = await localIterator.next()
3562
+ if (result.done === true) break
3563
+ channel.push(result.value)
3564
+ if (!localOpened) {
3565
+ localOpened = true
3566
+ if (deferredRefresh) {
3567
+ deferredRefresh = false
3568
+ channel.push(catalogRefreshFrame())
3569
+ }
3570
+ const pendingBatch = deps.summarySync.consumePendingRequest()
3571
+ if (deferredSummarySync || pendingBatch.length > 0) {
3572
+ deferredSummarySync = false
3573
+ runSummarySync(pendingBatch)
3574
+ }
3575
+ for (const slot of [...slots.values()]) {
3576
+ if (slot.statusDeferred) {
3577
+ slot.statusDeferred = false
3578
+ runStatusSync(slot)
3579
+ }
3580
+ }
3581
+ }
3582
+ }
3583
+ } catch (error) {
3584
+ if (alive) channel.fail(error)
3585
+ return
3586
+ }
3587
+ if (!alive) return
3588
+ alive = false
3589
+ for (const slot of [...slots.values()]) slot.controller?.abort()
3590
+ flushWaiters()
3591
+ channel.end()
3592
+ }
3593
+
3594
+ void pumpLocal()
3595
+ try {
3596
+ while (true) {
3597
+ const result = await channel.next()
3598
+ if (result.done === true) break
3599
+ yield result.value
3600
+ }
3601
+ } finally {
3602
+ alive = false
3603
+ if (retryTimer !== undefined) clock.clearTimeout(retryTimer)
3604
+ offSyncRequest()
3605
+ offPool()
3606
+ if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
3607
+ for (const slot of [...slots.values()]) {
3608
+ slot.removed = true
3609
+ slot.offState()
3610
+ slot.controller?.abort()
3611
+ }
3612
+ slots.clear()
3613
+ flushWaiters()
3614
+ void Promise.resolve(localIterator.return?.(undefined)).catch(() => {})
3615
+ }
3616
+ }
3617
+
3618
+ /**
3619
+ * Install the two own-property wrappers on the raw gateway. Assumes
3620
+ * {@link checkGatewayShape} passed (the wiring gates on it) — this function
3621
+ * records the verdict but does not re-gate, so it stays usable in tests
3622
+ * that install over deliberately odd shapes.
3623
+ */
3624
+ /**
3625
+ * P55-2: whether `filePath` lives at or under `cwd` — the /api/file
3626
+ * attribution test. Both paths are compared backslash-folded to `/` (a
3627
+ * server-Windows cwd meets a server-Windows image path, and the Markdown
3628
+ * renderer may hand either separator over), trailing separators of `cwd`
3629
+ * are dropped, and the match is exact-or-subpath (`cwd + '/'` prefix — a
3630
+ * directory merely PREFIXED by the cwd string is not a match). Windows
3631
+ * drive-rooted pairs compare case-insensitively WHOLE (`C:\Users` vs
3632
+ * `c:\users` — the drive letter is the visible case hazard, but NTFS is
3633
+ * case-insensitive end to end, and a missed match costs a permanently
3634
+ * broken image while an over-match needs two same-server cwds differing
3635
+ * only by case); everything else stays case-sensitive (POSIX is).
3636
+ * Deliberately NOT node:path — the two ends may run on different platforms,
3637
+ * so the rule is pure string arithmetic over the server's paths.
3638
+ */
3639
+ export function pathMatchesCwd(filePath: string, cwd: string): boolean {
3640
+ const slashify = (value: string): string => value.replaceAll('\\', '/')
3641
+ const file = slashify(filePath)
3642
+ let root = slashify(cwd)
3643
+ while (root.length > 1 && root.endsWith('/')) root = root.slice(0, -1)
3644
+ if (root === '') return false
3645
+ const matches = (f: string, r: string): boolean => f === r || f.startsWith(`${r}/`)
3646
+ if (matches(file, root)) return true
3647
+ if (/^[a-z]:/i.test(root) && /^[a-z]:/i.test(file)) return matches(file.toLowerCase(), root.toLowerCase())
3648
+ return false
3649
+ }
3650
+
3651
+ export function installIntercept(options: InstallInterceptOptions): InterceptHandle {
3652
+ const { raw, log } = options
3653
+ const clock = options.clock ?? defaultClock
3654
+ // MS-3: exactly one mode — the pool when given, the single row client
3655
+ // otherwise. Installing with neither is a wiring bug; refuse loudly at
3656
+ // install instead of crashing on the first remote call.
3657
+ const pool = options.pool
3658
+ const relay = options.relay
3659
+ const getServerId = options.getServerId
3660
+ if (pool === undefined && (relay === undefined || getServerId === undefined)) {
3661
+ throw new Error('installIntercept requires either `relay` + `getServerId` (single server) or `pool`')
3662
+ }
3663
+ // The single-server facts, proven present by the check above. The
3664
+ // single-server code paths read THESE (never the optional originals), so
3665
+ // the old behavior stays byte-identical without a narrowing dance.
3666
+ const singleRelay = relay as RelayClient
3667
+ const singleGetServerId = getServerId as () => string | undefined
3668
+ // T63: the summary-sync hub shared by the merged streams — the workspace
3669
+ // route nudges, the events leg fetches-and-announces. One per install: it
3670
+ // outlives individual stream generations so a nudge from a workspace leg
3671
+ // reaches whichever $events generation is open.
3672
+ const summarySync = createSessionSummarySync()
3673
+ // PB-25b: the inventory slot factory (the wiring's union feed) — passed
3674
+ // down to every merged stream, which owns one slot for its lifetime.
3675
+ const createSessionInventorySlot = options.createSessionInventorySlot
3676
+ // MS-3: the pool-event hub behind handle.notePoolEvent() — the wiring
3677
+ // forwards the pool's onStateChange/onServerRemoved callbacks here, and
3678
+ // every live merged stream (plus the pool-mode catalog watcher below)
3679
+ // re-scans on the broadcast.
3680
+ const poolListeners = new Set<() => void>()
3681
+ const watchPool = (fn: () => void): (() => void) => {
3682
+ poolListeners.add(fn)
3683
+ return () => {
3684
+ poolListeners.delete(fn)
3685
+ }
3686
+ }
3687
+ // T62: the current-session getter and the live-merged-stream listener
3688
+ // registry behind handle.currentSessionChanged().
3689
+ const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
3690
+ const currentSessionListeners = new Set<() => void>()
3691
+ const watchCurrentSession = (fn: () => void): (() => void) => {
3692
+ currentSessionListeners.add(fn)
3693
+ return () => { currentSessionListeners.delete(fn) }
3694
+ }
3695
+ const uninstallListeners = new Set<() => void>()
3696
+ const watchUninstall = (fn: () => void): (() => void) => {
3697
+ uninstallListeners.add(fn)
3698
+ return () => { uninstallListeners.delete(fn) }
3699
+ }
3700
+ const gateway = raw as Record<string | symbol, unknown>
3701
+ const shape = checkGatewayShape(raw)
3702
+ const counters = { openWireStream: 0, dispatchRpc: 0 }
3703
+ const failures: InterceptFailureRecord[] = []
3704
+ const incompatible: IncompatibleCallRecord[] = []
3705
+ // The closed-session registry (T34): insertion-ordered, capped, virtual-id
3706
+ // keyed. Entries live until THEIR session proves reachable again (a
3707
+ // succeeding call for it) — a reconnect clears nothing (T34-fix): a link
3708
+ // that flapped under a still-standing closure would make the banner flicker
3709
+ // away and back.
3710
+ const closedSessions = new Map<string, ClosedSessionRecord['reason']>()
3711
+ // T73-fix: waiters for "a closed-session record was just cleared" — the
3712
+ // quiet session/follow hold (rewriteUpstream) ends its silence the moment
3713
+ // the session it holds for is served again, so a re-enabled remote comes
3714
+ // back with a fresh history instead of staying blind under its banner.
3715
+ const closedClearWaiters = new Set<() => void>()
3716
+ // P55-2: the /api/file attribution state. `remoteSessionCwds` maps each
3717
+ // remote session's VIRTUAL id to its cwd as last seen (snapshot header,
3718
+ // session/list rows); `activeRemoteFollows` holds every live remote
3719
+ // `session/follow` this process opened (symbol-keyed so concurrent pages
3720
+ // each hold their own); `lastFollowedSession` is the follow the wrapper
3721
+ // answered MOST RECENTLY, local or remote — together they resolve a
3722
+ // path-only image request to its owning session without waiting for the
3723
+ // browser's 1s current-session report. All in-memory, session-scale
3724
+ // (the cwd map is capped like the closed-session registry). The
3725
+ // `session/control` merge is deliberately NOT a source: its frame
3726
+ // vocabulary (RT dsh-api-session-controller lib/typert.remote-client.js —
3727
+ // `baseline` with value.projections, `projection` with sessionId/seq/
3728
+ // changes) carries no session summary and no cwd, so a scan there could
3729
+ // never record anything.
3730
+ type RemoteSessionCwd = { serverId: string; sessionId: string; cwd: string }
3731
+ const remoteSessionCwds = new Map<string, RemoteSessionCwd>()
3732
+ const activeRemoteFollows = new Map<symbol, { serverId: string; sessionId: string }>()
3733
+ let lastFollowedSession: { kind: 'remote'; serverId: string; sessionId: string } | { kind: 'local' } | undefined
3734
+ const MAX_REMOTE_SESSION_CWDS = 200
3735
+ const recordRemoteSessionCwd = (serverId: string, rawSessionId: unknown, cwd: unknown): void => {
3736
+ if (typeof rawSessionId !== 'string' || rawSessionId === '') return
3737
+ if (typeof cwd !== 'string' || cwd === '') return
3738
+ const key = toVirtual(serverId, rawSessionId)
3739
+ // Re-insert to refresh the recency order, then cap like `closedSessions`.
3740
+ remoteSessionCwds.delete(key)
3741
+ if (remoteSessionCwds.size >= MAX_REMOTE_SESSION_CWDS) {
3742
+ const oldest = remoteSessionCwds.keys().next()
3743
+ if (oldest.done !== true) remoteSessionCwds.delete(oldest.value)
3744
+ }
3745
+ remoteSessionCwds.set(key, { serverId, sessionId: rawSessionId, cwd })
3746
+ }
3747
+ /** Record every cwd a remote `session/list` result carries (both merge
3748
+ * modes call this on the RAW remote value, before ids go virtual). */
3749
+ const noteRemoteSessionListCwds = (remoteResult: unknown, serverId: string): void => {
3750
+ if (!isPlainObject(remoteResult) || !Array.isArray(remoteResult.items)) return
3751
+ for (const item of remoteResult.items) {
3752
+ if (!isPlainObject(item)) continue
3753
+ const id = typeof item.sessionId === 'string' && item.sessionId !== ''
3754
+ ? item.sessionId
3755
+ : typeof item.id === 'string' && item.id !== ''
3756
+ ? item.id
3757
+ : undefined
3758
+ if (id === undefined) continue
3759
+ recordRemoteSessionCwd(serverId, id, item.cwd)
3760
+ }
3761
+ }
3762
+ /**
3763
+ * The resolution the /api/file wrap buys with one call. Steps, in order:
3764
+ * A) the ACTIVE remote session — the browser-reported current session
3765
+ * (unless this process's newest follow went LOCAL, which is the newer
3766
+ * fact), else the newest follow, else the newest live remote follow;
3767
+ * B) a precise cwd match — the active session's own cwd first, then any
3768
+ * known cwd the path falls under (a session with a LIVE follow preferred,
3769
+ * else the most recently refreshed match);
3770
+ * C) no cwd claim anywhere → the active remote (so a just-opened page's
3771
+ * first image resolves with zero cwd knowledge), or `undefined` when no
3772
+ * remote session is active — a local session's images stay local.
3773
+ */
3774
+ const resolveRemoteFileScope = (rawPath: string): { serverId: string; sessionId: string } | undefined => {
3775
+ if (typeof rawPath !== 'string' || rawPath === '') return undefined
3776
+ let activeRemote: { serverId: string; sessionId: string } | undefined
3777
+ const current = getCurrentSession()
3778
+ if (current.kind === 'open') {
3779
+ const parts = fromVirtual(current.sessionId)
3780
+ if (parts !== undefined && lastFollowedSession?.kind !== 'local') {
3781
+ activeRemote = { serverId: parts.serverId, sessionId: parts.id }
3782
+ }
3783
+ }
3784
+ if (activeRemote === undefined && lastFollowedSession?.kind === 'remote') {
3785
+ activeRemote = { serverId: lastFollowedSession.serverId, sessionId: lastFollowedSession.sessionId }
3786
+ }
3787
+ if (activeRemote === undefined && activeRemoteFollows.size > 0) {
3788
+ const values = [...activeRemoteFollows.values()]
3789
+ const last = values[values.length - 1]
3790
+ if (last !== undefined) activeRemote = { ...last }
3791
+ }
3792
+ if (activeRemote !== undefined) {
3793
+ const own = remoteSessionCwds.get(toVirtual(activeRemote.serverId, activeRemote.sessionId))
3794
+ if (own !== undefined && pathMatchesCwd(rawPath, own.cwd)) {
3795
+ return { serverId: own.serverId, sessionId: own.sessionId }
3796
+ }
3797
+ }
3798
+ const followedVirtuals = new Set([...activeRemoteFollows.values()].map((entry) => toVirtual(entry.serverId, entry.sessionId)))
3799
+ let latestMatch: RemoteSessionCwd | undefined
3800
+ let followedMatch: RemoteSessionCwd | undefined
3801
+ for (const entry of remoteSessionCwds.values()) {
3802
+ if (!pathMatchesCwd(rawPath, entry.cwd)) continue
3803
+ latestMatch = entry
3804
+ if (followedVirtuals.has(toVirtual(entry.serverId, entry.sessionId))) followedMatch = entry
3805
+ }
3806
+ if (followedMatch !== undefined) return { serverId: followedMatch.serverId, sessionId: followedMatch.sessionId }
3807
+ if (latestMatch !== undefined) return { serverId: latestMatch.serverId, sessionId: latestMatch.sessionId }
3808
+ return activeRemote
3809
+ }
3810
+ // The last successfully fetched SERVER model catalog (T52-fix), kept for
3811
+ // the offline merges of the `session/modelCatalog` route below — cleared
3812
+ // on a pairing wall (eagerly, by the T52-fix2 catalog watcher; the route's
3813
+ // own drop is the backstop) and never served for another server's id (see the
3814
+ // route's doc). Only an in-memory value: the groups are display data and
3815
+ // a reload refetches them anyway. Since T52-fix3 it feeds ONLY this route:
3816
+ // the modelSelection provider rewrites are unconditional again (the host's
3817
+ // projection store is first-write-wins per seq, so a catalog-gated rewrite
3818
+ // would poison the store — see virtualizeModelSelectionValue), and the
3819
+ // `groups` membership set is gone with the gate.
3820
+ let serverCatalogCache: { identity: MergerIdentity; value: unknown } | undefined
3821
+ /**
3822
+ * The serving generation, bumped on every pairing wall (`unpaired` /
3823
+ * `revoked`, T52-fix3): a catalog fetch that was IN FLIGHT when the wall
3824
+ * rose must not write its answer back — the wall's groups must not outlive
3825
+ * it, and a late write would resurrect them under the offline merge below.
3826
+ * Both cache writes (the proactive fetch here, the route's own fetch)
3827
+ * snapshot the counter before invoking and compare before storing; a
3828
+ * mismatched answer is silently dropped — it is not a fault, the next
3829
+ * serving period fetches fresh anyway.
3830
+ */
3831
+ let catalogGeneration = 0
3832
+ const rememberServerCatalog = (identity: MergerIdentity, value: unknown, generation: number): void => {
3833
+ if (generation !== catalogGeneration) return
3834
+ serverCatalogCache = { identity, value }
3835
+ }
3836
+ /** MS-3 pool mode: the per-server form of the same cache — one entry per
3837
+ * known `serverId`, keyed by it. An offline entry keeps serving its cache
3838
+ * (the groups must not go blank mid-outage); an entry that LEAVES the pool
3839
+ * takes its cache with it (its groups must not outlive the pairing). */
3840
+ const serverCatalogCaches = new Map<string, { identity: MergerIdentity; value: unknown }>()
3841
+ const catalogGenerations = new Map<string, number>()
3842
+ /** One server-catalog fetch whose answer only feeds the cache (T52-fix2):
3843
+ * the proactive pull that runs as soon as the relay serves — and once at
3844
+ * install when the relay already serves (subscribe replays no state —
3845
+ * relay-client.ts setState returns on a same-state write — so the
3846
+ * subscription alone would miss an install-into-online). Failures are
3847
+ * diagnostics-ring entries, nothing else (the route's own fetch is the
3848
+ * user-visible path and answers local-only there). */
3849
+ const fetchServerCatalog = (identity: MergerIdentity): void => {
3850
+ const generation = catalogGeneration
3851
+ void singleRelay.invoke('session', 'modelCatalog', {}, undefined).then(
3852
+ (value) => {
3853
+ rememberServerCatalog(identity, value, generation)
3854
+ },
3855
+ (error: unknown) => {
3856
+ recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
3857
+ },
3858
+ )
3859
+ }
3860
+ /** MS-3 pool mode: the per-server proactive fetch. The identity's display
3861
+ * name is the POOL's (the `-1`/`-2` disambiguated one) — it titles the
3862
+ * merged catalog's virtual groups. Generation-guarded per server, like
3863
+ * the single-server fetch above. */
3864
+ const fetchServerCatalogFor = (entry: { serverId: string; displayName: string; client: RelayClient }): void => {
3865
+ const serverId = entry.serverId
3866
+ const generation = catalogGenerations.get(serverId) ?? 0
3867
+ void entry.client.invoke('session', 'modelCatalog', {}, undefined).then(
3868
+ (value) => {
3869
+ if ((catalogGenerations.get(serverId) ?? 0) !== generation) return
3870
+ serverCatalogCaches.set(serverId, { identity: { serverId, serverName: entry.displayName }, value })
3871
+ },
3872
+ (error: unknown) => {
3873
+ recordFailure('session/modelCatalog', error instanceof RelayError ? error.code : 'internal')
3874
+ },
3875
+ )
3876
+ }
3877
+ // The catalog watcher (T52-fix2). The subscription fires on the real
3878
+ // online transitions (first handshake, every recovery) — and since T59
3879
+ // also on name-only notifications, which arrive with the state still
3880
+ // `online` and are deduped by the same refreshKey/stateSeen gates above.
3881
+ // The install-time check below covers an install into an already-online
3882
+ // relay. `unpaired` / `revoked`
3883
+ // drop the cache the same way the catalog route does lazily — the wall's
3884
+ // groups must not outlive it — and bump the generation so an in-flight
3885
+ // fetch cannot write the old catalog back (T52-fix3). Faults stay
3886
+ // contained: the sync body cannot throw, and the fetch settles through
3887
+ // .then/.catch above.
3888
+ let offCatalogWatch: (() => void) | undefined
3889
+ // MS-3 pool mode: one client subscription per pool entry (the map below),
3890
+ // created and retired by the watchPool rescan. MS-5: uninstall tears them
3891
+ // ALL down — `offCatalogWatch` only unsubscribes the rescan itself, and a
3892
+ // left-behind per-entry subscription would keep firing into this dead
3893
+ // generation's caches on every later state change.
3894
+ const catalogWatched = new Map<string, () => void>()
3895
+ if (pool === undefined) {
3896
+ try {
3897
+ offCatalogWatch = singleRelay.subscribe((state) => {
3898
+ if (state === 'unpaired' || state === 'revoked') {
3899
+ catalogGeneration += 1
3900
+ serverCatalogCache = undefined
3901
+ return
3902
+ }
3903
+ if (state !== 'online') return
3904
+ const identity = relayIdentityOf(singleRelay)
3905
+ if (identity !== undefined) fetchServerCatalog(identity)
3906
+ })
3907
+ // Install into an already-serving relay: subscribe replayed nothing, so
3908
+ // the transition-based fetch would wait for the NEXT flap — fill the
3909
+ // cache now instead (T52-fix3).
3910
+ if (singleRelay.state === 'online') {
3911
+ const identity = relayIdentityOf(singleRelay)
3912
+ if (identity !== undefined) fetchServerCatalog(identity)
3913
+ }
3914
+ } catch (error) {
3915
+ // Subscription failed: the cache still fills whenever the UI opens a
3916
+ // dropdown (the route's own fetch), only later.
3917
+ log?.('client catalog watcher unavailable: %s', messageOf(error))
3918
+ }
3919
+ } else {
3920
+ // MS-3 pool mode: one watcher per entry, rescanned on every pool event.
3921
+ // `unpaired` / `revoked` drop THAT server's cache and bump its
3922
+ // generation; `online` proactively refetches it (an entry appearing
3923
+ // already online is fetched at attach, same as the single-server
3924
+ // install-into-online above).
3925
+ offCatalogWatch = watchPool(() => {
3926
+ const seen = new Set<string>()
3927
+ for (const entry of pool.entries()) {
3928
+ seen.add(entry.entryId)
3929
+ if (catalogWatched.has(entry.entryId)) continue
3930
+ const unsubscribe = entry.client.subscribe((state) => {
3931
+ // The entry's serverId is read LIVE — a re-handshake may have
3932
+ // renamed it since this listener attached.
3933
+ const live = pool.entries().find((candidate) => candidate.entryId === entry.entryId)
3934
+ const serverId = live?.serverId
3935
+ if (serverId === undefined) return
3936
+ if (state === 'unpaired' || state === 'revoked') {
3937
+ catalogGenerations.set(serverId, (catalogGenerations.get(serverId) ?? 0) + 1)
3938
+ serverCatalogCaches.delete(serverId)
3939
+ return
3940
+ }
3941
+ if (state !== 'online') return
3942
+ const current = live ?? entry
3943
+ if (current.serverId !== undefined) {
3944
+ fetchServerCatalogFor({ serverId: current.serverId, displayName: current.displayName, client: current.client })
3945
+ }
3946
+ })
3947
+ catalogWatched.set(entry.entryId, unsubscribe)
3948
+ if (entry.client.state === 'online' && entry.serverId !== undefined) {
3949
+ fetchServerCatalogFor({ serverId: entry.serverId, displayName: entry.displayName, client: entry.client })
3950
+ }
3951
+ }
3952
+ for (const [entryId, unsubscribe] of [...catalogWatched]) {
3953
+ if (!seen.has(entryId)) {
3954
+ unsubscribe()
3955
+ catalogWatched.delete(entryId)
3956
+ }
3957
+ }
3958
+ })
3959
+ // Run the initial scan through the hub so the same rescan logic runs.
3960
+ for (const listener of [...poolListeners]) listener()
3961
+ }
3962
+ const registerClosed = (sessionId: string, reason: ClosedSessionRecord['reason']): void => {
3963
+ closedSessions.delete(sessionId)
3964
+ if (closedSessions.size >= MAX_CLOSED_SESSIONS) {
3965
+ const oldest = closedSessions.keys().next()
3966
+ if (oldest.done !== true) closedSessions.delete(oldest.value)
3967
+ }
3968
+ closedSessions.set(sessionId, reason)
3969
+ }
3970
+ const clearClosed = (virtuals: VirtualParts[]): void => {
3971
+ let cleared = false
3972
+ for (const parts of virtuals) {
3973
+ if (closedSessions.delete(toVirtual(parts.serverId, parts.id))) cleared = true
3974
+ }
3975
+ if (cleared) for (const waiter of [...closedClearWaiters]) waiter()
3976
+ }
3977
+ let installed = true
3978
+ let selfCheck: SelfCheckResult | undefined
3979
+
3980
+ /**
3981
+ * Reload overlay, for the record (CP4): the theoretical hazard is installing
3982
+ * a NEW wrapper while an OLD one is still installed, so each captures the
3983
+ * other and the older survives as a live layer. Cordis `fiber.restart()`
3984
+ * disposes the old fiber FIRST (its effect calls {@link InterceptHandle.uninstall},
3985
+ * restoring the saved property) and only then applies the new row — the
3986
+ * overlay ordering never happens today, so there is nothing to absorb at
3987
+ * install time. Detection is also not cheaply implementable: a reloaded row
3988
+ * loads a FRESH module instance, so a stale wrapper shares no mark with the
3989
+ * new one — telling "ours, from a dead generation" apart from another
3990
+ * plugin's wrapper would take a process-wide registry, which the comment
3991
+ * above argues is not worth it while the ordering cannot occur. The
3992
+ * `installed === false → inert passthrough` rule below is the safety net
3993
+ * for the ordering that CAN happen: a later plugin wrapping OVER us and
3994
+ * keeping a reference past our uninstall.
3995
+ *
3996
+ * Save the CURRENT value per property (possibly another plugin's wrapper —
3997
+ * shape note "own-property"), falling back to the prototype method only
3998
+ * when the current value is not callable. Uninstall restores the saved
3999
+ * own state verbatim: value if there was one, absence if not.
4000
+ */
4001
+ const saved: Record<string, { existed: boolean; value: unknown }> = {}
4002
+ const chainTarget: Record<string, unknown> = {}
4003
+ for (const name of ['openWireStream', 'dispatchRpc']) {
4004
+ const existed = Object.prototype.hasOwnProperty.call(gateway, name)
4005
+ const value = existed ? gateway[name] : undefined
4006
+ saved[name] = { existed, value }
4007
+ chainTarget[name] = typeof value === 'function' ? value : Object.getPrototypeOf(raw)?.[name]
4008
+ }
4009
+ // Filled in below, once the wrapper functions exist: uninstall may only
4010
+ // touch a property that still holds OUR wrapper.
4011
+ const wrappers: Record<string, unknown> = {}
4012
+
4013
+ const recordFailure = (endpoint: string, code: string): void => {
4014
+ if (failures.length >= MAX_FAILURES) failures.shift()
4015
+ failures.push({ time: new Date().toISOString(), endpoint, code })
4016
+ // T42: validation-shaped refusals additionally land in the incompatible
4017
+ // ring — every remote failure funnels through this one function, so the
4018
+ // classification cannot drift between the invoke and stream routes.
4019
+ if (!INCOMPATIBLE_CALL_CODES.has(code)) return
4020
+ if (incompatible.length >= MAX_INCOMPATIBLE_CALLS) incompatible.shift()
4021
+ incompatible.push({ time: Date.now(), endpoint, code })
4022
+ }
4023
+
4024
+ const failEnvelope = (
4025
+ endpoint: string,
4026
+ code: string,
4027
+ text: string,
4028
+ ): { ok: false; error: { code: string; message: string; details: Record<string, unknown> } } => {
4029
+ recordFailure(endpoint, code)
4030
+ // `details: {}` is load-bearing: the UI's server-response parser throws
4031
+ // `invalid server-response failure` on a details-less error (and the
4032
+ // gateway's rpcErrorSchema demands the same shape).
4033
+ return { ok: false, error: { code, message: text, details: {} } }
4034
+ }
4035
+
4036
+ /**
4037
+ * The shared validation both routes apply before forwarding — now (MS-3)
4038
+ * resolving the TARGET SERVER as well as the call's shape. The single
4039
+ * `relay` mode keeps the original verdict order byte-for-byte (offline →
4040
+ * mismatch → unsupported); pool mode routes by the virtual id's serverId:
4041
+ * no handshook server in the pool answers `remote-offline`, a handshook
4042
+ * pool without the target server answers `remote-mismatch`, and the
4043
+ * matched entry's client is the forwarding leg. A refusal is recorded
4044
+ * once, here, by the caller passing it through {@link failEnvelope}.
4045
+ */
4046
+ const copy = () => INTERCEPT_COPY[resolveInterceptLang(options.lang)]
4047
+ type Verdict = { ok: true; client: RelayClient; serverId: string } | { ok: false; code: string; message: string }
4048
+ function validate(endpoint: string, virtuals: VirtualParts[]): Verdict {
4049
+ const first = virtuals[0].serverId
4050
+ const c = copy()
4051
+ if (pool === undefined) {
4052
+ const current = singleGetServerId()
4053
+ if (current === undefined) return { ok: false, code: 'remote-offline', message: c.notConnected }
4054
+ if (virtuals.some((parts) => parts.serverId !== first) || current !== first) {
4055
+ return { ok: false, code: 'remote-mismatch', message: c.mismatch }
4056
+ }
4057
+ if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
4058
+ return { ok: false, code: 'remote-unsupported', message: c.unsupported }
4059
+ }
4060
+ return { ok: true, client: singleRelay, serverId: first }
4061
+ }
4062
+ if (virtuals.some((parts) => parts.serverId !== first)) {
4063
+ return { ok: false, code: 'remote-mismatch', message: c.mismatch }
4064
+ }
4065
+ const known = pool.entries().filter((entry) => entry.serverId !== undefined)
4066
+ if (known.length === 0) return { ok: false, code: 'remote-offline', message: c.notConnected }
4067
+ const target = known.find((entry) => entry.serverId === first)
4068
+ if (target === undefined) return { ok: false, code: 'remote-mismatch', message: c.mismatch }
4069
+ if (CLIENT_METHOD_FIELDS[endpoint] === undefined) {
4070
+ return { ok: false, code: 'remote-unsupported', message: c.unsupported }
4071
+ }
4072
+ return { ok: true, client: target.client, serverId: first }
4073
+ }
4074
+
4075
+ /**
4076
+ * The T32 `$events/result` split: an answer whose eventId is VIRTUAL
4077
+ * belongs to a forwarded remote event and must NOT reach the local
4078
+ * gateway (its own `$events` never delivered it — the local dispatcher
4079
+ * would answer with its `no active event stream` failure). The virtual
4080
+ * id goes back to the original, the local payload's clientId (the local
4081
+ * stream's own) is discarded — the relay server composes the gateway
4082
+ * payload from ITS subscription's clientId — and the outcome rides
4083
+ * verbatim. Anything else, including a malformed payload, is the local
4084
+ * gateway's business.
4085
+ */
4086
+ function forwardEventResult(payload: unknown, signal: AbortSignal | undefined, peer: unknown): unknown {
4087
+ const args = argsOf(payload)
2733
4088
  const eventId = isPlainObject(args) ? args.eventId : undefined
2734
4089
  const virtual = fromVirtual(eventId)
2735
4090
  if (virtual === undefined) {
@@ -2742,7 +4097,18 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2742
4097
  )
2743
4098
  }
2744
4099
  const endpoint = '$events/result'
2745
- const current = getServerId()
4100
+ const route = ((): { client: RelayClient } | 'offline' | 'missing' | 'mismatch' => {
4101
+ if (pool === undefined) {
4102
+ const current = singleGetServerId()
4103
+ if (current === undefined || singleRelay.state !== 'online') return 'offline'
4104
+ if (virtual.serverId !== current) return 'mismatch'
4105
+ return { client: singleRelay }
4106
+ }
4107
+ const target = pool.getByServerId(virtual.serverId)
4108
+ if (target === undefined) return 'missing'
4109
+ if (target.client.state !== 'online') return 'offline'
4110
+ return { client: target.client }
4111
+ })()
2746
4112
  // Offline (T34-fix): a refusal envelope would RESTART the UI's whole
2747
4113
  // `$events` generation — the client face's answer() throws on a
2748
4114
  // `!response.ok` body and the pump treats that as a delivery failure
@@ -2751,15 +4117,25 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2751
4117
  // synthesized cancels the dying leg already sent have closed these
2752
4118
  // prompts anyway. Answer the silent ok DSH itself gives a stale result,
2753
4119
  // and record the refusal in the diagnostics ring instead — the
2754
- // non-online write-refusal message, so the banner's word matches.
2755
- if (current === undefined || relay.state !== 'online') {
2756
- failEnvelope(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE)
4120
+ // non-online write-refusal message, so the banner's word matches. In
4121
+ // pool mode a leg the pool no longer serves (`missing` — unpaired away,
4122
+ // deduped, never paired) reads the same way: nothing can settle those
4123
+ // events anymore, so the silent ok wins there too. The single-server
4124
+ // mismatch keeps its original REFUSAL envelope (wrong server is a
4125
+ // different fact from a leg that just left).
4126
+ if (route === 'offline') {
4127
+ failEnvelope(endpoint, 'remote-offline', copy().writeOffline)
4128
+ return { ok: true, value: undefined }
4129
+ }
4130
+ if (route === 'missing') {
4131
+ failEnvelope(endpoint, 'remote-mismatch', copy().mismatch)
2757
4132
  return { ok: true, value: undefined }
2758
4133
  }
2759
- if (virtual.serverId !== current) return failEnvelope(endpoint, 'remote-mismatch', '此远程会话属于其他主服务端')
4134
+ if (route === 'mismatch') return failEnvelope(endpoint, 'remote-mismatch', copy().mismatch)
4135
+ const client = route.client
2760
4136
  return (async (): Promise<DispatchEnvelope> => {
2761
4137
  try {
2762
- await relay.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal)
4138
+ await client.postEventResult(virtual.id, isPlainObject(args) ? args.outcome : undefined, signal)
2763
4139
  return { ok: true, value: undefined }
2764
4140
  } catch (error) {
2765
4141
  const code = error instanceof RelayError ? error.code : 'internal'
@@ -2802,11 +4178,13 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2802
4178
  // A WRITE while the relay is not serving (T34, whitelist rule per
2803
4179
  // T34-fix): refused here, never sent — the call could only fail out
2804
4180
  // there, and the read paths answer for themselves (forwarded as today,
2805
- // failing with their own transport error).
2806
- if (isRemoteWrite(endpoint) && relay.state !== 'online') {
2807
- return failEnvelope(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE)
4181
+ // failing with their own transport error). Pool mode (MS-3): the verdict
4182
+ // names the TARGET leg — its state is the one that counts.
4183
+ if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
4184
+ return failEnvelope(endpoint, 'remote-offline', copy().writeOffline)
2808
4185
  }
2809
4186
  const serverId = verdict.serverId
4187
+ const client = verdict.client
2810
4188
  const slash = endpoint.indexOf('/')
2811
4189
  const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash)
2812
4190
  const method = slash === -1 ? '' : endpoint.slice(slash + 1)
@@ -2832,9 +4210,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2832
4210
  const references = restoreSessionReferences(clone, namespace, method, serverId)
2833
4211
  clone = references.args
2834
4212
  if (!references.ok) {
2835
- return failEnvelope(endpoint, 'remote-unsupported', '引用的会话不在服务端上,无法转发')
4213
+ return failEnvelope(endpoint, 'remote-unsupported', copy().referenceNotOnServer)
2836
4214
  }
2837
- const value = await relay.invoke(namespace, method, clone, signal)
4215
+ const value = await client.invoke(namespace, method, clone, signal)
2838
4216
  // The session answered: any closed-session entry for it is stale
2839
4217
  // (re-shared and served again) — the banner may go.
2840
4218
  clearClosed(virtuals)
@@ -2863,7 +4241,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2863
4241
  recordFailure(endpoint, code)
2864
4242
  // A call on a closed session reads like the banner, not like the
2865
4243
  // relay's bare code (T73).
2866
- const message = code === 'not-shared' ? CLOSED_MESSAGE : messageOf(error)
4244
+ const message = code === 'not-shared' ? copy().closed : messageOf(error)
2867
4245
  return { ok: false, error: { code, message, details: error instanceof RelayError && isPlainObject(error.details) ? error.details : {} } }
2868
4246
  }
2869
4247
  }
@@ -2897,20 +4275,20 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2897
4275
  * can never reach us (subscribe's contract keeps listener faults contained
2898
4276
  * on the client side).
2899
4277
  */
2900
- function waitForOnline(signal: AbortSignal | undefined): Promise<ServeWait> {
4278
+ function waitForOnline(client: RelayClient, signal: AbortSignal | undefined): Promise<ServeWait> {
2901
4279
  return new Promise<ServeWait>((resolve) => {
2902
4280
  if (signal?.aborted === true) {
2903
4281
  resolve('aborted')
2904
4282
  return
2905
4283
  }
2906
- if (relay.state === 'online') {
4284
+ if (client.state === 'online') {
2907
4285
  resolve('online')
2908
4286
  return
2909
4287
  }
2910
4288
  // The wall may already be down when the wait starts (the leg died and
2911
4289
  // the state flipped before this call) — same verdict as below.
2912
- if (relay.state === 'revoked' || relay.state === 'unpaired') {
2913
- resolve(relay.state)
4290
+ if (client.state === 'revoked' || client.state === 'unpaired') {
4291
+ resolve(client.state)
2914
4292
  return
2915
4293
  }
2916
4294
  let off: (() => void) | undefined
@@ -2922,7 +4300,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2922
4300
  resolve(verdict)
2923
4301
  }
2924
4302
  const onAbort = (): void => settle('aborted')
2925
- off = relay.subscribe((state) => {
4303
+ off = client.subscribe((state) => {
2926
4304
  if (state === 'online') settle('online')
2927
4305
  else if (state === 'revoked' || state === 'unpaired') settle(state)
2928
4306
  })
@@ -2955,14 +4333,14 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2955
4333
  * current backoff step. `aborted` only on caller abort; `revoked` /
2956
4334
  * `unpaired` when the pairing wall came down (CP5).
2957
4335
  */
2958
- async function waitServeAgain(signal: AbortSignal | undefined, backoffMs: number): Promise<ServeWait> {
2959
- if (relay.state === 'online') {
4336
+ async function waitServeAgain(client: RelayClient, signal: AbortSignal | undefined, backoffMs: number): Promise<ServeWait> {
4337
+ if (client.state === 'online') {
2960
4338
  await delayWait(signal, backoffMs)
2961
4339
  if (signal?.aborted === true) return 'aborted'
2962
- if (relay.state !== 'online') return waitForOnline(signal)
4340
+ if (client.state !== 'online') return waitForOnline(client, signal)
2963
4341
  return 'online'
2964
4342
  }
2965
- return waitForOnline(signal)
4343
+ return waitForOnline(client, signal)
2966
4344
  }
2967
4345
 
2968
4346
  /**
@@ -3007,6 +4385,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3007
4385
  */
3008
4386
  async function* rewriteUpstream(
3009
4387
  endpoint: string,
4388
+ client: RelayClient,
3010
4389
  upstream: AsyncIterable<unknown>,
3011
4390
  serverId: string,
3012
4391
  claimed: VirtualParts[],
@@ -3033,6 +4412,12 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3033
4412
  // closed-session entry for it is stale (T34).
3034
4413
  clearClosed(claimed)
3035
4414
  }
4415
+ // P55-2: the snapshot's header carries the session identity and its
4416
+ // cwd in ORIGINAL ids — record before the rewrite below virtualizes
4417
+ // them, so /api/file can attribute the session's own image paths.
4418
+ if (endpoint === 'session/follow' && isPlainObject(frame) && frame.type === 'snapshot' && isPlainObject(frame.header)) {
4419
+ recordRemoteSessionCwd(serverId, frame.header.id, frame.header.cwd)
4420
+ }
3036
4421
  yield rewriteFrame(endpoint, frame, serverId)
3037
4422
  }
3038
4423
  return
@@ -3090,7 +4475,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3090
4475
  if (onCleared !== undefined) closedClearWaiters.delete(onCleared)
3091
4476
  return
3092
4477
  }
3093
- throw new CodedStreamError(code, CLOSED_MESSAGE)
4478
+ throw new CodedStreamError(code, copy().closed)
3094
4479
  }
3095
4480
  recordFailure(endpoint, code)
3096
4481
  // A link-down fact (CP4), scoped by CP5: only the carrier-retry
@@ -3103,7 +4488,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3103
4488
  if (!SESSION_STREAM_HOLD_ENDPOINTS.has(endpoint)) {
3104
4489
  throw new CodedStreamError(code, messageOf(error))
3105
4490
  }
3106
- const serve = await waitServeAgain(signal, reopenDelayMs)
4491
+ const serve = await waitServeAgain(client, signal, reopenDelayMs)
3107
4492
  if (serve === 'aborted') return
3108
4493
  // The pairing wall came down while held (CP5): no ladder climbs back
3109
4494
  // from `revoked`/`unpaired` — only the settings page can — so the
@@ -3112,9 +4497,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3112
4497
  // would freeze the panel with no verdict and no wake-up.
3113
4498
  if (serve !== 'online') {
3114
4499
  recordFailure(endpoint, serve)
4500
+ const c = copy()
3115
4501
  throw new CodedStreamError(
3116
4502
  serve,
3117
- serve === 'revoked' ? '配对令牌已被吊销,请在设置页重新配对' : '尚未与服务端配对,请在设置页完成配对',
4503
+ serve === 'revoked' ? c.revoked : c.unpaired,
3118
4504
  )
3119
4505
  }
3120
4506
  if (callerAborted()) return
@@ -3131,6 +4517,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3131
4517
  * virtualized session ids. Paged requests, an offline relay, a non-ok local
3132
4518
  * envelope and a failed remote call all answer the local result untouched —
3133
4519
  * only a remote failure is recorded.
4520
+ *
4521
+ * MS-3 pool mode: EVERY online server's first page is fetched
4522
+ * concurrently and folded in `pool.entries()` order (pairing order), one
4523
+ * `mergeSessionList` per server. A failing leg is recorded and skipped —
4524
+ * the other servers' rows still merge.
3134
4525
  */
3135
4526
  async function mergedSessionListCall(
3136
4527
  endpoint: string,
@@ -3151,10 +4542,35 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3151
4542
  // Only the first page merges: a cursor pages the local list alone.
3152
4543
  const request = isPlainObject(args) ? args._request : undefined
3153
4544
  if (isPlainObject(request) && request.cursor !== undefined) return localEnvelope
3154
- const identity = relayIdentityOf(relay)
3155
- if (identity === undefined || relay.state !== 'online') return localEnvelope
4545
+ if (pool !== undefined) {
4546
+ const online = pool.entries().filter((entry) => entry.serverId !== undefined && entry.client.state === 'online')
4547
+ if (online.length === 0) return localEnvelope
4548
+ const settled = await Promise.all(
4549
+ online.map((entry) =>
4550
+ entry.client.invoke('session', 'list', isPlainObject(args) ? args : {}, signal).then(
4551
+ (value) => ({ ok: true as const, value, serverId: entry.serverId as string }),
4552
+ (error: unknown) => ({ ok: false as const, error, serverId: entry.serverId as string }),
4553
+ ),
4554
+ ),
4555
+ )
4556
+ let value = localEnvelope.value
4557
+ for (const result of settled) {
4558
+ if (!result.ok) {
4559
+ recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal')
4560
+ continue
4561
+ }
4562
+ // P55-2: the RAW remote rows (original ids) feed the /api/file cwd
4563
+ // registry before the merge virtualizes them.
4564
+ noteRemoteSessionListCwds(result.value, result.serverId)
4565
+ value = mergeSessionList(value, result.value, result.serverId)
4566
+ }
4567
+ return { ok: true, value }
4568
+ }
4569
+ const identity = relayIdentityOf(singleRelay)
4570
+ if (identity === undefined || singleRelay.state !== 'online') return localEnvelope
3156
4571
  try {
3157
- const remote = await relay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal)
4572
+ const remote = await singleRelay.invoke('session', 'list', isPlainObject(args) ? args : {}, signal)
4573
+ noteRemoteSessionListCwds(remote, identity.serverId)
3158
4574
  return { ok: true, value: mergeSessionList(localEnvelope.value, remote, identity.serverId) }
3159
4575
  } catch (error) {
3160
4576
  recordFailure(endpoint, error instanceof RelayError ? error.code : 'internal')
@@ -3197,8 +4613,52 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3197
4613
  )) as unknown
3198
4614
  // A local failure is the answer — there is nothing to merge into.
3199
4615
  if (!isPlainObject(localEnvelope) || localEnvelope.ok !== true) return localEnvelope
3200
- const identity = relayIdentityOf(relay)
3201
- if (identity !== undefined && relay.state === 'online') {
4616
+ if (pool !== undefined) {
4617
+ // MS-3: every PAIRED server contributes its own groups, folded in
4618
+ // pairing order; each server's last fetched catalog is cached by its
4619
+ // serverId so an offline server keeps its groups while the online ones
4620
+ // refetch. A cache whose server left the pool dies with the entry.
4621
+ const entries = pool.entries().filter((entry) => entry.serverId !== undefined)
4622
+ if (entries.length === 0) return localEnvelope
4623
+ const online = entries.filter((entry) => entry.client.state === 'online')
4624
+ const settled =
4625
+ online.length === 0
4626
+ ? []
4627
+ : await Promise.all(
4628
+ online.map((entry) =>
4629
+ entry.client.invoke('session', 'modelCatalog', {}, signal).then(
4630
+ (value) => ({ ok: true as const, value, serverId: entry.serverId as string }),
4631
+ (error: unknown) => ({ ok: false as const, error, serverId: entry.serverId as string }),
4632
+ ),
4633
+ ),
4634
+ )
4635
+ const resultsByServer = new Map(settled.map((result) => [result.serverId, result]))
4636
+ let acc = localEnvelope.value
4637
+ for (const entry of entries) {
4638
+ const serverId = entry.serverId as string
4639
+ const identity: MergerIdentity = { serverId, serverName: entry.displayName }
4640
+ const result = resultsByServer.get(serverId)
4641
+ if (result === undefined) {
4642
+ const cached = serverCatalogCaches.get(serverId)
4643
+ if (cached !== undefined) acc = mergeModelCatalogs(acc, cached.value, cached.identity)
4644
+ continue
4645
+ }
4646
+ if (!result.ok) {
4647
+ recordFailure(endpoint, result.error instanceof RelayError ? result.error.code : 'internal')
4648
+ const cached = serverCatalogCaches.get(serverId)
4649
+ if (cached !== undefined) acc = mergeModelCatalogs(acc, cached.value, cached.identity)
4650
+ continue
4651
+ }
4652
+ serverCatalogCaches.set(serverId, { identity, value: result.value })
4653
+ acc = mergeModelCatalogs(acc, result.value, identity)
4654
+ }
4655
+ for (const serverId of [...serverCatalogCaches.keys()]) {
4656
+ if (!entries.some((entry) => entry.serverId === serverId)) serverCatalogCaches.delete(serverId)
4657
+ }
4658
+ return { ok: true, value: acc }
4659
+ }
4660
+ const identity = relayIdentityOf(singleRelay)
4661
+ if (identity !== undefined && singleRelay.state === 'online') {
3202
4662
  try {
3203
4663
  // The method has NO parameters (RT lib/typert.remote-client.js:1019) —
3204
4664
  // the relay route allows it field-less (relay-access.ts), filters the
@@ -3209,7 +4669,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3209
4669
  // the counter and the answer never re-enters the cache — the caller
3210
4670
  // still gets its (live) answer, it asked while the link served.
3211
4671
  const generation = catalogGeneration
3212
- const remote = await relay.invoke('session', 'modelCatalog', {}, signal)
4672
+ const remote = await singleRelay.invoke('session', 'modelCatalog', {}, signal)
3213
4673
  rememberServerCatalog(identity, remote, generation)
3214
4674
  return { ok: true, value: mergeModelCatalogs(localEnvelope.value, remote, identity) }
3215
4675
  } catch (error) {
@@ -3219,7 +4679,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3219
4679
  }
3220
4680
  // Not serving normally. A pairing wall is a verdict about the server
3221
4681
  // itself — its groups must not outlive it.
3222
- if (relay.state === 'unpaired' || relay.state === 'revoked') {
4682
+ if (singleRelay.state === 'unpaired' || singleRelay.state === 'revoked') {
3223
4683
  serverCatalogCache = undefined
3224
4684
  return localEnvelope
3225
4685
  }
@@ -3277,16 +4737,26 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3277
4737
  const sessionParts = fromVirtual(sessionId)
3278
4738
  const providerParts = fromVirtual(provider)
3279
4739
  if (sessionParts === undefined && providerParts === undefined) return undefined
4740
+ const c = copy()
3280
4741
  if (sessionParts === undefined) {
3281
- return { code: 'remote-unsupported', message: '该模型分组属于远程服务端,无法在本机会话中使用' }
4742
+ return { code: 'remote-unsupported', message: c.modelGroupRemote }
3282
4743
  }
3283
- if (getServerId() !== sessionParts.serverId) return undefined
4744
+ // MS-3: the session's server is looked up in the POOL (by the virtual
4745
+ // id's serverId); a pool without that server falls through to the
4746
+ // generic path's verdict (offline / mismatch). The message names the
4747
+ // pool's display name — the same `-1`/`-2` disambiguated one the merged
4748
+ // catalog groups are titled with.
4749
+ const displayName = ((): string | undefined => {
4750
+ if (pool !== undefined) return pool.getByServerId(sessionParts.serverId)?.displayName
4751
+ if (singleGetServerId() !== sessionParts.serverId) return undefined
4752
+ return relayIdentityOf(singleRelay)?.serverName
4753
+ })()
4754
+ if (displayName === undefined) return undefined
3284
4755
  if (providerParts === undefined) {
3285
- const serverName = relayIdentityOf(relay)?.serverName ?? '服务端'
3286
- return { code: 'remote-unsupported', message: `请选择「${serverName} · …」分组里的模型` }
4756
+ return { code: 'remote-unsupported', message: c.modelGroupPickServer(displayName) }
3287
4757
  }
3288
4758
  if (providerParts.serverId !== sessionParts.serverId) {
3289
- return { code: 'remote-mismatch', message: '此模型分组属于其他主服务端' }
4759
+ return { code: 'remote-mismatch', message: c.modelGroupMismatch }
3290
4760
  }
3291
4761
  return { args: { ...args, request: { ...request, provider: providerParts.id } } }
3292
4762
  }
@@ -3379,13 +4849,32 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3379
4849
  return (async (): Promise<unknown> => {
3380
4850
  const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
3381
4851
  if (!isAsyncIterable(local)) return local
3382
- return mergedEventsStream({ local, relay, signal, recordFailure, log, clock, summarySync })
4852
+ if (pool !== undefined) {
4853
+ return mergedEventsStreamMulti({ local, pool, signal, recordFailure, log, clock, summarySync, watchPool })
4854
+ }
4855
+ return mergedEventsStream({ local, relay: singleRelay, signal, recordFailure, log, clock, summarySync })
3383
4856
  })()
3384
4857
  }
3385
4858
  const args = argsOf(payload)
3386
4859
  const fields = CLIENT_METHOD_FIELDS[endpoint]
3387
4860
  const virtuals = collectVirtuals(fields, args)
3388
4861
  if (virtuals.length === 0) {
4862
+ // P55-2: a LOCAL session/follow (a well-formed session address whose id
4863
+ // is not virtual) is the newest fact about which page the user is on —
4864
+ // recorded at the same instant the wrap is reached, so a later image
4865
+ // request never resolves against the remote session just left.
4866
+ if (endpoint === 'session/follow') {
4867
+ const address = isPlainObject(args) && isPlainObject(args.request) ? args.request.address : undefined
4868
+ if (
4869
+ isPlainObject(address) &&
4870
+ address.kind === 'session' &&
4871
+ typeof address.sessionId === 'string' &&
4872
+ address.sessionId !== '' &&
4873
+ !isVirtual(address.sessionId)
4874
+ ) {
4875
+ lastFollowedSession = { kind: 'local' }
4876
+ }
4877
+ }
3389
4878
  if (endpoint === 'workspace/follow' || endpoint === 'session/control') {
3390
4879
  // The startup self-check probe wants the LOCAL baseline only — no
3391
4880
  // remote leg, no merge (its payload is registered in probePayloads).
@@ -3414,12 +4903,32 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3414
4903
  // it); the remote leg carries no uplink.
3415
4904
  const local = await open.call(raw, endpoint, payload, uplink, peer, signal, control, uplinkWindow)
3416
4905
  if (!isAsyncIterable(local)) return local
4906
+ if (pool !== undefined) {
4907
+ return mergedGlobalStreamMulti({
4908
+ endpoint,
4909
+ namespace,
4910
+ method,
4911
+ local,
4912
+ pool,
4913
+ signal,
4914
+ recordFailure,
4915
+ log,
4916
+ clock,
4917
+ getCurrentSession,
4918
+ watchCurrentSession,
4919
+ watchUninstall,
4920
+ summarySync,
4921
+ createSessionInventorySlot,
4922
+ watchPool,
4923
+ lang: options.lang,
4924
+ })
4925
+ }
3417
4926
  return mergedGlobalStream({
3418
4927
  endpoint,
3419
4928
  namespace,
3420
4929
  method,
3421
4930
  local,
3422
- relay,
4931
+ relay: singleRelay,
3423
4932
  signal,
3424
4933
  recordFailure,
3425
4934
  log,
@@ -3428,7 +4937,10 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3428
4937
  watchCurrentSession,
3429
4938
  watchUninstall,
3430
4939
  summarySync,
3431
- createSessionInventorySlot,
4940
+ lang: options.lang,
4941
+ ...(createSessionInventorySlot !== undefined
4942
+ ? { createSessionInventorySlot: () => createSessionInventorySlot(undefined) }
4943
+ : {}),
3432
4944
  })
3433
4945
  })()
3434
4946
  }
@@ -3444,22 +4956,39 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3444
4956
  )
3445
4957
  }
3446
4958
  const verdict = validate(endpoint, virtuals)
3447
- if (!verdict.ok) throwRecorded(endpoint, verdict.code, verdict.message)
4959
+ if (!verdict.ok) {
4960
+ // TA-2: a refusal must not leak the host's UplinkInbox — half-close it
4961
+ // before the coded throw, the same courtesy the forwarded path's own
4962
+ // failure paths extend below.
4963
+ releaseUplink(uplink)
4964
+ throwRecorded(endpoint, verdict.code, verdict.message)
4965
+ }
3448
4966
  // A WRITE stream while the relay is not serving (CP4, the invoke route's
3449
- // T34 rule applied to the stream route): `terminal/follow` is the one
3450
- // write-shaped STREAM in the table — its attachment takes over the
3451
- // terminal's input control — so opening it into a dead link must refuse
3452
- // here, exactly like every other mutation.
3453
- if (isRemoteWrite(endpoint) && relay.state !== 'online') {
3454
- throwRecorded(endpoint, 'remote-offline', WRITE_OFFLINE_MESSAGE)
4967
+ // T34 rule applied to the stream route): the write-shaped STREAMS of the
4968
+ // table — `terminal/follow`, and its 0.2.2 duplex successor
4969
+ // `terminal/attach` (REMOTE_UPLINK_STREAM_METHODS) — take over the
4970
+ // terminal's input control, so opening them into a dead link must refuse
4971
+ // here, exactly like every other mutation. MS-3: the TARGET leg's state
4972
+ // is the one that counts.
4973
+ if (isRemoteWrite(endpoint) && verdict.client.state !== 'online') {
4974
+ releaseUplink(uplink)
4975
+ throwRecorded(endpoint, 'remote-offline', copy().writeOffline)
3455
4976
  }
3456
4977
  // The multiplexed wire channel ALWAYS hands us an UplinkInbox here —
3457
- // never undefined. Remote streams consume no uplink items, so the uplink
3458
- // is half-closed and the stream forwarded (the host's own move for its
3459
- // `$events` subscription, gateway releaseUplink). Rejecting uplinks
3460
- // would reject every remote stream the UI opens.
3461
- releaseUplink(uplink)
4978
+ // never undefined. One-directional remote streams consume no uplink
4979
+ // items, so the uplink is half-closed and the stream forwarded (the
4980
+ // host's own move for its `$events` subscription, gateway releaseUplink).
4981
+ // TA-2: a DUPLEX member of REMOTE_UPLINK_STREAM_METHODS keeps its inbox —
4982
+ // the host's TerminalInput write/resize commands ride it, so the iterable
4983
+ // and the mux's uplink window (shape-checked, garbage dropped) travel to
4984
+ // the relay client's pump instead; the pump releases the iterable itself
4985
+ // when the stream ends. Rejecting uplinks would reject every remote
4986
+ // stream the UI opens.
4987
+ const forwardsUplink = REMOTE_UPLINK_STREAM_METHODS.has(endpoint) && isAsyncIterable(uplink)
4988
+ if (!forwardsUplink) releaseUplink(uplink)
4989
+ const normalizedWindow = forwardsUplink ? normalizeUplinkWindow(uplinkWindow) : undefined
3462
4990
  const serverId = verdict.serverId
4991
+ const client = verdict.client
3463
4992
  const slash = endpoint.indexOf('/')
3464
4993
  const namespace = slash === -1 ? endpoint : endpoint.slice(0, slash)
3465
4994
  const method = slash === -1 ? '' : endpoint.slice(slash + 1)
@@ -3475,8 +5004,22 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3475
5004
  // through the outage and reopens the leg when the relay serves again
3476
5005
  // (CP4); every other endpoint's first read fails out with the original
3477
5006
  // code (CP5 scope, the pre-CP4 behavior).
3478
- upstream = relay.openStream(namespace, method, clone, signal)
5007
+ upstream = forwardsUplink
5008
+ ? client.openStream(
5009
+ namespace,
5010
+ method,
5011
+ clone,
5012
+ signal,
5013
+ {
5014
+ uplink: uplink as AsyncIterable<unknown>,
5015
+ ...(normalizedWindow !== undefined ? { uplinkWindow: normalizedWindow } : {}),
5016
+ },
5017
+ )
5018
+ : client.openStream(namespace, method, clone, signal)
3479
5019
  } catch (error) {
5020
+ // TA-2: a synchronous openStream failure (an unpaired relay) strands
5021
+ // the forwarded inbox — the pump never started, so half-close it here.
5022
+ if (forwardsUplink) releaseUplink(uplink)
3480
5023
  const code = error instanceof RelayError ? error.code : 'internal'
3481
5024
  throwRecorded(endpoint, code, messageOf(error))
3482
5025
  }
@@ -3489,9 +5032,28 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3489
5032
  // the endpoints OUTSIDE the list end with the original code at once,
3490
5033
  // their consumers rendering a clean end as a terminal (CP5,
3491
5034
  // SESSION_STREAM_HOLD_ENDPOINTS).
3492
- return rewriteUpstream(endpoint, upstream, serverId, virtuals, signal, () =>
3493
- relay.openStream(namespace, method, clone, signal),
5035
+ const held = rewriteUpstream(endpoint, client, upstream, serverId, virtuals, signal, () =>
5036
+ client.openStream(namespace, method, clone, signal),
3494
5037
  )
5038
+ // P55-2: a remote session/follow is recorded the instant the wrap is
5039
+ // reached (before any frame, before any browser report) — the first
5040
+ // <img> of the page cannot race a 1s poll. The follow stays in
5041
+ // `activeRemoteFollows` for the generator's whole life and leaves in its
5042
+ // finally, so the map holds exactly the LIVE follows.
5043
+ if (endpoint === 'session/follow') {
5044
+ const claimed = virtuals[0]
5045
+ lastFollowedSession = { kind: 'remote', serverId: claimed.serverId, sessionId: claimed.id }
5046
+ const token = Symbol()
5047
+ activeRemoteFollows.set(token, { serverId: claimed.serverId, sessionId: claimed.id })
5048
+ return (async function* () {
5049
+ try {
5050
+ yield* held
5051
+ } finally {
5052
+ activeRemoteFollows.delete(token)
5053
+ }
5054
+ })()
5055
+ }
5056
+ return held
3495
5057
  }
3496
5058
 
3497
5059
 
@@ -3505,11 +5067,22 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3505
5067
  uninstall() {
3506
5068
  if (!installed) return
3507
5069
  installed = false
5070
+ // P55-2: the attribution state dies with the wrap — the fetch-route
5071
+ // half reads the wiring's live `interceptHandle` slot, which the
5072
+ // uninstall clears, so this is hygiene; a stale map must never
5073
+ // outlive the interception that fed it.
5074
+ remoteSessionCwds.clear()
5075
+ activeRemoteFollows.clear()
5076
+ lastFollowedSession = undefined
3508
5077
  for (const listener of [...uninstallListeners]) listener()
3509
5078
  // The catalog watcher dies with the wrap (T52-fix2): a late online
3510
- // transition after this point belongs to another generation.
5079
+ // transition after this point belongs to another generation. MS-5: in
5080
+ // pool mode that includes every per-entry client subscription the
5081
+ // rescan created — the rescan's own unsubscribe is not enough.
3511
5082
  offCatalogWatch?.()
3512
5083
  offCatalogWatch = undefined
5084
+ for (const unsubscribe of catalogWatched.values()) unsubscribe()
5085
+ catalogWatched.clear()
3513
5086
  for (const name of ['openWireStream', 'dispatchRpc']) {
3514
5087
  // Only replace/delete while the property still holds OUR wrapper.
3515
5088
  // A later plugin may have wrapped OVER us: its wrapper captured this
@@ -3544,6 +5117,13 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3544
5117
  const virtualIds = ids.filter((id) => typeof id === 'string' && id !== '' && isVirtual(id))
3545
5118
  if (virtualIds.length > 0) summarySync.noteForwardedSessionIds(virtualIds)
3546
5119
  },
5120
+ notePoolEvent() {
5121
+ // MS-3: broadcast to every pool watcher — the live merged streams'
5122
+ // re-scans and the pool-mode catalog watcher (already registered as
5123
+ // one of the listeners in pool mode). No-op in single-server mode.
5124
+ for (const listener of [...poolListeners]) listener()
5125
+ },
5126
+ resolveRemoteFileScope,
3547
5127
  }
3548
5128
  }
3549
5129