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
package/src/index.ts CHANGED
@@ -39,20 +39,21 @@ declare module '@deepseek-ai/cordis' {
39
39
  'loader/volatile-update'(paths: string[][]): void
40
40
  }
41
41
  }
42
- import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
42
+ import { readFileConfig, resolveConfig, unwrapVolatile, servesRole, connectsRole } from './config.js'
43
43
  import { createActivityTracker, createParentIndex, seedParentIndexFromSnapshots, startSweeper, type PersistedSessionSnapshot } from './activity.js'
44
44
  import { ADMIN_ROUTE_PREFIX, createAdminHandler } from './admin-routes.js'
45
- import type { SessionQueryLike } from './admin-routes.js'
46
- import { normalizedRowServerUrl } from './client-pairing.js'
45
+ import type { AdminHandlerOptions, SessionQueryLike } from './admin-routes.js'
47
46
  import { CLIENT_ROUTE_PREFIX, createClientHandler } from './client-routes.js'
48
47
  import { responseJson, sameOriginPost } from './http.js'
49
- import { onSessionCreated, restoreBusy } from './share-ops.js'
50
- import type { AgentStatusLike } from './share-ops.js'
48
+ import { isBlankSession, isFirstTurnStart, onSessionCreated, restoreBusy } from './share-ops.js'
49
+ import type { AgentStatusLike, TurnBoundaryLike } from './share-ops.js'
51
50
  import type { AdminAdmission } from './admin-routes.js'
52
51
  import { startRestartWatcher } from './restart-watcher.js'
53
52
  import { handleShareExport, SHARE_EXPORT_ROUTE } from './share-export.js'
54
53
  import { createShareStore } from './share-store.js'
55
- import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from './relay-client.js'
54
+ import { createRelayPool } from './relay-pool.js'
55
+ import type { RelayPool } from './relay-pool.js'
56
+ import { volatileUpdateTouchesServerName } from './relay-client.js'
56
57
  import type { RelayClient } from './relay-client.js'
57
58
  import { computeFingerprints } from './fingerprint.js'
58
59
  import { runSelfCheck, installIntercept } from './intercept.js'
@@ -76,6 +77,7 @@ import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } fro
76
77
  import type { RelayGateway, RelayHandler } from './relay-server.js'
77
78
  import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
78
79
  import { createWizardHandler, WIZARD_ROUTE_PREFIX } from './wizard/routes.js'
80
+ import type { WizardHandlerOptions } from './wizard/routes.js'
79
81
  import { createCaddyManager } from './wizard/caddy-process.js'
80
82
  import type { SubprocessServiceLike } from './wizard/caddy-process.js'
81
83
  import { cfTokenConfigured, deleteCfTokenFile, readCfTokenFile, writeCfTokenFile } from './wizard/token-store.js'
@@ -89,6 +91,9 @@ import { createPluginLoopback } from './plugin-loopback.js'
89
91
  import { createPluginTunnel } from './plugin-tunnel.js'
90
92
  import { createLogRing, createPluginLog } from './log-ring.js'
91
93
  import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
94
+ import { applyUnshadowedRowKeys, clearUnshadowedRowKeys, writeCliConfig } from './cli/config-writer.js'
95
+ import { acquireCliIpcServer } from './cli/ipc-server.js'
96
+ import type { CliIpcDelegate, CliRouteHandler } from './cli/ipc-server.js'
92
97
 
93
98
  // The plugin bridge's public types (PB-1), re-exported so another plugin's
94
99
  // author can `import type { … } from 'dsh-zen-remote'` without reaching into
@@ -100,7 +105,7 @@ export type { BridgeDeclaration, ZenRemoteService } from './plugin-bridge.js'
100
105
  // plugin row's public surface stays on the main entry. The same-origin gate
101
106
  // moved to src/http.ts next to the response envelope (T14) but its export
102
107
  // stays here for scripts/check-upload-endpoint.mjs.
103
- export { Config, resolveRole } from './config.js'
108
+ export { Config, resolveRole, servesRole, connectsRole } from './config.js'
104
109
  export { sameOriginPost } from './http.js'
105
110
 
106
111
  // The two sub-plugin entries the host role loads. They ship as plain .mjs at
@@ -170,11 +175,30 @@ const MAX_COLLISION_TRIES = 100
170
175
  */
171
176
  let clientRelayClient: RelayClient | undefined
172
177
 
173
- /** The live client-role relay client, if one was built. */
178
+ /** The live client-role relay client, if one was built. MS-5: the wiring no
179
+ * longer builds one — the relay POOL below replaced it — so this stays
180
+ * undefined in production compositions; the getter (and the client-routes
181
+ * option of the same shape) remain for the single-client contract's old
182
+ * consumers and tests. */
174
183
  export function getRelayClient(): RelayClient | undefined {
175
184
  return clientRelayClient
176
185
  }
177
186
 
187
+ /**
188
+ * The client role's relay pool (MS-5), created inside apply() and kept on
189
+ * the module so the debug-report wiring or a later half can reach it without
190
+ * reaching into a context. Undefined on a pure host role (and in
191
+ * compositions that never ran this module's apply), and — identity-guarded
192
+ * in the disposal effect — after the row that owned a pool was unloaded or
193
+ * reloaded (the T23a-fix discipline the relay client's slot always followed).
194
+ */
195
+ let clientRelayPool: RelayPool | undefined
196
+
197
+ /** The live client-role relay pool, if one was built. */
198
+ export function getRelayPool(): RelayPool | undefined {
199
+ return clientRelayPool
200
+ }
201
+
178
202
  /**
179
203
  * The plugin bridge's declaration registry (PB-1), created inside apply() on
180
204
  * BOTH roles and kept on the module so later tasks (the handshake's plugins
@@ -551,6 +575,8 @@ export async function handleUpload(
551
575
  * request.
552
576
  */
553
577
  export function apply(ctx: Context, config: MobileNavConfig = {}): void {
578
+ const initialHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
579
+ applyUnshadowedRowKeys(initialHome, config as Record<string, unknown>)
554
580
  // CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
555
581
  // row reload clears it (expected: the report describes THIS process
556
582
  // generation). `logWarn` / `logInfo` are the branch-shared double-writers:
@@ -735,10 +761,42 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
735
761
  return undefined
736
762
  }
737
763
  }
738
- if (effective.values.role === 'host') {
764
+ // MS-5: the two role halves are INDEPENDENT branches now, not an either/or
765
+ // — a future 'peer' deploy (servesRole AND connectsRole both true) runs
766
+ // both; host runs only the serving half; client only the dialing half.
767
+ // CLI-1: the row-scoped pieces the CLI IPC delegate forwards to. The role
768
+ // branches fill the slots they own; the restart-watcher effect at the
769
+ // bottom hands them to acquireCliIpcServer as ONE delegate, whose release
770
+ // rides the same effect (role-wiring pins the top-level effect counts, so
771
+ // the IPC lifecycle may not register its own).
772
+ const noopAdmit: (req: IncomingMessage) => AdminAdmission = () => ({ peer: {} })
773
+ const reflectNow = (): { get(name: string): unknown } | undefined =>
774
+ (ctx as Context & { reflect?: { get(name: string): unknown } }).reflect
775
+ let cliWizardHandler: CliRouteHandler | undefined
776
+ let cliAdminHandler: CliRouteHandler | undefined
777
+ let cliClientHandler: CliRouteHandler | undefined
778
+ let reportedBrowserLang: 'zh' | 'en' | undefined
779
+ const resolveActiveLang = (): 'zh' | 'en' => {
780
+ const cfg = resolveConfig(config, readFileConfig(), process.env).values.lang
781
+ if (cfg === 'en') return 'en'
782
+ if (cfg === 'zh') return 'zh'
783
+ if (reportedBrowserLang !== undefined) return reportedBrowserLang
784
+ return 'zh'
785
+ }
786
+ /** The managed ingress children THIS row generation owns (host branch
787
+ * only): the restart-watcher disposer stops them, which is the ONLY stop
788
+ * on compositions without a webServer (there the wizard effect — the
789
+ * historic owner of the stop — never fires). */
790
+ const cliManagedChildren: Array<{ stop(): void }> = []
791
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
792
+ if (servesRole(effective.values.role)) {
739
793
  // A row that switched from client to host must not leave a stale relay
740
- // client where getRelayClient() can find it (T23a-fix).
794
+ // client where getRelayClient() can find it (T23a-fix) — and no stale
795
+ // pool either where getRelayPool() can (MS-5; a peer row rebuilds its
796
+ // pool right after, this block having only cleared the previous
797
+ // generation's slot).
741
798
  clientRelayClient = undefined
799
+ clientRelayPool = undefined
742
800
  // The relay shared secret (T22a): minted fresh per apply, handed to the
743
801
  // gateway child through LAN_GATE_RELAY_SECRET and kept here for the
744
802
  // relay routes below. The push half never sees it.
@@ -765,10 +823,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
765
823
  // The shared-session table backing the relay's access control. The relay
766
824
  // route consumes it below; the activity tracker and idle sweeper (T22c)
767
825
  // keep its "quiet for idleHours → close" promise. The settings surface is
768
- // a later task.
769
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
826
+ // a later task. (`home` is hoisted to apply() level — the CLI IPC
827
+ // delegate binds its socket under the same directory.)
828
+ // MS-1: the table now lives INSIDE the three-in-one state file (alongside
829
+ // the server identity and the paired-servers list); share-store's loader
830
+ // migrates a legacy zen-remote-shares.json in and every write preserves
831
+ // the other two top-level fields.
770
832
  const store = createShareStore({
771
- file: join(home, 'zen-remote-shares.json'),
833
+ file: join(home, 'zen-remote-state.json'),
772
834
  idleHours: effective.values.idleHours,
773
835
  })
774
836
  // The live relay handler, kept so the admin shares route can read viewer
@@ -793,6 +855,16 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
793
855
  // clocks and the relay can walk the same chain for reachability.
794
856
  const parentIndex = createParentIndex()
795
857
  const tracker = createActivityTracker(store, parentIndex.parentOf)
858
+ // Track sessions explicitly unshared by a user or client during this row's
859
+ // lifetime so the `turn/start` (turn 1) fallback for `reuseLive` blank
860
+ // sessions never overrides an intentional manual unshare before Turn 1.
861
+ const manuallyUnshared = new Set<string>()
862
+ store.subscribe((ev) => {
863
+ if (ev.type === 'shared') manuallyUnshared.delete(ev.sessionId)
864
+ else if (ev.type === 'unshared' && (ev.reason === 'manual' || ev.reason === 'client')) {
865
+ manuallyUnshared.add(ev.sessionId)
866
+ }
867
+ })
796
868
  try {
797
869
  ctx.on('session/event', (session, event) => {
798
870
  // Record the parent link BEFORE tracking, so a child's very first
@@ -800,22 +872,36 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
800
872
  parentIndex.observe(session)
801
873
  const sessionId: unknown = session?.id
802
874
  if (typeof sessionId !== 'string' || sessionId === '') return
875
+ // When `session/create` reuses a blank session that was ALREADY live
876
+ // in memory (`reuseLive`), DSH emits no `agent/created`. If that blank
877
+ // session was not shared (e.g. `autoShareNewSessions` was turned on
878
+ // after it was pre-created, or its idle window expired in memory) and
879
+ // was not manually unshared while blank, share it as Turn 1 starts so
880
+ // `tracker.onEvent` right below marks it busy too.
881
+ if (isFirstTurnStart(session, event) && !manuallyUnshared.has(sessionId) && !store.isShared(sessionId)) {
882
+ try {
883
+ onSessionCreated(session.header, {
884
+ store,
885
+ autoShare: resolveConfig(config, readFileConfig(), process.env).values.autoShareNewSessions,
886
+ parentOf: parentIndex.parentOf,
887
+ })
888
+ } catch (error) {
889
+ logWarn('dsh-zen-remote cannot share new session "%s": %s', sessionId, message(error))
890
+ }
891
+ }
803
892
  tracker.onEvent(sessionId, event.type)
804
893
  })
805
894
  } catch (error) {
806
895
  logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error))
807
896
  }
808
- // Auto-share and fork-following (T33a), off `agent/created` — the only
809
- // host event that fires EXACTLY ONCE per NEW session. Its `source` is
810
- // 'startup' for fresh creation (top-level, fork and subagent all go
897
+ // Auto-share and fork-following (T33a), off `agent/created`. Its `source`
898
+ // is 'startup' for fresh creation (top-level, fork and subagent all go
811
899
  // through the same factory call) and 'resume' when a persisted session
812
- // re-enters the live store, so filtering on 'startup' is what keeps a
813
- // manually-closed session closed across reopens — `session/created`
814
- // re-fires on resume and `api-session/added` also fires when an agent is
815
- // disposed, so neither qualifies. The payload's agent id IS the session
816
- // id (dsh-agent keys its registry by it; its typert wire type is
817
- // `dsh-session/types#SessionId`), and by announce time the fresh header
818
- // already carries the subagent/fork markers the share decision reads.
900
+ // re-enters the live store. DSH's "New Session" (`createInWorkspace` →
901
+ // `session/create`) reuses persisted blank sessions (zero turns ever
902
+ // started) via `agents.resume`, which arrives with `source === 'resume'`;
903
+ // `isBlankSession` lets those reclaimed placeholders auto-share while
904
+ // keeping established conversations closed across reopens.
819
905
  // autoShareNewSessions is a volatile row setting: resolved at EVENT time,
820
906
  // never snapshotted here.
821
907
  try {
@@ -834,7 +920,12 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
834
920
  // whose share it inherits through its parent, and the forwarded
835
921
  // status frames the client needs to announce the row never left.
836
922
  parentIndex.observe(agent.session)
837
- if (source !== 'startup') return
923
+ const turnBoundaryOf = (s: unknown): TurnBoundaryLike | undefined => {
924
+ const ag = reflectNow()?.get('agents') as { turnBoundaryOf?: (sess: unknown) => TurnBoundaryLike } | undefined
925
+ return typeof ag?.turnBoundaryOf === 'function' ? ag.turnBoundaryOf(s) : undefined
926
+ }
927
+ if (source !== 'startup' && !(source === 'resume' && isBlankSession(agent.session, turnBoundaryOf))) return
928
+ if (typeof agent.id === 'string') manuallyUnshared.delete(agent.id)
838
929
  onSessionCreated(agent.session.header, {
839
930
  store,
840
931
  autoShare: resolveConfig(config, readFileConfig(), process.env).values.autoShareNewSessions,
@@ -882,6 +973,245 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
882
973
  } catch (error) {
883
974
  logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
884
975
  }
976
+ // CLI-1: the managed-ingress machinery MOVED OUT of the wizard route's
977
+ // inject so the CLI IPC delegate shares ONE set of managers / handler
978
+ // options with the browser routes (same caddyManager, same
979
+ // cloudflaredManager, same verification session, same relay store). The
980
+ // subprocess service is read the lazy way — off the reflection layer,
981
+ // which answers undefined on compositions without it — so the wizard
982
+ // options simply degrade to their 501 placeholders there.
983
+ const getConfig = (): ReturnType<typeof resolveConfig> => resolveConfig(config, readFileConfig(), process.env)
984
+ const subprocessService = reflectNow()?.get('subprocess') as SubprocessServiceLike | undefined
985
+ const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome: home })
986
+ const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome: home })
987
+ for (const child of [caddyManager, cloudflaredManager]) {
988
+ if (child !== undefined) cliManagedChildren.push(child)
989
+ }
990
+ // The verification session lives in THIS manager set's closure (GW-9):
991
+ // memory-only by design, and a row reload rebuilds it — the same
992
+ // "stale" verdict the config fingerprint produces, arriving via the
993
+ // process generation instead.
994
+ const currentMode = (): string => getConfig().values.ingressMode
995
+ const detectLanCandidates = (): { ip: string; kind: 'tailscale' | 'lan'; iface: string }[] => {
996
+ try {
997
+ const nets = networkInterfaces()
998
+ const tailscale: { ip: string; kind: 'tailscale' | 'lan'; iface: string }[] = []
999
+ const primaryLan: { ip: string; kind: 'tailscale' | 'lan'; iface: string }[] = []
1000
+ const bridgeLan: { ip: string; kind: 'tailscale' | 'lan'; iface: string }[] = []
1001
+ for (const [iface, entries] of Object.entries(nets)) {
1002
+ if (!Array.isArray(entries)) continue
1003
+ const isContainerBridge = /^(?:docker\d*|br-|veth|cni\d*|flannel|virbr\d*|cali)/i.test(iface)
1004
+ for (const entry of entries) {
1005
+ if (!entry || entry.internal || entry.family !== 'IPv4') continue
1006
+ const addr = entry.address
1007
+ if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
1008
+ tailscale.push({ ip: addr, kind: 'tailscale', iface })
1009
+ } else if (
1010
+ /^192\.168\./.test(addr)
1011
+ || /^10\./.test(addr)
1012
+ || /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)
1013
+ ) {
1014
+ if (isContainerBridge) bridgeLan.push({ ip: addr, kind: 'lan', iface })
1015
+ else primaryLan.push({ ip: addr, kind: 'lan', iface })
1016
+ }
1017
+ }
1018
+ }
1019
+ return [...tailscale, ...primaryLan, ...bridgeLan]
1020
+ } catch {
1021
+ return []
1022
+ }
1023
+ }
1024
+ // One runtime probe per request: the gateway's own status (source-IP
1025
+ // detection + the ACTUAL port for snippets/QR), plus our processes'
1026
+ // liveness for the projection.
1027
+ const buildRuntime = async () => {
1028
+ const lanAddresses = detectLanCandidates()
1029
+ const lanAddress = (lanAddresses.find((c) => c.kind === 'lan') ?? lanAddresses[0])?.ip
1030
+ try {
1031
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
1032
+ if (!response.ok) return { caddyRunning: caddyManager?.isRunning() === true, lanAddress, lanAddresses }
1033
+ const body = (await response.json()) as { host?: unknown; port?: unknown; lastForwardedSource?: unknown }
1034
+ return {
1035
+ lastForwardedSource: detectForwardedSource(body),
1036
+ // GW-fix-3/M2: the wizard's snippets and QR target the
1037
+ // gateway's ACTUAL port (it may have retried past the
1038
+ // configured one).
1039
+ gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
1040
+ gatewayActualHost: typeof body?.host === 'string' ? body.host : undefined,
1041
+ lanAddress,
1042
+ lanAddresses,
1043
+ caddyRunning: caddyManager?.isRunning() === true,
1044
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
1045
+ tunnelTokenConfigured: cloudflaredManager !== undefined,
1046
+ cfTokenConfigured: cfTokenConfigured(home),
1047
+ }
1048
+ } catch {
1049
+ // No answer is no detection — the status projection renders
1050
+ // its null shape and the UI offers nothing.
1051
+ return {
1052
+ lanAddress,
1053
+ lanAddresses,
1054
+ caddyRunning: caddyManager?.isRunning() === true,
1055
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
1056
+ tunnelTokenConfigured: tunnelTokenConfigured(home),
1057
+ cfTokenConfigured: cfTokenConfigured(home),
1058
+ }
1059
+ }
1060
+ }
1061
+ const verification = createVerificationManager({
1062
+ gatewayBase: `http://127.0.0.1:${effective.values.port}`,
1063
+ getValues: () => getConfig().values,
1064
+ // runtimeReady now covers the managed paths: Caddy must actually be
1065
+ // alive; cloudflared arrives with GW-13b; the external path has no
1066
+ // process of ours to wait for.
1067
+ runtimeReady: () => {
1068
+ const mode = currentMode()
1069
+ if (mode === 'caddy') return caddyManager?.isRunning() === true
1070
+ if (mode === 'cloudflared') return cloudflaredManager?.isRunning() === true
1071
+ return true
1072
+ },
1073
+ })
1074
+ const wizardOptionsFor = (admit: (req: IncomingMessage) => AdminAdmission): WizardHandlerOptions => ({
1075
+ admit,
1076
+ getConfig,
1077
+ tokenStore: {
1078
+ writeCfToken: (token) => { writeCfTokenFile(home, token) },
1079
+ deleteCfToken: () => {
1080
+ // Stop the consumers first (the mode-switch rule): whichever
1081
+ // process might hold the token in its environment dies before
1082
+ // the file disappears.
1083
+ caddyManager?.stop()
1084
+ cloudflaredManager?.stop()
1085
+ deleteCfTokenFile(home)
1086
+ },
1087
+ cfTokenConfigured: () => cfTokenConfigured(home),
1088
+ writeTunnelToken: (token) => { writeTunnelTokenFile(home, token) },
1089
+ },
1090
+ ...(caddyManager === undefined ? {} : {
1091
+ caddy: {
1092
+ start: async (input: { domain: string; httpsPort: number; gatewayPort: number }) => {
1093
+ // 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
1094
+ // and restarts Caddy if it was already running with an older config.
1095
+ cloudflaredManager?.stop()
1096
+ if (caddyManager.isRunning()) caddyManager.stop()
1097
+ // The token rides the child env only — read from the store
1098
+ // inside the wiring, never through a route payload.
1099
+ const token = readCfTokenFile(home)
1100
+ if (token === undefined) return { ok: false as const, error: 'missing-token' as const }
1101
+ return caddyManager.start({ ...input, cfToken: token })
1102
+ },
1103
+ stop: () => caddyManager.stop(),
1104
+ status: () => (caddyManager.isRunning() ? 'running' as const : caddyManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
1105
+ },
1106
+ }),
1107
+ ...(cloudflaredManager === undefined ? {} : {
1108
+ tunnel: {
1109
+ checkDomain: (domain: string) => runTunnelCheck(
1110
+ { readToken: () => readCfTokenFile(home) },
1111
+ domain,
1112
+ ),
1113
+ setup: (input: { domain: string; tunnelId?: string }) => setupTunnel(
1114
+ {
1115
+ dshHome: home,
1116
+ readToken: () => readCfTokenFile(home),
1117
+ gatewayPort: async () => {
1118
+ // The ingress service target is the gateway's ACTUAL
1119
+ // port — same fallback as the caddy start route: probe
1120
+ // /lan-gate/status first, fall back to the configured
1121
+ // port when it does not answer (GW-fix-4/M2).
1122
+ try {
1123
+ const response = await fetch(`http://127.0.0.1:${getConfig().values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
1124
+ if (response.ok) {
1125
+ const body = (await response.json()) as { port?: unknown }
1126
+ if (typeof body?.port === 'number') return body.port
1127
+ }
1128
+ } catch { /* fall through to the configured port */ }
1129
+ return getConfig().values.port
1130
+ },
1131
+ cloudflared: {
1132
+ start: async (startInput: { tunnelToken: string }) => {
1133
+ if (cloudflaredManager.isRunning()) cloudflaredManager.stop()
1134
+ const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken })
1135
+ return started.ok ? ({ ok: true } as const) : ({ ok: false, error: started.error ?? 'spawn-failed' } as const)
1136
+ },
1137
+ // 模式切换:starting the tunnel stops the managed Caddy.
1138
+ stopCaddy: () => caddyManager?.stop(),
1139
+ },
1140
+ },
1141
+ input,
1142
+ ),
1143
+ stop: () => cloudflaredManager.stop(),
1144
+ status: () => (cloudflaredManager.isRunning() ? 'running' as const : cloudflaredManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
1145
+ },
1146
+ }),
1147
+ verify: { start: (input) => verification.start(input), check: () => verification.check() },
1148
+ getRuntime: () => buildRuntime(),
1149
+ trustProxy: {
1150
+ resolved: () => {
1151
+ const snapshot = resolveConfig(config, readFileConfig(), process.env)
1152
+ return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies }
1153
+ },
1154
+ write: async (expected, list) => {
1155
+ const file = join(home, 'lan-gate.config.json')
1156
+ const current = readFileConfig()
1157
+ // Concurrent-write guard (GW-fix-2/L2): the route resolved
1158
+ // the list moments ago; if the file layer moved since (a
1159
+ // parallel trust click, a hand edit), refuse instead of
1160
+ // clobbering the other writer's entry. Both sides compare
1161
+ // normalized so an illegal file value reads as "empty", the
1162
+ // same thing resolveConfig saw.
1163
+ const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : ''
1164
+ if (parseProxyList(now).join(',') !== parseProxyList(expected).join(',')) return false
1165
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1166
+ await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 })
1167
+ await rename(tmp, file)
1168
+ return true
1169
+ },
1170
+ },
1171
+ })
1172
+ const adminOptionsFor = (admit: (req: IncomingMessage) => AdminAdmission): AdminHandlerOptions => ({
1173
+ admit,
1174
+ gatewayBase: `http://127.0.0.1:${effective.values.port}`,
1175
+ getConfig,
1176
+ // Shares routes (T33a): the table above is the truth they serve and
1177
+ // mutate. typertGateway and agents are deliberately NOT declared in
1178
+ // any inject — adding them would keep the WHOLE admin prefix off
1179
+ // 0.1.7 / Electron compositions that lack them — so they are read PER
1180
+ // REQUEST through the reflection layer's store lookup (runtime
1181
+ // `ctx.get`, declared `ctx.reflect.get`), which answers undefined
1182
+ // when the service is absent. Property access is not an option:
1183
+ // cordis refuses reads of services the context did not declare
1184
+ // (`cannot get property … without inject`), which would turn every
1185
+ // shares request into a 500.
1186
+ store,
1187
+ typert: () => reflectNow()?.get('typertGateway') as RelayGateway | undefined,
1188
+ getPeer: createOwnerPeerSource(() => reflectNow()?.get('connection')),
1189
+ listAgents: () => (reflectNow()?.get('agents') as { list(): readonly AgentStatusLike[] } | undefined)?.list() ?? [],
1190
+ // The "who is looking" badge (T33a2): read through whichever relay
1191
+ // handler is live right now. sessionQuery likewise — the share
1192
+ // action reads the durable session header through it to judge
1193
+ // subagent sessions, degrading to the projection identity when the
1194
+ // composition lacks the service.
1195
+ viewerCount: (id: string) => relayHandler?.viewerCount(id) ?? 0,
1196
+ sessionQuery: () => reflectNow()?.get('sessionQuery') as SessionQueryLike | undefined,
1197
+ // The plugin bridge listing (PB-5): the SAME registry this apply
1198
+ // built and the SAME live consent reader the relay's plugins table
1199
+ // uses — one source of truth per question. PB-11: the listing
1200
+ // marks the plugins an adapter stands in for (live — a plugin
1201
+ // that registered its own declaration stops being marked).
1202
+ pluginBridge: {
1203
+ registry,
1204
+ getConsent: () => getConfig().values.pluginBridgeConsent,
1205
+ adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
1206
+ },
1207
+ })
1208
+ // The CLI IPC delegate's OWN handler instances (CLI-1): same options,
1209
+ // same managers, same verification session as the browser routes below
1210
+ // — only the admit differs, because the request arrived over the 0600
1211
+ // socket (same user, same machine) instead of DSH's cookie wall.
1212
+ cliAdminHandler = createAdminHandler(adminOptionsFor(noopAdmit))
1213
+ cliWizardHandler = createWizardHandler(wizardOptionsFor(noopAdmit))
1214
+
885
1215
  // Settings-surface admin routes (T14). The browser cannot call the
886
1216
  // gateway's loopback-only admin API from DSH's origin, so the host
887
1217
  // process re-exposes it same-origin and the handler calls the gateway AS
@@ -898,42 +1228,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
898
1228
  webCtx.effect(() => webCtx.webServer.register({
899
1229
  kind: 'prefix',
900
1230
  path: ADMIN_ROUTE_PREFIX,
901
- handler: createAdminHandler({
902
- admit: (req) => webCtx.connection.admit(req),
903
- gatewayBase: `http://127.0.0.1:${effective.values.port}`,
904
- getConfig: () => resolveConfig(config, readFileConfig(), process.env),
905
- // Shares routes (T33a): the table above is the truth they serve and
906
- // mutate. typertGateway and agents are deliberately NOT in this
907
- // inject's declaration list — adding them would keep the WHOLE
908
- // admin prefix off 0.1.7 / Electron compositions that lack them —
909
- // so they are read PER REQUEST through the reflection layer's
910
- // store lookup (runtime `ctx.get`, declared `ctx.reflect.get`),
911
- // which answers undefined when the service is absent. Property
912
- // access is not an option: cordis refuses reads of services the
913
- // context did not declare (`cannot get property … without inject`),
914
- // which would turn every shares request into a 500.
915
- store,
916
- typert: () => webCtx.reflect.get('typertGateway') as RelayGateway | undefined,
917
- getPeer: createOwnerPeerSource(() => webCtx.connection),
918
- listAgents: () => (webCtx.reflect.get('agents') as { list(): readonly AgentStatusLike[] } | undefined)?.list() ?? [],
919
- // The "who is looking" badge (T33a2): read through whichever relay
920
- // handler is live right now. sessionQuery likewise — the share
921
- // action reads the durable session header through it to judge
922
- // subagent sessions, degrading to the projection identity when the
923
- // composition lacks the service.
924
- viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
925
- sessionQuery: () => webCtx.reflect.get('sessionQuery') as SessionQueryLike | undefined,
926
- // The plugin bridge listing (PB-5): the SAME registry this apply
927
- // built and the SAME live consent reader the relay's plugins table
928
- // uses — one source of truth per question. PB-11: the listing
929
- // marks the plugins an adapter stands in for (live — a plugin
930
- // that registered its own declaration stops being marked).
931
- pluginBridge: {
932
- registry,
933
- getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
934
- adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
935
- },
936
- }),
1231
+ handler: createAdminHandler(adminOptionsFor((req) => webCtx.connection.admit(req))),
937
1232
  }), 'dsh-zen-remote: admin routes')
938
1233
  })
939
1234
  // Gateway-wizard routes (GW-4, extended by GW-5): a separate LONGEST-
@@ -949,207 +1244,25 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
949
1244
  // reloads the row from (trustedProxies is a restart field), restarting
950
1245
  // the gateway with the new trust list. When the resolved value comes
951
1246
  // from env or the row the route refuses instead: a file-layer write
952
- // would be shadowed there.
1247
+ // would be shadowed there. (CLI-1: the managers and the handler options
1248
+ // moved OUT of this inject to apply() level — see wizardOptionsFor —
1249
+ // so the CLI IPC delegate drives the SAME manager set; this effect only
1250
+ // registers the route and stops the children with the row.)
953
1251
  ctx.inject(['webServer', 'connection'], (wizardCtx) => {
954
- // GW-13a: the managed Caddy lives in THIS row's closure — a row
955
- // reload rebuilds the manager (and the row's disposal, below, kills
956
- // the child), which is the reconcile-by-process-generation rule. The
957
- // subprocess service is a lazy reflection lookup: adding it to the
958
- // inject list would keep the whole wizard off compositions that lack
959
- // it, and there `reflect.get` answers undefined instead.
960
- const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
961
- const subprocessService = wizardCtx.reflect.get('subprocess') as SubprocessServiceLike | undefined
962
- const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome })
963
- const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome })
964
- // The verification session lives in THIS manager's closure (GW-9):
965
- // memory-only by design, and a row reload rebuilds it — the same
966
- // "stale" verdict the config fingerprint produces, arriving via the
967
- // process generation instead.
968
- const currentMode = (): string => resolveConfig(config, readFileConfig(), process.env).values.ingressMode
969
- const detectLanAddress = (): string | undefined => {
970
- try {
971
- const nets = networkInterfaces()
972
- let tailscaleIp: string | undefined
973
- let lanIp: string | undefined
974
- for (const entries of Object.values(nets)) {
975
- if (!Array.isArray(entries)) continue
976
- for (const entry of entries) {
977
- if (!entry || entry.internal || entry.family !== 'IPv4') continue
978
- const addr = entry.address
979
- if (
980
- /^192\.168\./.test(addr)
981
- || /^10\./.test(addr)
982
- || /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)
983
- ) {
984
- lanIp ??= addr
985
- } else if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
986
- tailscaleIp ??= addr
987
- }
988
- }
989
- }
990
- return lanIp ?? tailscaleIp
991
- } catch {
992
- return undefined
993
- }
994
- }
995
- // One runtime probe per request: the gateway's own status (source-IP
996
- // detection + the ACTUAL port for snippets/QR), plus our processes'
997
- // liveness for the projection.
998
- const buildRuntime = async () => {
999
- const lanAddress = detectLanAddress()
1000
- try {
1001
- const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
1002
- if (!response.ok) return { caddyRunning: caddyManager?.isRunning() === true, lanAddress }
1003
- const body = (await response.json()) as { port?: unknown; lastForwardedSource?: unknown }
1004
- return {
1005
- lastForwardedSource: detectForwardedSource(body),
1006
- // GW-fix-3/M2: the wizard's snippets and QR target the
1007
- // gateway's ACTUAL port (it may have retried past the
1008
- // configured one).
1009
- gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
1010
- lanAddress,
1011
- caddyRunning: caddyManager?.isRunning() === true,
1012
- cloudflaredRunning: cloudflaredManager?.isRunning() === true,
1013
- tunnelTokenConfigured: cloudflaredManager !== undefined,
1014
- cfTokenConfigured: cfTokenConfigured(dshHome),
1015
- }
1016
- } catch {
1017
- // No answer is no detection — the status projection renders
1018
- // its null shape and the UI offers nothing.
1019
- return {
1020
- lanAddress,
1021
- caddyRunning: caddyManager?.isRunning() === true,
1022
- cloudflaredRunning: cloudflaredManager?.isRunning() === true,
1023
- tunnelTokenConfigured: tunnelTokenConfigured(dshHome),
1024
- cfTokenConfigured: cfTokenConfigured(dshHome),
1025
- }
1026
- }
1027
- }
1028
- const verification = createVerificationManager({
1029
- gatewayBase: `http://127.0.0.1:${effective.values.port}`,
1030
- getValues: () => resolveConfig(config, readFileConfig(), process.env).values,
1031
- // runtimeReady now covers the managed paths: Caddy must actually be
1032
- // alive; cloudflared arrives with GW-13b; the external path has no
1033
- // process of ours to wait for.
1034
- runtimeReady: () => {
1035
- const mode = currentMode()
1036
- if (mode === 'caddy') return caddyManager?.isRunning() === true
1037
- if (mode === 'cloudflared') return cloudflaredManager?.isRunning() === true
1038
- return true
1039
- },
1040
- })
1041
1252
  wizardCtx.effect(() => {
1042
1253
  const unregister = wizardCtx.webServer.register({
1043
- kind: 'prefix',
1044
- path: WIZARD_ROUTE_PREFIX,
1045
- handler: createWizardHandler({
1046
- admit: (req) => wizardCtx.connection.admit(req),
1047
- getConfig: () => resolveConfig(config, readFileConfig(), process.env),
1048
- tokenStore: {
1049
- writeCfToken: (token) => { writeCfTokenFile(dshHome, token) },
1050
- deleteCfToken: () => {
1051
- // Stop the consumers first (the mode-switch rule): whichever
1052
- // process might hold the token in its environment dies before
1053
- // the file disappears.
1054
- caddyManager?.stop()
1055
- cloudflaredManager?.stop()
1056
- deleteCfTokenFile(dshHome)
1057
- },
1058
- cfTokenConfigured: () => cfTokenConfigured(dshHome),
1059
- writeTunnelToken: (token) => { writeTunnelTokenFile(dshHome, token) },
1060
- },
1061
- ...(caddyManager === undefined ? {} : {
1062
- caddy: {
1063
- start: async (input: { domain: string; httpsPort: number; gatewayPort: number }) => {
1064
- // 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
1065
- // and restarts Caddy if it was already running with an older config.
1066
- cloudflaredManager?.stop()
1067
- if (caddyManager.isRunning()) caddyManager.stop()
1068
- // The token rides the child env only — read from the store
1069
- // inside the wiring, never through a route payload.
1070
- const token = readCfTokenFile(dshHome)
1071
- if (token === undefined) return { ok: false as const, error: 'missing-token' as const }
1072
- return caddyManager.start({ ...input, cfToken: token })
1073
- },
1074
- stop: () => caddyManager.stop(),
1075
- status: () => (caddyManager.isRunning() ? 'running' as const : caddyManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
1076
- },
1077
- }),
1078
- ...(cloudflaredManager === undefined ? {} : {
1079
- tunnel: {
1080
- checkDomain: (domain: string) => runTunnelCheck(
1081
- { readToken: () => readCfTokenFile(dshHome) },
1082
- domain,
1083
- ),
1084
- setup: (input: { domain: string; tunnelId?: string }) => setupTunnel(
1085
- {
1086
- dshHome,
1087
- readToken: () => readCfTokenFile(dshHome),
1088
- gatewayPort: async () => {
1089
- // The ingress service target is the gateway's ACTUAL
1090
- // port — same fallback as the caddy start route: probe
1091
- // /lan-gate/status first, fall back to the configured
1092
- // port when it does not answer (GW-fix-4/M2).
1093
- try {
1094
- const response = await fetch(`http://127.0.0.1:${resolveConfig(config, readFileConfig(), process.env).values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
1095
- if (response.ok) {
1096
- const body = (await response.json()) as { port?: unknown }
1097
- if (typeof body?.port === 'number') return body.port
1098
- }
1099
- } catch { /* fall through to the configured port */ }
1100
- return resolveConfig(config, readFileConfig(), process.env).values.port
1101
- },
1102
- cloudflared: {
1103
- start: async (startInput: { tunnelToken: string }) => {
1104
- if (cloudflaredManager.isRunning()) cloudflaredManager.stop()
1105
- const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken })
1106
- return started.ok ? ({ ok: true } as const) : ({ ok: false, error: started.error ?? 'spawn-failed' } as const)
1107
- },
1108
- // 模式切换:starting the tunnel stops the managed Caddy.
1109
- stopCaddy: () => caddyManager?.stop(),
1110
- },
1111
- },
1112
- input,
1113
- ),
1114
- stop: () => cloudflaredManager.stop(),
1115
- status: () => (cloudflaredManager.isRunning() ? 'running' as const : cloudflaredManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
1116
- },
1117
- }),
1118
- verify: { start: (input) => verification.start(input), check: () => verification.check() },
1119
- getRuntime: () => buildRuntime(),
1120
- trustProxy: {
1121
- resolved: () => {
1122
- const snapshot = resolveConfig(config, readFileConfig(), process.env)
1123
- return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies }
1124
- },
1125
- write: async (expected, list) => {
1126
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1127
- const file = join(home, 'lan-gate.config.json')
1128
- const current = readFileConfig()
1129
- // Concurrent-write guard (GW-fix-2/L2): the route resolved
1130
- // the list moments ago; if the file layer moved since (a
1131
- // parallel trust click, a hand edit), refuse instead of
1132
- // clobbering the other writer's entry. Both sides compare
1133
- // normalized so an illegal file value reads as "empty", the
1134
- // same thing resolveConfig saw.
1135
- const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : ''
1136
- if (parseProxyList(now).join(',') !== parseProxyList(expected).join(',')) return false
1137
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1138
- await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 })
1139
- await rename(tmp, file)
1140
- return true
1141
- },
1142
- },
1143
- }),
1144
- })
1145
- // H1: the row's disposal must not orphan the managed children — the
1146
- // Caddy/cloudflared processes die WITH the row that spawned them,
1147
- // before the route registration unregisters.
1148
- return () => {
1149
- caddyManager?.stop()
1150
- cloudflaredManager?.stop()
1151
- unregister()
1152
- }
1254
+ kind: 'prefix',
1255
+ path: WIZARD_ROUTE_PREFIX,
1256
+ handler: createWizardHandler(wizardOptionsFor((req) => wizardCtx.connection.admit(req))),
1257
+ })
1258
+ // H1: the row's disposal must not orphan the managed children — the
1259
+ // Caddy/cloudflared processes die WITH the row that spawned them,
1260
+ // before the route registration unregisters.
1261
+ return () => {
1262
+ caddyManager?.stop()
1263
+ cloudflaredManager?.stop()
1264
+ unregister()
1265
+ }
1153
1266
  }, 'dsh-zen-remote: wizard routes')
1154
1267
  })
1155
1268
  // The idle sweeper (T22c): every minute, hand the table the CURRENT
@@ -1355,101 +1468,126 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1355
1468
  getGatewaySummary: gatewaySummary,
1356
1469
  getProfileName: profileName,
1357
1470
  getPluginVersion: () => pluginVersion,
1471
+ lang: resolveActiveLang,
1358
1472
  }),
1359
1473
  }), 'dsh-zen-remote: client routes (host)')
1360
1474
  })
1361
- } else {
1362
- // Sub-client half (T16, T23a): pairing claim + connection status, talking to
1363
- // the SERVER's gateway instead of running one. Same admission wall as
1364
- // the admin routes — and, like them, a composition without a connection
1365
- // service (Electron carries none) simply never mounts the routes. The
1366
- // handler keeps the RAW row object: serverUrl / deviceToken are volatile
1367
- // ({ get() } wrapped), so every request reads them live through
1368
- // unwrapVolatile — an apply-time snapshot would go stale the moment the
1369
- // settings page pairs or unpairs.
1370
- //
1371
- // The relay client (T23a) reads the SAME volatile fields live at every
1372
- // request; the address passes the claim route's normalizer, and an
1373
- // address that no longer normalizes counts as missing — a hand-edited
1374
- // row must not receive the pairing token.
1475
+ // CLI-1: the IPC delegate's client face on a serving role — remote-status
1476
+ // and the debug report answer (both are every role's own); the IPC
1477
+ // prefix gate covers the pairing routes (409 wrong-role before any
1478
+ // request reaches here), and the handler keeps the host mount's
1479
+ // least-exposure wall besides.
1480
+ cliClientHandler = createClientHandler({
1481
+ admit: noopAdmit,
1482
+ getRowConfig: () => config,
1483
+ remoteStatusOnly: true,
1484
+ getServiceProbe: uploadServiceProbe(ctx),
1485
+ getLogSnapshot: () => ring.snapshot(),
1486
+ getGatewaySummary: gatewaySummary,
1487
+ getProfileName: profileName,
1488
+ getPluginVersion: () => pluginVersion,
1489
+ lang: resolveActiveLang,
1490
+ })
1491
+ }
1492
+ if (connectsRole(effective.values.role)) {
1493
+ // Sub-client half (T16, T23a; MS-5 pool rewiring): the paired-servers
1494
+ // connection pool instead of the single row-driven client. The row's
1495
+ // serverUrl/deviceToken survive only as the LEGACY source — read once
1496
+ // here so an old single-server row migrates into
1497
+ // `zen-remote-state.json`'s paired list (state-store's migration keeps
1498
+ // the list's other entries); from then on the pool's paired list is the
1499
+ // single truth, edited by the client routes' claim/unpair.
1375
1500
  const row = config as Record<string, unknown>
1376
- const relayClient = createRelayClient({
1377
- getServerUrl: () => normalizedRowServerUrl(row),
1378
- getToken: () => {
1379
- const value = unwrapVolatile(row.deviceToken)
1380
- return typeof value === 'string' && value !== '' ? value : undefined
1381
- },
1501
+ // (`home` is hoisted to apply() level — the pool's state file and the
1502
+ // CLI IPC socket share the same directory.)
1503
+ // MS-3/MS-5: the intercept handle is created inside the typertGateway
1504
+ // inject below, but the POOL's state callbacks (created first) forward
1505
+ // to it — the closure is fine (the callbacks fire after apply() returned
1506
+ // or not at all); the declaration just moved up so the wiring reads
1507
+ // top-down.
1508
+ let interceptHandle: InterceptHandle | undefined
1509
+ const legacyCredentials = (): { serverUrl?: string; deviceToken?: string } | undefined => {
1510
+ const url = unwrapVolatile(row.serverUrl)
1511
+ const token = unwrapVolatile(row.deviceToken)
1512
+ const serverUrl = typeof url === 'string' && url.trim() !== '' ? url : undefined
1513
+ const deviceToken = typeof token === 'string' && token !== '' ? token : undefined
1514
+ return serverUrl === undefined && deviceToken === undefined ? undefined : { serverUrl, deviceToken }
1515
+ }
1516
+ // CU-1 lifecycle: the pool's per-server link flips enter the ring —
1517
+ // `online` with the entry and its handshake server id, `offline` with
1518
+ // the failure code (lastError is a short code by contract, never free
1519
+ // text). The client's subscribe re-announces the CURRENT state when only
1520
+ // the handshake identity moved (the T59 heartbeat), so the per-entry
1521
+ // dedupe keeps the heartbeats out of the ring: one line per actual state
1522
+ // change. Every event ALSO reaches the intercept's pool hub — the merged
1523
+ // streams re-scan `pool.entries()` on it.
1524
+ const poolRingStates = new Map<string, string>()
1525
+ const relayPool = createRelayPool({
1526
+ stateFile: join(home, 'zen-remote-state.json'),
1527
+ legacySource: legacyCredentials,
1382
1528
  // Interface compatibility (T42): after each completed handshake this
1383
1529
  // side computes its OWN fingerprints against THIS process's typert
1384
1530
  // registry (read per call — at creation time the row context may still
1385
- // be assembling), and the relay client stores the group-by-group
1386
- // verdict the status route reports.
1531
+ // be assembling), and the client stores the group-by-group verdict the
1532
+ // status route reports.
1387
1533
  computeOwnFingerprints: () => computeFingerprints(ctx),
1534
+ onStateChange: ({ entryId, serverId, state, lastError }) => {
1535
+ if (poolRingStates.get(entryId) !== state) {
1536
+ poolRingStates.set(entryId, state)
1537
+ if (state === 'online') ring.push('info', 'relay pool online (entry=%s server=%s)', entryId, serverId ?? '?')
1538
+ else if (state === 'offline') ring.push('warn', 'relay pool offline (entry=%s code=%s)', entryId, lastError ?? '?')
1539
+ }
1540
+ interceptHandle?.notePoolEvent()
1541
+ },
1542
+ onServerRemoved: ({ entryId, reason }) => {
1543
+ poolRingStates.delete(entryId)
1544
+ ring.push('info', 'relay pool server removed (entry=%s reason=%s)', entryId, reason)
1545
+ interceptHandle?.notePoolEvent()
1546
+ },
1388
1547
  })
1389
- clientRelayClient = relayClient
1390
- // The plugin bridge's live verdict (PB-2), the CLIENT role's job — a host
1391
- // has no remote sessions of its own to bridge anything into. Computed PER
1548
+ clientRelayPool = relayPool
1549
+ // A stopped or reloaded row must not leave a stale pool where the module
1550
+ // getter can hand it out (T23a-fix's discipline), and its clients'
1551
+ // reconnect machinery must not outlive the row either: stop() ends every
1552
+ // client (which cancels armed retry timers and blocks scheduling). The
1553
+ // identity guard keeps a slow dispose from clearing a NEWER row's pool.
1554
+ ctx.effect(() => () => {
1555
+ relayPool.stop()
1556
+ if (clientRelayPool === relayPool) clientRelayPool = undefined
1557
+ }, 'dsh-zen-remote: relay pool disposal')
1558
+ // The plugin bridge's live verdict (PB-2), per POOL ENTRY now (MS-5) — a
1559
+ // host has no remote sessions of its own to bridge anything into, and
1560
+ // server A's verdicts must never answer for server B's. Computed PER
1392
1561
  // CALL (never a stored conclusion): the local registry is this apply's
1393
- // own closure, the server's table is the LAST handshake's `plugins`
1562
+ // own closure, the entry's table is ITS LAST handshake's `plugins`
1394
1563
  // (undefined until one succeeded — an old server reads server-too-old),
1395
- // and `online` is the client's CURRENT state — so a registry change, a
1564
+ // and `online` is that client's CURRENT state — so a registry change, a
1396
1565
  // fresh handshake or a link flip shows up at the very next read.
1397
- const pluginBridgeStatus = (): BridgeStatus[] =>
1398
- compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
1399
- // CU-1 lifecycle: the relay link's flips enter the ring — `online` with
1400
- // the server id it handshook, `offline` with the mapped failure code
1401
- // (relayClient.lastError is a short code by contract, never free text).
1402
- // subscribe re-announces the CURRENT state when only the handshake
1403
- // identity moved (the T59 heartbeat), so the dedupe below keeps the
1404
- // heartbeats out of the ring: one line per actual state change.
1405
- let lastRelayRingState: string | undefined
1406
- const offRelayRing = relayClient.subscribe((state) => {
1407
- if (state === lastRelayRingState) return
1408
- lastRelayRingState = state
1409
- if (state === 'online') {
1410
- ring.push('info', 'relay link online (server=%s)', relayClient.handshakeInfo?.serverId ?? '?')
1411
- } else if (state === 'offline') {
1412
- ring.push('warn', 'relay link offline (code=%s)', relayClient.lastError ?? '?')
1413
- }
1414
- })
1415
- // A stopped or reloaded row must not leave a stale instance where the
1416
- // module getter can hand it out (T23a-fix), and its reconnect machinery
1417
- // (T43) must not outlive the row either: stop() cancels the armed retry
1418
- // timer and blocks further scheduling. The identity guard keeps a slow
1419
- // dispose from clearing a NEWER row's instance that a reload already
1420
- // installed.
1421
- ctx.effect(() => () => {
1422
- offRelayRing()
1423
- relayClient.stop()
1424
- if (clientRelayClient === relayClient) clientRelayClient = undefined
1425
- }, 'dsh-zen-remote: relay client disposal')
1426
- // Credentials watch (T43): serverUrl / deviceToken are volatile row
1427
- // fields, and a pairing write (or unpair) commits them WITHOUT a row
1428
- // restart. The loader announces the commit on the row context; the
1429
- // client drops its pending reconnect wait and dials the new credentials
1430
- // immediately. Failure to subscribe degrades to a warning — the ladder
1431
- // and the per-request getters keep working without it.
1432
- // T59 rides the same announcement, and ONLY that announcement (T59-fix):
1433
- // the row's `serverName` is this device's name, and a USER-committed
1434
- // change is forwarded to the gateway's device table — but the event
1435
- // fires for EVERY volatile commit (an unrelated knob, or the settings
1436
- // page's own server-driven follow write arriving at startup), so the
1437
- // listener filters on the changed paths. A push fires only when
1438
- // `serverName` itself moved and differs from the server's record
1439
- // (queueDeviceName's contract) — which is what keeps the follow write
1440
- // from bouncing back and forth. NOT pushed at startup either: the row's
1441
- // loaded name is stale by definition (the server's table may have been
1442
- // edited while we were gone) and an unconditional boot-time push would
1443
- // overwrite an administrator's rename. A rename committed offline and
1444
- // lost to a process exit therefore stays local until the user edits it
1445
- // again — accepted; the server's record wins by default.
1566
+ const pluginBridgeStatusFor = (serverId: string | undefined, client: RelayClient): BridgeStatus[] =>
1567
+ compareBridges(registry.list(), client.handshakeInfo?.plugins, client.state === 'online')
1568
+ // Device-name watch (T59 through MS-5's pool): the row's `serverName` is
1569
+ // this device's name, and a USER-committed change is broadcast to EVERY
1570
+ // paired server's gateway device table — one-way (MS decision: the
1571
+ // server's record never rewrites the row). The loader announces the
1572
+ // commit on the row context, but the event fires for EVERY volatile
1573
+ // commit (an unrelated knob, or a settings page's own server-driven
1574
+ // follow write), so the listener filters on the changed paths. Each
1575
+ // client pushes only when the name differs from its server's record
1576
+ // (queueDeviceName's contract) — which is what keeps a follow write from
1577
+ // bouncing back and forth. NOT pushed at startup either: the row's
1578
+ // loaded name is stale by definition and an unconditional boot-time push
1579
+ // would overwrite an administrator's rename. A rename committed offline
1580
+ // and lost to a process exit stays local until the user edits it again —
1581
+ // accepted; the server's record wins by default. (The old T43
1582
+ // credentialsChanged half of this listener is gone with the row client:
1583
+ // the pool's credentials live in the state file, edited by claim/unpair,
1584
+ // and its clients react to THOSE on their own.)
1446
1585
  ctx.effect(() => {
1447
1586
  try {
1448
1587
  const off = ctx.on('loader/volatile-update', (paths) => {
1449
- relayClient.credentialsChanged()
1450
1588
  if (!volatileUpdateTouchesServerName(paths)) return
1451
1589
  const name = unwrapVolatile(row.serverName)
1452
- if (typeof name === 'string' && name.trim() !== '') relayClient.queueDeviceName(name)
1590
+ if (typeof name === 'string' && name.trim() !== '') relayPool.queueDeviceNameAll(name)
1453
1591
  })
1454
1592
  return () => {
1455
1593
  off()
@@ -1459,23 +1597,15 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1459
1597
  return () => {}
1460
1598
  }
1461
1599
  }, 'dsh-zen-remote: relay credentials watcher')
1462
- // One connection attempt at startup (T23a). Failures only log — the
1463
- // status route surfaces the resulting state, and the client's own
1464
- // backoff ladder (T43) takes over from the first failure. An unpaired
1465
- // row is the normal not-configured shape and stays silent.
1466
- void relayClient.connect().catch((error) => {
1467
- if (error instanceof RelayError && error.code === 'unpaired') return
1468
- const code = error instanceof RelayError ? error.code : 'error'
1469
- logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error))
1470
- })
1471
1600
  // The typertGateway interception (T23b-1): remote-session calls ride the
1472
1601
  // relay, everything else reaches the local gateway untouched. The live
1473
1602
  // diagnostics flow to the client status route through whichever of the
1474
1603
  // two slots is filled — an installed handle, or the shape verdict that
1475
1604
  // REFUSED an install (both belong on the settings surface). A
1476
1605
  // composition without typertGateway (0.1.7 client) leaves both empty and
1477
- // the status answer simply carries no intercept field.
1478
- let interceptHandle: InterceptHandle | undefined
1606
+ // the status answer simply carries no intercept field. (The handle's
1607
+ // declaration itself sits above, beside the pool — its callbacks forward
1608
+ // pool events here.)
1479
1609
  let interceptRefused: GatewayShapeCheck | undefined
1480
1610
  // T62: the browser's current-session report, held here and read live by
1481
1611
  // the intercept (which cannot reach the browser's localStorage — it
@@ -1483,8 +1613,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1483
1613
  // first report arrives the value is `unavailable` and the intercept
1484
1614
  // keeps its conservative tombstone behavior.
1485
1615
  let reportedCurrentSession: CurrentSessionRead = { kind: 'unavailable' }
1486
- const onCurrentSession = (read: CurrentSessionRead): void => {
1616
+ const onCurrentSession = (read: CurrentSessionRead, reportedLang?: 'zh' | 'en'): void => {
1487
1617
  reportedCurrentSession = read
1618
+ if (reportedLang !== undefined) reportedBrowserLang = reportedLang
1488
1619
  // Re-judge NOW — the merger dedupes no-change calls, so a report that
1489
1620
  // carries nothing new costs one compare.
1490
1621
  interceptHandle?.currentSessionChanged()
@@ -1502,18 +1633,21 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1502
1633
  // between two stream refreshes waits for the next refresh by design
1503
1634
  // (the documented ceiling in merge-streams.ts). Read-only consumption:
1504
1635
  // nothing below ever mutates what a merger handed over.
1505
- const knownSessionShares = new Map<SessionInventorySlot, ReadonlySet<string>>()
1636
+ // MS-5: each share is tagged with the serverId its stream asked the slot
1637
+ // for, so the plugin-route's per-server face can answer the union of ONE
1638
+ // server's ids — a rewrite against server A must never see B's ids.
1639
+ const knownSessionShares = new Map<SessionInventorySlot, { serverId: string | undefined; ids: ReadonlySet<string> }>()
1506
1640
  const knownRealIds = new Set<string>()
1507
1641
  const rebuildKnownRealIds = (): void => {
1508
1642
  knownRealIds.clear()
1509
1643
  for (const share of knownSessionShares.values()) {
1510
- for (const id of share) knownRealIds.add(id)
1644
+ for (const id of share.ids) knownRealIds.add(id)
1511
1645
  }
1512
1646
  }
1513
- const createSessionInventorySlot = (): SessionInventorySlot => {
1647
+ const createSessionInventorySlot = (serverId?: string): SessionInventorySlot => {
1514
1648
  const slot: SessionInventorySlot = {
1515
1649
  replace(ids) {
1516
- knownSessionShares.set(slot, ids)
1650
+ knownSessionShares.set(slot, { serverId, ids })
1517
1651
  rebuildKnownRealIds()
1518
1652
  },
1519
1653
  dispose() {
@@ -1522,6 +1656,17 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1522
1656
  }
1523
1657
  return slot
1524
1658
  }
1659
+ /** MS-5: one server's share of the union, for the plugin-route rewrite's
1660
+ * per-server face. An entry that never reported (its stream not open
1661
+ * yet) contributes nothing — the request-local scan still runs. */
1662
+ const knownRealIdsForServer = (serverId: string): ReadonlySet<string> => {
1663
+ const out = new Set<string>()
1664
+ for (const share of knownSessionShares.values()) {
1665
+ if (share.serverId !== serverId) continue
1666
+ for (const id of share.ids) out.add(id)
1667
+ }
1668
+ return out
1669
+ }
1525
1670
  const interceptDiagnostics = (): InterceptDiagnostics | undefined => {
1526
1671
  if (interceptHandle !== undefined) return interceptHandle.diagnostics()
1527
1672
  if (interceptRefused !== undefined) {
@@ -1549,9 +1694,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1549
1694
  }
1550
1695
  const handle = installIntercept({
1551
1696
  raw,
1552
- relay: relayClient,
1553
- getServerId: () => relayClient.handshakeInfo?.serverId,
1697
+ // MS-5 POOL mode: routing and merging read the relay pool — every
1698
+ // call/stream/event rides the virtual id's serverId half to ITS
1699
+ // server, the three global reads merge ALL paired servers in pairing
1700
+ // order. (The single-server `relay` + `getServerId` form is gone
1701
+ // from the wiring with the row client.)
1702
+ pool: relayPool,
1554
1703
  getCurrentSession: () => reportedCurrentSession,
1704
+ lang: resolveActiveLang,
1555
1705
  // PB-25b: each merged stream reports its real-session inventory
1556
1706
  // share into the union above.
1557
1707
  createSessionInventorySlot,
@@ -1643,8 +1793,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1643
1793
  }
1644
1794
  // Shape first, like the typert wrap: a failed check leaves every route
1645
1795
  // exactly as it was — local uploads untouched, remote uploads keep
1646
- // failing with the honest local error.
1647
- const shape = checkFetchRouteShape(raw)
1796
+ // failing with the honest local error. (The media note rides along:
1797
+ // this wiring always hands a resolveRemoteFileScope over, P55-2.)
1798
+ const shape = checkFetchRouteShape(raw, true)
1648
1799
  if (!shape.ok) {
1649
1800
  fetchRouteRefused = shape
1650
1801
  logWarn('dsh-zen-remote fetch-route intercept not installed: %s', shape.reasons.join('; '))
@@ -1652,8 +1803,14 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1652
1803
  }
1653
1804
  const handle = installFetchRouteIntercept({
1654
1805
  connection: raw,
1655
- relay: relayClient,
1656
- getServerId: () => relayClient.handshakeInfo?.serverId,
1806
+ // MS-5 pool mode: each upload's serverId half picks its own client.
1807
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1808
+ // P55-2: the /api/file media route forwards to the session the
1809
+ // intercept currently holds — read LIVE per request, so an uninstalled
1810
+ // (or not-yet-installed) typert wrap answers undefined and the media
1811
+ // request passes through to the local route.
1812
+ resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
1813
+ lang: resolveActiveLang,
1657
1814
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1658
1815
  })
1659
1816
  fetchRouteHandle = handle
@@ -1719,7 +1876,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1719
1876
  }
1720
1877
  // Shape against the proxy first (fetchRoutes reads through it); the
1721
1878
  // raw instance comes off the same symbols slot the inject path uses.
1722
- const shape = checkFetchRouteShape(connection)
1879
+ // (The media note rides along — same install options as the inject
1880
+ // path, P55-2.)
1881
+ const shape = checkFetchRouteShape(connection, true)
1723
1882
  if (!shape.ok) {
1724
1883
  // Structural mismatch is a verdict, not a wait: record the attempt
1725
1884
  // and stop. A later inject trigger may still install (it checks the
@@ -1740,8 +1899,12 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1740
1899
  }
1741
1900
  const handle = installFetchRouteIntercept({
1742
1901
  connection: raw,
1743
- relay: relayClient,
1744
- getServerId: () => relayClient.handshakeInfo?.serverId,
1902
+ // MS-5 pool mode: each upload's serverId half picks its own client.
1903
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1904
+ // P55-2: same live read as the inject path — the media route follows
1905
+ // whichever typert intercept is currently installed.
1906
+ resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
1907
+ lang: resolveActiveLang,
1745
1908
  log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args) },
1746
1909
  })
1747
1910
  fetchRouteHandle = handle
@@ -1810,9 +1973,16 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1810
1973
  const handle = installPluginRouteIntercept({
1811
1974
  webServer: raw,
1812
1975
  registry,
1813
- relay: relayClient,
1814
- getServerId: () => relayClient.handshakeInfo?.serverId,
1815
- getStatus: pluginBridgeStatus,
1976
+ // MS-5 pool mode: validation, forwarding and response rewriting are
1977
+ // judged against the located id's OWN server — its relay client, its
1978
+ // bridge verdicts (its handshake's plugin table against the local
1979
+ // registry) and its own known-real-id inventory.
1980
+ getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
1981
+ getStatusForServer: (serverId) => {
1982
+ const entry = relayPool.getByServerId(serverId)
1983
+ return entry !== undefined ? pluginBridgeStatusFor(entry.serverId, entry.client) : []
1984
+ },
1985
+ getKnownRealIdsForServer: knownRealIdsForServer,
1816
1986
  adapters,
1817
1987
  // PB-12: the browser's currently open session — a VIRTUAL id means
1818
1988
  // a remote panel is the request's origin, the signal the adapter
@@ -1836,24 +2006,36 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1836
2006
  if (pluginRouteHandle === handle) pluginRouteHandle = undefined
1837
2007
  }, 'dsh-zen-remote: plugin route intercept')
1838
2008
  })
2009
+ // CLI-1: one options factory, two handler instances — the browser mount
2010
+ // below keeps its admit wall, and the CLI IPC delegate's instance (same
2011
+ // pool, same diagnostics getters, same everything but admit) lets the
2012
+ // local CLI drive THIS pool directly over the 0600 socket.
2013
+ const clientOptionsFor = (admit: (req: IncomingMessage) => AdminAdmission, probeCtx: Context) => ({
2014
+ admit,
2015
+ getRowConfig: () => config,
2016
+ // MS-5 POOL mode: the paired list is the truth — claim pairs into
2017
+ // the pool (the token never reaches the browser), unpair/reconnect
2018
+ // address pool entries, and status / remote-status report every
2019
+ // server. The row still rides along for the non-connection knobs
2020
+ // (the debug report's config summary) — never for credentials.
2021
+ pool: relayPool,
2022
+ getPluginBridgeStatusForServer: pluginBridgeStatusFor,
2023
+ getIntercept: interceptDiagnostics,
2024
+ getFetchRouteIntercept: fetchRouteDiagnostics,
2025
+ getServiceProbe: uploadServiceProbe(probeCtx),
2026
+ getPluginRouteIntercept: pluginRouteDiagnostics,
2027
+ onCurrentSession,
2028
+ getLogSnapshot: () => ring.snapshot(),
2029
+ getProfileName: profileName,
2030
+ getPluginVersion: () => pluginVersion,
2031
+ lang: resolveActiveLang,
2032
+ })
2033
+ cliClientHandler = createClientHandler(clientOptionsFor(noopAdmit, ctx))
1839
2034
  ctx.inject(['webServer', 'connection'], (clientCtx) => {
1840
2035
  clientCtx.effect(() => clientCtx.webServer.register({
1841
2036
  kind: 'prefix',
1842
2037
  path: CLIENT_ROUTE_PREFIX,
1843
- handler: createClientHandler({
1844
- admit: (req) => clientCtx.connection.admit(req),
1845
- getRowConfig: () => config,
1846
- getRelayClient: () => relayClient,
1847
- getIntercept: interceptDiagnostics,
1848
- getFetchRouteIntercept: fetchRouteDiagnostics,
1849
- getServiceProbe: uploadServiceProbe(clientCtx),
1850
- getPluginRouteIntercept: pluginRouteDiagnostics,
1851
- getPluginBridgeStatus: pluginBridgeStatus,
1852
- onCurrentSession,
1853
- getLogSnapshot: () => ring.snapshot(),
1854
- getProfileName: profileName,
1855
- getPluginVersion: () => pluginVersion,
1856
- }),
2038
+ handler: createClientHandler(clientOptionsFor((req) => clientCtx.connection.admit(req), clientCtx)),
1857
2039
  }), 'dsh-zen-remote: client routes')
1858
2040
  })
1859
2041
  }
@@ -1904,11 +2086,57 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1904
2086
  // Registration failure degrades to a warning, like the session feeds
1905
2087
  // above: the poll trigger keeps the watcher fully functional without it.
1906
2088
  try {
1907
- ctx.on('loader/volatile-update', () => { watcher.checkNow() })
2089
+ ctx.on('loader/volatile-update', (paths) => {
2090
+ if (Array.isArray(paths)) {
2091
+ const topKeys = paths
2092
+ .map((p) => (Array.isArray(p) && typeof p[0] === 'string' ? p[0] : undefined))
2093
+ .filter((k): k is string => k !== undefined)
2094
+ if (topKeys.length > 0) clearUnshadowedRowKeys(home, topKeys)
2095
+ }
2096
+ watcher.checkNow()
2097
+ })
1908
2098
  } catch (error) {
1909
2099
  logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error))
1910
2100
  }
1911
- return watcher.stop
2101
+ // CLI-1: hot-swap THIS row generation's delegate into the module-level
2102
+ // IPC server (acquire is synchronous — apply() runs inside cordis; the
2103
+ // first bind completes in the background and the CLI client retries
2104
+ // until then). writeCliConfig's onAfterWrite re-checks the fingerprint
2105
+ // IMMEDIATELY, so a CLI config write to a restart-required field
2106
+ // reloads the row without waiting out the 2s poll. Every role getter
2107
+ // re-resolves per call: ping answers the CURRENT effective role even in
2108
+ // the instant before a pending reload lands. The IPC lifecycle rides
2109
+ // this existing effect on purpose — role-wiring pins the top-level
2110
+ // effect counts, so it may not register its own.
2111
+ const delegate: CliIpcDelegate = {
2112
+ role: () => resolveConfig(config, readFileConfig(), process.env).values.role,
2113
+ serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
2114
+ profileName,
2115
+ pluginVersion: () => pluginVersion,
2116
+ writeConfig: (input) => writeCliConfig({
2117
+ dshHome: home,
2118
+ row: config as Record<string, unknown>,
2119
+ env: process.env,
2120
+ set: input.set,
2121
+ unset: input.unset,
2122
+ onAfterWrite: () => { watcher.checkNow() },
2123
+ }),
2124
+ wizardHandler: cliWizardHandler,
2125
+ adminHandler: cliAdminHandler,
2126
+ clientHandler: cliClientHandler,
2127
+ }
2128
+ const cliIpc = acquireCliIpcServer(home, delegate)
2129
+ return () => {
2130
+ // Release THIS row's hold (a concurrent reload's new acquire keeps
2131
+ // the socket alive — that is the coordinator's grace window). The
2132
+ // managed ingress children die with the row here too: on compositions
2133
+ // WITHOUT a webServer the wizard effect above never fires, so this is
2134
+ // the only stop they would ever get (with webServer both stop — stop
2135
+ // is idempotent, safe before any start).
2136
+ cliIpc.release()
2137
+ watcher.stop()
2138
+ for (const child of cliManagedChildren) child.stop()
2139
+ }
1912
2140
  }, 'dsh-zen-remote: restart watcher')
1913
2141
  // The upload route carries the T17 admission wall too: webServer routes
1914
2142
  // skip DSH's /api authentication, so without the connection service's