dsh-zen-remote 2.3.11 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  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-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -40,6 +40,8 @@ import type { BridgeStatus } from './plugin-bridge.js'
40
40
  import { RELAY_HTTP_ROUTES } from './relay-access.js'
41
41
  import { relayCredentialsDigest, RelayError } from './relay-client.js'
42
42
  import type { RelayClient, RelayHttpResult } from './relay-client.js'
43
+ import { MAX_PAIRED_SERVERS } from './relay-pool.js'
44
+ import type { RelayPool, RelayPoolEntryView } from './relay-pool.js'
43
45
  import { resolveDshVersion } from './relay-server.js'
44
46
  import { fromVirtual } from './virtual-id.js'
45
47
 
@@ -105,6 +107,13 @@ export const CLIENT_UNSHARE_ROUTE = `${CLIENT_ROUTE_PREFIX}/unshare`
105
107
  * and re-judges the mergers immediately. */
106
108
  export const CLIENT_CURRENT_SESSION_ROUTE = `${CLIENT_ROUTE_PREFIX}/current-session`
107
109
 
110
+ /** POST `{entryId?: string, all?: boolean}` (MS-5): remove one paired server
111
+ * by its entry id, or every paired server at once (`all: true`). The paired
112
+ * list lives in `zen-remote-state.json`; this route goes through the pool so
113
+ * the live clients stop with their records. Pool-mode only — a mount without
114
+ * a pool has no paired list to edit and answers the unknown-path 404. */
115
+ export const CLIENT_UNPAIR_ROUTE = `${CLIENT_ROUTE_PREFIX}/unpair`
116
+
108
117
  /** Longest `sessionId` the current-session route accepts (T62): real ids —
109
118
  * a local `session-<uuid>` or a `zr~<serverId>~<id>` virtual id — are far
110
119
  * shorter; the cap bounds garbage, not reality. */
@@ -150,6 +159,21 @@ export interface ClientHandlerOptions {
150
159
  * never connected, so the settings page still gets a fresh answer while
151
160
  * the startup `connect()` is still unpaired. */
152
161
  getRelayClient?: () => RelayClient | undefined
162
+ /** MS-5 POOL mode: the multi-server relay pool. When present it REPLACES
163
+ * the row-driven single-client facts everywhere: the paired list (not the
164
+ * row's serverUrl/deviceToken) is the truth, claim pairs straight into the
165
+ * pool (the token never reaches the browser), unpair/reconnect address
166
+ * pool entries, and status / remote-status report the whole list (the
167
+ * top level keeps the FIRST entry's fields so the existing page keeps
168
+ * rendering). The single-`getRelayClient` form above stays exactly as it
169
+ * was for the old wiring and the tests. */
170
+ pool?: RelayPool
171
+ /** MS-5: the PB-2 bridge verdict for ONE pool entry, computed PER CALL off
172
+ * that entry's own handshake table and connection state — per-server
173
+ * isolation for the status / remote-status bodies. Undefined (old tests,
174
+ * the unwired host mount): every per-server `pluginBridge` / per-server
175
+ * `bridgedPlugins` list reads `[]`. */
176
+ getPluginBridgeStatusForServer?: (serverId: string | undefined, client: RelayClient) => BridgeStatus[]
153
177
  /** The typert interception's diagnostics (T23b-1), read live per request.
154
178
  * Shapes, counters and failure codes only — never a token. Undefined when
155
179
  * this composition never even attempted an install (no typertGateway). */
@@ -184,7 +208,9 @@ export interface ClientHandlerOptions {
184
208
  * mount's remoteStatusOnly wall 404s the route before this matters, and
185
209
  * a client handler WITHOUT the sink answers the same 404 (no sink, no
186
210
  * route). */
187
- onCurrentSession?: (read: CurrentSessionRead) => void
211
+ onCurrentSession?: (read: CurrentSessionRead, reportedLang?: 'zh' | 'en') => void
212
+ /** Language for route error messages (`'zh'` default, `'en'` English). */
213
+ lang?: 'zh' | 'en' | (() => 'zh' | 'en') | undefined
188
214
  /** CU-3, the debug-report route's inputs — all optional; the route answers
189
215
  * its 404-shaped refusal without them (an unwired composition must not
190
216
  * pretend to have diagnostics). Wired on BOTH role mounts.
@@ -424,28 +450,24 @@ function remoteStatusStateOf(relay: RelayClient | undefined): 'online' | 'offlin
424
450
  }
425
451
 
426
452
  /**
427
- * The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
428
- * branch always has — extracted (CU-3) so the debug-report route can embed
429
- * the same diagnostics aggregation without a second implementation. All
430
- * reads are live per call: volatile row fields, the interception getters,
431
- * the relay client's current state. May await one bounded probe or connect
432
- * (never longer than the probe timeout).
453
+ * The diagnostics decorator shared by the single-client and pool status
454
+ * bodies (CU-3's extraction, extended by MS-5): every interception getter is
455
+ * read live per call, and the compat verdict rides whenever there is
456
+ * something to report — from `compatRelay`'s last handshake (the single
457
+ * client, or in pool mode the FIRST entry's client) or from the interceptor's
458
+ * incompatible-call ring. `pluginBridgeOverride` replaces the single-client
459
+ * verdict source in pool mode (the first entry's per-server verdicts, keeping
460
+ * the top-level field's shape).
433
461
  */
434
- async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
435
- const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
436
- // The typert interception's view (T23b-1) rides on EVERY status
437
- // answer, whatever the connection state — the settings surface needs
438
- // it to explain a refused install even while unpaired. The interface
439
- // compat verdict (T42) appears once there is something to report: a
440
- // stored comparison from the relay client's last handshake, or any
441
- // incompatible call the interceptor recorded. Absent otherwise —
442
- // nothing to show, and the settings block presence-gates on exactly
443
- // that.
462
+ function diagnosticsDecorator(
463
+ options: ClientHandlerOptions,
464
+ compatRelay: RelayClient | undefined,
465
+ pluginBridgeOverride?: BridgeStatus[],
466
+ ): (body: Record<string, unknown>) => Record<string, unknown> {
444
467
  const intercept = options.getIntercept?.()
445
468
  const fetchRouteIntercept = options.getFetchRouteIntercept?.()
446
469
  const serviceProbe = options.getServiceProbe?.()
447
470
  const pluginRouteIntercept = options.getPluginRouteIntercept?.()
448
- const compatRelay = options.getRelayClient?.()
449
471
  const compat =
450
472
  compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
451
473
  ? {
@@ -455,17 +477,97 @@ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<strin
455
477
  incompatibleCalls: intercept?.incompatibleCalls ?? [],
456
478
  }
457
479
  : undefined
458
- const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
480
+ return (body: Record<string, unknown>): Record<string, unknown> => {
459
481
  // PB-2: the per-plugin bridge verdict rides EVERY status answer —
460
482
  // the settings surface explains a not-bridged plugin whatever the
461
483
  // connection state (unpaired reads all-offline, the honest verdict).
462
- let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
484
+ let next: Record<string, unknown> = { ...body, pluginBridge: pluginBridgeOverride ?? options.getPluginBridgeStatus?.() ?? [] }
463
485
  if (intercept !== undefined) next = { ...next, intercept }
464
486
  if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
465
487
  if (serviceProbe !== undefined) next = { ...next, serviceProbe }
466
488
  if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
467
489
  return compat === undefined ? next : { ...next, compat }
468
490
  }
491
+ }
492
+
493
+ /**
494
+ * One pool entry's per-server status row (MS-5): the live client facts and
495
+ * the entry's own identity — NEVER the token, which the route never even
496
+ * sees (it lives in the state file, behind the pool). `compat` appears only
497
+ * once that client has a handshake verdict to report; `pluginBridge` comes
498
+ * from the per-server verdict getter ([] unwired).
499
+ */
500
+ function poolServerStatus(entry: RelayPoolEntryView, options: ClientHandlerOptions): Record<string, unknown> {
501
+ const client = entry.client
502
+ const body: Record<string, unknown> = {
503
+ entryId: entry.entryId,
504
+ serverId: entry.serverId,
505
+ serverUrl: entry.serverUrl,
506
+ serverName: entry.serverName ?? '',
507
+ displayName: entry.displayName,
508
+ state: client.state,
509
+ nextRetryAt: client.nextRetryAt ?? null,
510
+ ...(client.lastError !== undefined ? { lastError: client.lastError } : {}),
511
+ ...(client.compat !== undefined
512
+ ? { compat: { identical: client.compat.identical, different: client.compat.different, unavailable: client.compat.unavailable } }
513
+ : {}),
514
+ pluginBridge: options.getPluginBridgeStatusForServer?.(entry.serverId, client) ?? [],
515
+ }
516
+ return body
517
+ }
518
+
519
+ /**
520
+ * The POOL status body (MS-5): the paired list is the truth — the row's
521
+ * serverUrl/deviceToken are never consulted. The top level keeps the FIRST
522
+ * entry's compatibility fields (the old single-server page keeps rendering)
523
+ * and every entry reports as one `servers` row; `maxServers` names the cap.
524
+ * An empty pool answers `unpaired` — the honest "nothing paired".
525
+ */
526
+ async function poolStatusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
527
+ const pool = options.pool as RelayPool
528
+ const entries = pool.entries()
529
+ const first = entries[0]
530
+ const bridgeOverride = first !== undefined
531
+ ? options.getPluginBridgeStatusForServer?.(first.serverId, first.client) ?? []
532
+ : []
533
+ const withDiagnostics = diagnosticsDecorator(options, first?.client, bridgeOverride)
534
+ const servers = entries.map((entry) => poolServerStatus(entry, options))
535
+ if (first === undefined) {
536
+ return withDiagnostics({ state: 'unpaired', maxServers: MAX_PAIRED_SERVERS, servers })
537
+ }
538
+ const body = deviceStatusBody(
539
+ first.client.state,
540
+ { serverName: first.serverName ?? '', deviceName: first.client.handshakeInfo?.deviceName },
541
+ first.serverUrl,
542
+ first.client.deviceNameSyncing,
543
+ )
544
+ if (first.client.nextRetryAt !== null && first.client.nextRetryAt !== undefined) body.nextRetryAt = first.client.nextRetryAt
545
+ if (first.client.lastError !== undefined) body.lastError = first.client.lastError
546
+ return withDiagnostics({ ...body, maxServers: MAX_PAIRED_SERVERS, servers })
547
+ }
548
+
549
+ /**
550
+ * The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
551
+ * branch always has — extracted (CU-3) so the debug-report route can embed
552
+ * the same diagnostics aggregation without a second implementation. All
553
+ * reads are live per call: volatile row fields, the interception getters,
554
+ * the relay client's current state. May await one bounded probe or connect
555
+ * (never longer than the probe timeout). In pool mode the paired list is
556
+ * the truth instead ({@link poolStatusBodyOf}); the row is not consulted.
557
+ */
558
+ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
559
+ const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
560
+ if (options.pool !== undefined) return poolStatusBodyOf(options)
561
+ // The typert interception's view (T23b-1) rides on EVERY status
562
+ // answer, whatever the connection state — the settings surface needs
563
+ // it to explain a refused install even while unpaired. The interface
564
+ // compat verdict (T42) appears once there is something to report: a
565
+ // stored comparison from the relay client's last handshake, or any
566
+ // incompatible call the interceptor recorded. Absent otherwise —
567
+ // nothing to show, and the settings block presence-gates on exactly
568
+ // that.
569
+ const compatRelay = options.getRelayClient?.()
570
+ const withDiagnostics = diagnosticsDecorator(options, compatRelay)
469
571
  // The ROW decides its own two shapes first, per request (both fields
470
572
  // are volatile { get() } wrappers — only the row object apply()
471
573
  // received is a live source): without both credentials nothing is
@@ -527,6 +629,12 @@ async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<strin
527
629
  */
528
630
  export function createClientHandler(options: ClientHandlerOptions): ClientHandler {
529
631
  const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
632
+ const resolveLang = (): 'zh' | 'en' => {
633
+ const value = typeof options.lang === 'function' ? options.lang() : options.lang
634
+ return value === 'en' ? 'en' : 'zh'
635
+ }
636
+ const mismatchMessage = (): string =>
637
+ resolveLang() === 'en' ? 'This remote session belongs to another server' : '此远程会话属于其他主服务端'
530
638
  return async (req, res) => {
531
639
  // Wall 1: DSH's own admission, relaying its refusal verbatim.
532
640
  const admission = options.admit(req)
@@ -549,7 +657,10 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
549
657
  if (route === CLIENT_DEBUG_REPORT_ROUTE && req.headers['x-zen-remote-via'] !== undefined) {
550
658
  responseJson(res, 403, {
551
659
  ok: false,
552
- error: { code: 'via-gateway', message: '诊断报告只能在本机导出' },
660
+ error: {
661
+ code: 'via-gateway',
662
+ message: resolveLang() === 'en' ? 'Diagnostics report can only be exported on this machine' : '诊断报告只能在本机导出',
663
+ },
553
664
  })
554
665
  return
555
666
  }
@@ -580,6 +691,23 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
580
691
  responseJson(res, 400, { ok: false, code: normalized.reason })
581
692
  return
582
693
  }
694
+ // MS-5 pool mode: the 8-server cap is judged BEFORE the pairing
695
+ // round-trip — a refusal the user can fix locally (remove a server,
696
+ // or re-enter an already-paired address) must not spend a claim code
697
+ // on the wire. An address that is ALREADY paired is always allowed
698
+ // through: re-pairing it just refreshes the token in place, at 8/8
699
+ // too. The pool stays the single writer afterwards: the gateway's
700
+ // token goes straight into `zen-remote-state.json` and NEVER reaches
701
+ // the browser — this response carries only the entry id and the
702
+ // address echo.
703
+ const pool = options.pool
704
+ if (pool !== undefined) {
705
+ const known = pool.entries().some((entry) => entry.serverUrl === normalized.url)
706
+ if (!known && pool.entries().length >= MAX_PAIRED_SERVERS) {
707
+ responseJson(res, 409, { ok: false, code: 'limit-reached' })
708
+ return
709
+ }
710
+ }
583
711
  const payload = JSON.stringify({
584
712
  code: stringOrEmpty(body.code),
585
713
  name: stringOrEmpty(body.name),
@@ -614,7 +742,68 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
614
742
  responseJson(res, 502, { ok: false, code: 'unreachable' })
615
743
  return
616
744
  }
617
- respondClaim(res, classifyClaimResponse(status, gatewayBody), normalized.url)
745
+ const outcome = classifyClaimResponse(status, gatewayBody)
746
+ if (pool === undefined || !outcome.ok) {
747
+ respondClaim(res, outcome, normalized.url)
748
+ return
749
+ }
750
+ // Pool mode, redemption succeeded: land the pairing in the state
751
+ // file through the pool (which also starts/refreshes the live
752
+ // client) and answer without the token. The pair() can only refuse
753
+ // past the pre-check on a concurrent writer — the same envelopes.
754
+ const paired = await pool.pair({
755
+ serverUrl: normalized.url,
756
+ token: outcome.token,
757
+ deviceId: outcome.deviceId,
758
+ deviceName: outcome.deviceName,
759
+ })
760
+ if (!paired.ok) {
761
+ responseJson(res, paired.code === 'limit-reached' ? 409 : 400, { ok: false, code: paired.code })
762
+ return
763
+ }
764
+ responseJson(res, 200, {
765
+ ok: true,
766
+ entryId: paired.entry.entryId,
767
+ deviceId: outcome.deviceId,
768
+ deviceName: outcome.deviceName,
769
+ serverUrl: normalized.url,
770
+ })
771
+ return
772
+ }
773
+ if (route === CLIENT_UNPAIR_ROUTE) {
774
+ // MS-5: remove one paired server or all of them. Pool-mode only —
775
+ // a mount without a pool has no paired list to edit (the old
776
+ // single-server pairing lived in the ROW, which the browser writes
777
+ // directly), and answers the same 404 an unknown path would.
778
+ const pool = options.pool
779
+ if (pool === undefined) {
780
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
781
+ return
782
+ }
783
+ if (method !== 'POST') {
784
+ res.setHeader('Allow', 'POST')
785
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
786
+ return
787
+ }
788
+ if (!sameOriginPost(req)) {
789
+ responseJson(res, 403, {
790
+ ok: false,
791
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
792
+ })
793
+ return
794
+ }
795
+ const body = await readJsonBody(req)
796
+ if (body.all === true) {
797
+ const removed = pool.unpairAll()
798
+ responseJson(res, 200, { ok: true, removed })
799
+ return
800
+ }
801
+ if (typeof body.entryId === 'string' && body.entryId !== '') {
802
+ const removed = pool.unpair(body.entryId)
803
+ responseJson(res, 200, { ok: true, removed: removed ? 1 : 0 })
804
+ return
805
+ }
806
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'entryId (non-empty string) or all (true) is required' } })
618
807
  return
619
808
  }
620
809
  if (route === CLIENT_STATUS_ROUTE) {
@@ -672,6 +861,8 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
672
861
  pushSummary: values.pushSummary,
673
862
  pushTurnEnd: values.pushTurnEnd,
674
863
  pushTool: values.pushTool,
864
+ pushEnabled: values.pushEnabled,
865
+ pushSessionName: values.pushSessionName,
675
866
  serverUrl: stringOrEmpty(unwrapVolatile(record.serverUrl)).trim(),
676
867
  },
677
868
  statusBody: await statusBodyOf(options),
@@ -696,6 +887,31 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
696
887
  return
697
888
  }
698
889
  const relay = options.getRelayClient?.()
890
+ // MS-5 pool mode: an optional { entryId } names ONE server — the
891
+ // same offline-only, 409-on-conflict contract the single client's
892
+ // button has, per entry; without it every OFFLINE server gets one
893
+ // attempt (an online server is not "reconnected", it is fine).
894
+ const pool = options.pool
895
+ if (pool !== undefined) {
896
+ const body = await readJsonBody(req)
897
+ const entryId = typeof body.entryId === 'string' && body.entryId !== '' ? body.entryId : undefined
898
+ if (entryId === undefined) {
899
+ const started = await pool.reconnect()
900
+ responseJson(res, 200, { ok: true, started })
901
+ return
902
+ }
903
+ const entry = pool.getByEntryId(entryId)
904
+ if (entry === undefined) {
905
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'No such paired server' } })
906
+ return
907
+ }
908
+ if (entry.client.state !== 'offline' || !entry.client.reconnect()) {
909
+ responseJson(res, 409, { ok: false, error: { code: 'not-offline', message: 'The client is not offline' } })
910
+ return
911
+ }
912
+ responseJson(res, 200, { ok: true })
913
+ return
914
+ }
699
915
  // No relay client (a composition that never built one): there is
700
916
  // nothing to reconnect, and the probe-shaped status route is the
701
917
  // only connection surface this deployment has.
@@ -729,6 +945,16 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
729
945
  return
730
946
  }
731
947
  const relay = options.getRelayClient?.()
948
+ // MS-5 pool mode: the same QUESTION per server — one authenticated
949
+ // ping sweep over the offline clients, the reachable ones get the
950
+ // controlled `networkRestored()` (probe-first, no storm, offline
951
+ // only — the pool method enforces the same rules per client).
952
+ const pool = options.pool
953
+ if (pool !== undefined) {
954
+ const outcome = await pool.probeAndRestoreAll()
955
+ responseJson(res, 200, { ok: true, reachable: outcome.reachable, reconnected: outcome.reconnected })
956
+ return
957
+ }
732
958
  if (relay === undefined) {
733
959
  responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
734
960
  return
@@ -762,8 +988,41 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
762
988
  // sort is pinned here so the field's shape never depends on the
763
989
  // registry's internal order). The host mount — no verdict source —
764
990
  // answers [].
765
- const relay = options.getRelayClient?.()
991
+ // MS-5 pool mode: the SAME shape per server, under `servers` keyed
992
+ // by the handshake serverId (an entry that never handshook owns no
993
+ // virtual ids and is not addressable — skipped). The top level
994
+ // mirrors the FIRST entry so the old page keeps rendering.
995
+ const pool = options.pool
766
996
  const intercept = options.getIntercept?.()
997
+ const bridgedOf = (serverId: string | undefined, client: RelayClient): string[] =>
998
+ (options.getPluginBridgeStatusForServer?.(serverId, client) ?? [])
999
+ .filter((status) => status.bridged)
1000
+ .map((status) => status.plugin)
1001
+ .sort()
1002
+ if (pool !== undefined) {
1003
+ const entries = pool.entries()
1004
+ const first = entries[0]
1005
+ const servers: Record<string, Record<string, unknown>> = {}
1006
+ for (const entry of entries) {
1007
+ if (entry.serverId === undefined) continue
1008
+ servers[entry.serverId] = {
1009
+ state: remoteStatusStateOf(entry.client),
1010
+ versionMismatch: (entry.client.compat?.different?.length ?? 0) > 0,
1011
+ serverName: entry.serverName ?? '',
1012
+ bridgedPlugins: bridgedOf(entry.serverId, entry.client),
1013
+ }
1014
+ }
1015
+ responseJson(res, 200, {
1016
+ state: remoteStatusStateOf(first?.client),
1017
+ versionMismatch: (first?.client.compat?.different?.length ?? 0) > 0,
1018
+ serverName: first?.serverName ?? '',
1019
+ closed: Object.fromEntries((intercept?.closedSessions ?? []).map((record) => [record.sessionId, record.reason])),
1020
+ bridgedPlugins: first !== undefined ? bridgedOf(first.serverId, first.client) : [],
1021
+ servers,
1022
+ })
1023
+ return
1024
+ }
1025
+ const relay = options.getRelayClient?.()
767
1026
  responseJson(res, 200, {
768
1027
  state: remoteStatusStateOf(relay),
769
1028
  versionMismatch: (relay?.compat?.different?.length ?? 0) > 0,
@@ -807,7 +1066,8 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
807
1066
  return
808
1067
  }
809
1068
  const read: CurrentSessionRead = raw === null ? { kind: 'none' } : { kind: 'open', sessionId: raw }
810
- sink(read)
1069
+ const reportedLang = body.lang === 'en' ? ('en' as const) : body.lang === 'zh' ? ('zh' as const) : undefined
1070
+ sink(read, reportedLang)
811
1071
  responseJson(res, 200, { ok: true })
812
1072
  return
813
1073
  }
@@ -832,6 +1092,25 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
832
1092
  responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId must be a remote (virtual) session id' } })
833
1093
  return
834
1094
  }
1095
+ // MS-5 pool mode: the id's serverId half names the server — its own
1096
+ // client closes the session with the ORIGINAL id. A server the pool
1097
+ // does not hold is the same remote-mismatch the interceptor answers.
1098
+ const pool = options.pool
1099
+ if (pool !== undefined) {
1100
+ const entry = pool.getByServerId(parts.serverId)
1101
+ if (entry === undefined) {
1102
+ responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
1103
+ return
1104
+ }
1105
+ try {
1106
+ await entry.client.unshare(parts.id)
1107
+ responseJson(res, 200, { ok: true })
1108
+ } catch (error) {
1109
+ const code = error instanceof RelayError ? error.code : 'internal'
1110
+ responseJson(res, 200, { ok: false, error: { code, message: error instanceof Error ? error.message : String(error) } })
1111
+ }
1112
+ return
1113
+ }
835
1114
  const relay = options.getRelayClient?.()
836
1115
  if (relay === undefined) {
837
1116
  responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'No relay client is running' } })
@@ -841,7 +1120,7 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
841
1120
  // just answer not-shared out there — the mismatch is answerable
842
1121
  // locally, with the code the interceptor uses for the same fact.
843
1122
  if (relay.handshakeInfo === undefined || relay.handshakeInfo.serverId !== parts.serverId) {
844
- responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: '此远程会话属于其他主服务端' } })
1123
+ responseJson(res, 200, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
845
1124
  return
846
1125
  }
847
1126
  try {
@@ -888,13 +1167,42 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
888
1167
  responseJson(res, 400, { ok: false, error: { code: 'not-virtual', message: 'sessionId is not a remote session id' } })
889
1168
  return
890
1169
  }
1170
+ // MS-5 pool mode: route by the id's serverId half. A server the pool
1171
+ // does not hold refuses like a foreign one; its client must be the
1172
+ // ONLINE one before the query travels with the original id restored.
1173
+ const pool = options.pool
1174
+ if (pool !== undefined) {
1175
+ const entry = pool.getByServerId(parts.serverId)
1176
+ if (entry === undefined) {
1177
+ responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
1178
+ return
1179
+ }
1180
+ if (entry.client.state !== 'online') {
1181
+ responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } })
1182
+ return
1183
+ }
1184
+ url.searchParams.set('sessionId', parts.id)
1185
+ // A browser walk-off cancels the round-trip mid-flight; the write
1186
+ // guard in the catch keeps a settled answer from racing the close.
1187
+ const hangUp = new AbortController()
1188
+ res.once('close', () => {
1189
+ if (!res.writableEnded) hangUp.abort()
1190
+ })
1191
+ try {
1192
+ const result = await entry.client.http(httpRoute, url.searchParams.toString(), hangUp.signal)
1193
+ respondUpstream(res, result)
1194
+ } catch (error) {
1195
+ if (!res.writableEnded && !res.destroyed) respondRelayFailure(res, error)
1196
+ }
1197
+ return
1198
+ }
891
1199
  const relay = options.getRelayClient?.()
892
1200
  if (relay === undefined || relay.state !== 'online') {
893
1201
  responseJson(res, 503, { ok: false, error: { code: 'remote-offline' } })
894
1202
  return
895
1203
  }
896
1204
  if (relay.handshakeInfo?.serverId !== parts.serverId) {
897
- responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: '此远程会话属于其他主服务端' } })
1205
+ responseJson(res, 400, { ok: false, error: { code: 'remote-mismatch', message: mismatchMessage() } })
898
1206
  return
899
1207
  }
900
1208
  url.searchParams.set('sessionId', parts.id)
package/src/config.ts CHANGED
@@ -41,9 +41,11 @@ const SERVER_NAME_MAX = 40
41
41
 
42
42
  /** The effective configuration one resolved value set, defaults included. */
43
43
  export interface ZenRemoteConfig {
44
- /** Which half of the plugin runs in this DSH process; anything the layers
45
- * leave unresolved means `'host'` (see {@link resolveRole}). */
46
- role: 'host' | 'client'
44
+ /** Which half of the plugin runs in this DSH process. `'host'` serves the
45
+ * gateway and never dials out; `'client'` dials one server and serves no
46
+ * gateway; `'peer'` (MS Phase 2) does both. Anything the layers leave
47
+ * unresolved means `'host'` (see {@link resolveRole}). */
48
+ role: 'host' | 'client' | 'peer'
47
49
  /** Port the gateway child listens on. */
48
50
  port: number
49
51
  /** Bind address of the gateway child. */
@@ -69,6 +71,12 @@ export interface ZenRemoteConfig {
69
71
  pushTurnEnd: boolean
70
72
  /** Register the model-facing `push_notify` tool. */
71
73
  pushTool: boolean
74
+ /** Master switch for session notifications: when false, disables all push
75
+ * listeners and does not register push_notify. */
76
+ pushEnabled: boolean
77
+ /** Include the source session name on the first line of push notification
78
+ * bodies. */
79
+ pushSessionName: boolean
72
80
  /** Server display name, at most {@link SERVER_NAME_MAX} characters. */
73
81
  serverName: string
74
82
  /** Idle-sleep window of a remote-enabled session: after this many hours
@@ -120,6 +128,8 @@ export const DEFAULTS: ZenRemoteConfig = {
120
128
  pushSummary: false,
121
129
  pushTurnEnd: false,
122
130
  pushTool: true,
131
+ pushEnabled: true,
132
+ pushSessionName: false,
123
133
  serverName: defaultServerName(),
124
134
  idleHours: 48,
125
135
  autoShareNewSessions: false,
@@ -335,7 +345,7 @@ interface FieldResolver<T> {
335
345
  }
336
346
 
337
347
  const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]> } = {
338
- role: { row: oneOf('host', 'client'), file: oneOf('host', 'client') },
348
+ role: { row: oneOf('host', 'client', 'peer'), file: oneOf('host', 'client', 'peer') },
339
349
  port: { env: envNumber('LAN_GATE_PORT', intIn(1, 65535)), row: intIn(1, 65535), file: lenientFileNumber(intIn(1, 65535)) },
340
350
  host: { env: envString('LAN_GATE_HOST'), row: nonEmptyString, file: nonEmptyString },
341
351
  targetPort: { env: envNumber('LAN_GATE_TARGET_PORT', intIn(1, 65535)), row: intIn(1, 65535) },
@@ -348,6 +358,12 @@ const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]>
348
358
  pushSummary: { env: envBool('DSH_PUSH_SUMMARY', (raw) => raw === '1'), row: boolLike, file: boolLike },
349
359
  pushTurnEnd: { env: envBool('DSH_PUSH_TURN_END', (raw) => raw === '1'), row: boolLike, file: boolLike },
350
360
  pushTool: { env: envBool('DSH_PUSH_TOOL', (raw) => raw !== '0'), row: boolLike, file: boolLike },
361
+ // The master switch copies pushTool's env shape (default on, only the exact
362
+ // '0' flips it off); the session-name line copies pushSummary's (default
363
+ // off, '1' turns it on). Both restart the push leg because apply() wires
364
+ // its listeners from them (P55-1) — hence the RESTART_FIELDS entries.
365
+ pushEnabled: { env: envBool('DSH_PUSH_ENABLED', (raw) => raw !== '0'), row: boolLike, file: boolLike },
366
+ pushSessionName: { env: envBool('DSH_PUSH_SESSION_NAME', (raw) => raw === '1'), row: boolLike, file: boolLike },
351
367
  serverName: { row: serverNameOf, file: serverNameOf },
352
368
  idleHours: { row: inRange(0, 8760), file: inRange(0, 8760) },
353
369
  autoShareNewSessions: { row: boolLike, file: boolLike },
@@ -444,6 +460,8 @@ export function resolveConfig(
444
460
  pushSummary: resolveField('pushSummary', safeRow, safeFile, env, sources),
445
461
  pushTurnEnd: resolveField('pushTurnEnd', safeRow, safeFile, env, sources),
446
462
  pushTool: resolveField('pushTool', safeRow, safeFile, env, sources),
463
+ pushEnabled: resolveField('pushEnabled', safeRow, safeFile, env, sources),
464
+ pushSessionName: resolveField('pushSessionName', safeRow, safeFile, env, sources),
447
465
  serverName: resolveField('serverName', safeRow, safeFile, env, sources),
448
466
  idleHours: resolveField('idleHours', safeRow, safeFile, env, sources),
449
467
  autoShareNewSessions: resolveField('autoShareNewSessions', safeRow, safeFile, env, sources),
@@ -475,19 +493,42 @@ export function readFileConfig(env: NodeJS.ProcessEnv = process.env): Record<str
475
493
  }
476
494
 
477
495
  /**
478
- * The normalized role behind a row's `role` knob. Anything but the exact
479
- * string `'client'` means host, so a typo degrades to the full plugin rather
480
- * than silently dropping the gateway and push halves. The value is read
481
- * through {@link unwrapVolatile} first: since T17 every row field arrives as
482
- * a `{ get() }` live reference, and comparing the wrapper itself against
483
- * `'client'` would always answer host (T17b). Resolution-order note:
484
- * {@link resolveConfig} validates each layer against the two-member enum
485
- * first, so an invalid row role can still be rescued by the file layer; this
486
- * function answers the final merged value. Kept on the main entry's export
487
- * surface for test/role-wiring.test.cjs.
496
+ * The normalized role behind a row's `role` knob. Only the exact strings
497
+ * `'client'` and `'peer'` mean those roles — everything else degrades to
498
+ * host, so a typo runs the full plugin rather than silently dropping the
499
+ * gateway and push halves. The value is read through {@link unwrapVolatile}
500
+ * first: since T17 every row field arrives as a `{ get() }` live reference,
501
+ * and comparing the wrapper itself against a role string would always answer
502
+ * host (T17b). Resolution-order note: {@link resolveConfig} validates each
503
+ * layer against the enum first, so an invalid row role can still be rescued
504
+ * by the file layer; this function answers the final merged value. Kept on
505
+ * the main entry's export surface for test/role-wiring.test.cjs.
488
506
  */
489
- export function resolveRole(row: unknown): 'host' | 'client' {
490
- return unwrapVolatile((row as { role?: unknown } | null | undefined)?.role) === 'client' ? 'client' : 'host'
507
+ export function resolveRole(row: unknown): 'host' | 'client' | 'peer' {
508
+ const value = unwrapVolatile((row as { role?: unknown } | null | undefined)?.role)
509
+ if (value === 'client') return 'client'
510
+ if (value === 'peer') return 'peer'
511
+ return 'host'
512
+ }
513
+
514
+ /**
515
+ * Whether the role serves remote access to OTHER machines: host and peer do
516
+ * (the gateway, relay and push halves mount), a pure client does not. The
517
+ * role-gate the wiring reads INSTEAD of a `=== 'host'` compare, so a peer
518
+ * deploy gets the serving halves without every call site growing a third
519
+ * branch (MS Phase 2 wires it through).
520
+ */
521
+ export function servesRole(role: 'host' | 'client' | 'peer'): boolean {
522
+ return role !== 'client'
523
+ }
524
+
525
+ /**
526
+ * Whether the role dials OUT to paired servers: client and peer do (the
527
+ * relay client / pool), a pure host has nothing to dial. {@link servesRole}'s
528
+ * counterpart.
529
+ */
530
+ export function connectsRole(role: 'host' | 'client' | 'peer'): boolean {
531
+ return role !== 'host'
491
532
  }
492
533
 
493
534
  /**
@@ -536,6 +577,11 @@ export const Config = z.object({
536
577
  pushSummary: z.any().volatile(),
537
578
  pushTurnEnd: z.any().volatile(),
538
579
  pushTool: z.any().volatile(),
580
+ // Push-leg master switch and session-name line (P55-1): apply() wires its
581
+ // listeners and the push_notify tool from them, so both reload the row on
582
+ // change (RESTART_FIELDS).
583
+ pushEnabled: z.any().volatile(),
584
+ pushSessionName: z.any().volatile(),
539
585
  serverName: z.any().volatile(),
540
586
  idleHours: z.any().volatile(),
541
587
  autoShareNewSessions: z.any().volatile(),