dsh-zen-remote 2.2.9 → 2.3.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 (149) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/docs/interface.md +1 -1
  4. package/docs/remote-access.md +20 -0
  5. package/lan-gate.mjs +11 -1
  6. package/lib/adapters/dsh-better-sidebar/index.js +6 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
  9. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  10. package/lib/admin-routes.js +33 -2
  11. package/lib/admin-routes.js.map +1 -1
  12. package/lib/client.js +3289 -6
  13. package/lib/config.js +70 -0
  14. package/lib/config.js.map +1 -1
  15. package/lib/index.js +232 -2
  16. package/lib/index.js.map +1 -1
  17. package/lib/intercept.js +43 -3
  18. package/lib/intercept.js.map +1 -1
  19. package/lib/lan-gate-server.cjs +29 -3
  20. package/lib/merge-streams.js +42 -12
  21. package/lib/merge-streams.js.map +1 -1
  22. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  23. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  24. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  25. package/lib/types/admin-routes.d.ts +13 -0
  26. package/lib/types/admin-routes.d.ts.map +1 -1
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/locales.d.ts +204 -1
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  31. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  32. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  33. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  34. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  35. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  37. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  39. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  41. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  43. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  45. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  46. package/lib/types/client-data/qr.d.ts +20 -0
  47. package/lib/types/client-data/qr.d.ts.map +1 -0
  48. package/lib/types/client-data/settings-form.d.ts +7 -2
  49. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  50. package/lib/types/client-data/wizard.d.ts +247 -0
  51. package/lib/types/client-data/wizard.d.ts.map +1 -0
  52. package/lib/types/config.d.ts +27 -0
  53. package/lib/types/config.d.ts.map +1 -1
  54. package/lib/types/index.d.ts.map +1 -1
  55. package/lib/types/intercept.d.ts.map +1 -1
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/wizard/binary-io.d.ts +111 -0
  58. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  59. package/lib/types/wizard/binary.d.ts +118 -0
  60. package/lib/types/wizard/binary.d.ts.map +1 -0
  61. package/lib/types/wizard/caddy-process.d.ts +101 -0
  62. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  63. package/lib/types/wizard/caddy.d.ts +53 -0
  64. package/lib/types/wizard/caddy.d.ts.map +1 -0
  65. package/lib/types/wizard/cloudflare.d.ts +112 -0
  66. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  67. package/lib/types/wizard/cloudflared.d.ts +71 -0
  68. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  69. package/lib/types/wizard/probe.d.ts +66 -0
  70. package/lib/types/wizard/probe.d.ts.map +1 -0
  71. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  72. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  73. package/lib/types/wizard/routes.d.ts +152 -0
  74. package/lib/types/wizard/routes.d.ts.map +1 -0
  75. package/lib/types/wizard/source-detect.d.ts +53 -0
  76. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  77. package/lib/types/wizard/status.d.ts +115 -0
  78. package/lib/types/wizard/status.d.ts.map +1 -0
  79. package/lib/types/wizard/token-store.d.ts +27 -0
  80. package/lib/types/wizard/token-store.d.ts.map +1 -0
  81. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  82. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  83. package/lib/types/wizard/verify.d.ts +97 -0
  84. package/lib/types/wizard/verify.d.ts.map +1 -0
  85. package/lib/wizard/binary-io.js +386 -0
  86. package/lib/wizard/binary-io.js.map +1 -0
  87. package/lib/wizard/binary.js +138 -0
  88. package/lib/wizard/binary.js.map +1 -0
  89. package/lib/wizard/caddy-process.js +162 -0
  90. package/lib/wizard/caddy-process.js.map +1 -0
  91. package/lib/wizard/caddy.js +80 -0
  92. package/lib/wizard/caddy.js.map +1 -0
  93. package/lib/wizard/cloudflare.js +334 -0
  94. package/lib/wizard/cloudflare.js.map +1 -0
  95. package/lib/wizard/cloudflared.js +158 -0
  96. package/lib/wizard/cloudflared.js.map +1 -0
  97. package/lib/wizard/probe.js +118 -0
  98. package/lib/wizard/probe.js.map +1 -0
  99. package/lib/wizard/proxy-snippets.js +112 -0
  100. package/lib/wizard/proxy-snippets.js.map +1 -0
  101. package/lib/wizard/routes.js +462 -0
  102. package/lib/wizard/routes.js.map +1 -0
  103. package/lib/wizard/source-detect.js +77 -0
  104. package/lib/wizard/source-detect.js.map +1 -0
  105. package/lib/wizard/status.js +94 -0
  106. package/lib/wizard/status.js.map +1 -0
  107. package/lib/wizard/token-store.js +71 -0
  108. package/lib/wizard/token-store.js.map +1 -0
  109. package/lib/wizard/tunnel-orchestrator.js +155 -0
  110. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  111. package/lib/wizard/verify.js +171 -0
  112. package/lib/wizard/verify.js.map +1 -0
  113. package/package.json +37 -37
  114. package/scripts/check-settings-form.mjs +5 -4
  115. package/scripts/check-wizard-ui.mjs +690 -0
  116. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  117. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  118. package/src/admin-routes.ts +44 -2
  119. package/src/client/index.tsx +9 -0
  120. package/src/client/locales.ts +410 -2
  121. package/src/client/wizard/GatewayWizard.tsx +2220 -0
  122. package/src/client/wizard/WizardCard.tsx +81 -0
  123. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  124. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  125. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  126. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  127. package/src/client/wizard/register-wizard.ts +99 -0
  128. package/src/client/wizard/wizard-css.ts +948 -0
  129. package/src/client-data/qr.ts +284 -0
  130. package/src/client-data/settings-form.ts +12 -3
  131. package/src/client-data/wizard.ts +432 -0
  132. package/src/config.ts +87 -0
  133. package/src/index.ts +227 -2
  134. package/src/intercept.ts +43 -3
  135. package/src/merge-streams.ts +41 -12
  136. package/src/wizard/binary-io.ts +449 -0
  137. package/src/wizard/binary.ts +177 -0
  138. package/src/wizard/caddy-process.ts +228 -0
  139. package/src/wizard/caddy.ts +97 -0
  140. package/src/wizard/cloudflare.ts +413 -0
  141. package/src/wizard/cloudflared.ts +196 -0
  142. package/src/wizard/probe.ts +161 -0
  143. package/src/wizard/proxy-snippets.ts +128 -0
  144. package/src/wizard/routes.ts +537 -0
  145. package/src/wizard/source-detect.ts +87 -0
  146. package/src/wizard/status.ts +180 -0
  147. package/src/wizard/token-store.ts +73 -0
  148. package/src/wizard/tunnel-orchestrator.ts +197 -0
  149. package/src/wizard/verify.ts +236 -0
package/src/index.ts CHANGED
@@ -16,11 +16,11 @@
16
16
  */
17
17
 
18
18
  import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
19
- import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises'
19
+ import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
20
20
  import type { FileHandle } from 'node:fs/promises'
21
21
  import type { IncomingMessage, ServerResponse } from 'node:http'
22
22
  import { randomBytes } from 'node:crypto'
23
- import { homedir } from 'node:os'
23
+ import { homedir, networkInterfaces } from 'node:os'
24
24
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
25
25
  import type { Context } from '@deepseek-ai/cordis'
26
26
  import type {} from '@deepseek-ai/dsh-agent'
@@ -68,6 +68,15 @@ import { ADAPTERS } from './adapters/index.js'
68
68
  import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
69
69
  import type { RelayGateway, RelayHandler } from './relay-server.js'
70
70
  import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
71
+ import { createWizardHandler, WIZARD_ROUTE_PREFIX } from './wizard/routes.js'
72
+ import { createCaddyManager } from './wizard/caddy-process.js'
73
+ import type { SubprocessServiceLike } from './wizard/caddy-process.js'
74
+ import { cfTokenConfigured, deleteCfTokenFile, readCfTokenFile, writeCfTokenFile } from './wizard/token-store.js'
75
+ import { createCloudflaredManager, tunnelTokenConfigured } from './wizard/cloudflared.js'
76
+ import { checkDomainOnCloudflare as runTunnelCheck, setupTunnel } from './wizard/tunnel-orchestrator.js'
77
+ import { writeTunnelTokenFile } from './wizard/cloudflared.js'
78
+ import { detectForwardedSource, parseProxyList } from './wizard/source-detect.js'
79
+ import { createVerificationManager } from './wizard/verify.js'
71
80
  import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
72
81
  import { createPluginLoopback } from './plugin-loopback.js'
73
82
  import { createPluginTunnel } from './plugin-tunnel.js'
@@ -789,6 +798,222 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
789
798
  }),
790
799
  }), 'dsh-zen-remote: admin routes')
791
800
  })
801
+ // Gateway-wizard routes (GW-4, extended by GW-5): a separate LONGEST-
802
+ // prefix registration under the admin prefix — the host web server
803
+ // matches the longest prefix first, so /admin/wizard/* lands here and
804
+ // everything else stays with the admin handler above. Same host-only
805
+ // gate and admit wall; the wizard handler applies its own via-marker
806
+ // and same-origin walls to every route. The runtime probe reads the
807
+ // gateway's own /lan-gate/status for the untrusted-proxy detection
808
+ // (read-only, best-effort); the trust-proxy persister merges into the
809
+ // FILE layer of lan-gate.config.json — the only writable config surface
810
+ // the plugin owns — which the restart watcher's poll picks up and
811
+ // reloads the row from (trustedProxies is a restart field), restarting
812
+ // the gateway with the new trust list. When the resolved value comes
813
+ // from env or the row the route refuses instead: a file-layer write
814
+ // would be shadowed there.
815
+ ctx.inject(['webServer', 'connection'], (wizardCtx) => {
816
+ // GW-13a: the managed Caddy lives in THIS row's closure — a row
817
+ // reload rebuilds the manager (and the row's disposal, below, kills
818
+ // the child), which is the reconcile-by-process-generation rule. The
819
+ // subprocess service is a lazy reflection lookup: adding it to the
820
+ // inject list would keep the whole wizard off compositions that lack
821
+ // it, and there `reflect.get` answers undefined instead.
822
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
823
+ const subprocessService = wizardCtx.reflect.get('subprocess') as SubprocessServiceLike | undefined
824
+ const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome })
825
+ const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome })
826
+ // The verification session lives in THIS manager's closure (GW-9):
827
+ // memory-only by design, and a row reload rebuilds it — the same
828
+ // "stale" verdict the config fingerprint produces, arriving via the
829
+ // process generation instead.
830
+ const currentMode = (): string => resolveConfig(config, readFileConfig(), process.env).values.ingressMode
831
+ const detectLanAddress = (): string | undefined => {
832
+ try {
833
+ const nets = networkInterfaces()
834
+ let tailscaleIp: string | undefined
835
+ let lanIp: string | undefined
836
+ for (const entries of Object.values(nets)) {
837
+ if (!Array.isArray(entries)) continue
838
+ for (const entry of entries) {
839
+ if (!entry || entry.internal || entry.family !== 'IPv4') continue
840
+ const addr = entry.address
841
+ if (
842
+ /^192\.168\./.test(addr)
843
+ || /^10\./.test(addr)
844
+ || /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)
845
+ ) {
846
+ lanIp ??= addr
847
+ } else if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
848
+ tailscaleIp ??= addr
849
+ }
850
+ }
851
+ }
852
+ return lanIp ?? tailscaleIp
853
+ } catch {
854
+ return undefined
855
+ }
856
+ }
857
+ // One runtime probe per request: the gateway's own status (source-IP
858
+ // detection + the ACTUAL port for snippets/QR), plus our processes'
859
+ // liveness for the projection.
860
+ const buildRuntime = async () => {
861
+ const lanAddress = detectLanAddress()
862
+ try {
863
+ const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
864
+ if (!response.ok) return { caddyRunning: caddyManager?.isRunning() === true, lanAddress }
865
+ const body = (await response.json()) as { port?: unknown; lastForwardedSource?: unknown }
866
+ return {
867
+ lastForwardedSource: detectForwardedSource(body),
868
+ // GW-fix-3/M2: the wizard's snippets and QR target the
869
+ // gateway's ACTUAL port (it may have retried past the
870
+ // configured one).
871
+ gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
872
+ lanAddress,
873
+ caddyRunning: caddyManager?.isRunning() === true,
874
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
875
+ tunnelTokenConfigured: cloudflaredManager !== undefined,
876
+ cfTokenConfigured: cfTokenConfigured(dshHome),
877
+ }
878
+ } catch {
879
+ // No answer is no detection — the status projection renders
880
+ // its null shape and the UI offers nothing.
881
+ return {
882
+ lanAddress,
883
+ caddyRunning: caddyManager?.isRunning() === true,
884
+ cloudflaredRunning: cloudflaredManager?.isRunning() === true,
885
+ tunnelTokenConfigured: tunnelTokenConfigured(dshHome),
886
+ cfTokenConfigured: cfTokenConfigured(dshHome),
887
+ }
888
+ }
889
+ }
890
+ const verification = createVerificationManager({
891
+ gatewayBase: `http://127.0.0.1:${effective.values.port}`,
892
+ getValues: () => resolveConfig(config, readFileConfig(), process.env).values,
893
+ // runtimeReady now covers the managed paths: Caddy must actually be
894
+ // alive; cloudflared arrives with GW-13b; the external path has no
895
+ // process of ours to wait for.
896
+ runtimeReady: () => {
897
+ const mode = currentMode()
898
+ if (mode === 'caddy') return caddyManager?.isRunning() === true
899
+ if (mode === 'cloudflared') return cloudflaredManager?.isRunning() === true
900
+ return true
901
+ },
902
+ })
903
+ wizardCtx.effect(() => {
904
+ const unregister = wizardCtx.webServer.register({
905
+ kind: 'prefix',
906
+ path: WIZARD_ROUTE_PREFIX,
907
+ handler: createWizardHandler({
908
+ admit: (req) => wizardCtx.connection.admit(req),
909
+ getConfig: () => resolveConfig(config, readFileConfig(), process.env),
910
+ tokenStore: {
911
+ writeCfToken: (token) => { writeCfTokenFile(dshHome, token) },
912
+ deleteCfToken: () => {
913
+ // Stop the consumers first (the mode-switch rule): whichever
914
+ // process might hold the token in its environment dies before
915
+ // the file disappears.
916
+ caddyManager?.stop()
917
+ cloudflaredManager?.stop()
918
+ deleteCfTokenFile(dshHome)
919
+ },
920
+ cfTokenConfigured: () => cfTokenConfigured(dshHome),
921
+ writeTunnelToken: (token) => { writeTunnelTokenFile(dshHome, token) },
922
+ },
923
+ ...(caddyManager === undefined ? {} : {
924
+ caddy: {
925
+ start: async (input: { domain: string; httpsPort: number; gatewayPort: number }) => {
926
+ // 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
927
+ // and restarts Caddy if it was already running with an older config.
928
+ cloudflaredManager?.stop()
929
+ if (caddyManager.isRunning()) caddyManager.stop()
930
+ // The token rides the child env only — read from the store
931
+ // inside the wiring, never through a route payload.
932
+ const token = readCfTokenFile(dshHome)
933
+ if (token === undefined) return { ok: false as const, error: 'missing-token' as const }
934
+ return caddyManager.start({ ...input, cfToken: token })
935
+ },
936
+ stop: () => caddyManager.stop(),
937
+ status: () => (caddyManager.isRunning() ? 'running' as const : caddyManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
938
+ },
939
+ }),
940
+ ...(cloudflaredManager === undefined ? {} : {
941
+ tunnel: {
942
+ checkDomain: (domain: string) => runTunnelCheck(
943
+ { readToken: () => readCfTokenFile(dshHome) },
944
+ domain,
945
+ ),
946
+ setup: (input: { domain: string; tunnelId?: string }) => setupTunnel(
947
+ {
948
+ dshHome,
949
+ readToken: () => readCfTokenFile(dshHome),
950
+ gatewayPort: async () => {
951
+ // The ingress service target is the gateway's ACTUAL
952
+ // port — same fallback as the caddy start route: probe
953
+ // /lan-gate/status first, fall back to the configured
954
+ // port when it does not answer (GW-fix-4/M2).
955
+ try {
956
+ const response = await fetch(`http://127.0.0.1:${resolveConfig(config, readFileConfig(), process.env).values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
957
+ if (response.ok) {
958
+ const body = (await response.json()) as { port?: unknown }
959
+ if (typeof body?.port === 'number') return body.port
960
+ }
961
+ } catch { /* fall through to the configured port */ }
962
+ return resolveConfig(config, readFileConfig(), process.env).values.port
963
+ },
964
+ cloudflared: {
965
+ start: async (startInput: { tunnelToken: string }) => {
966
+ if (cloudflaredManager.isRunning()) cloudflaredManager.stop()
967
+ const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken })
968
+ return started.ok ? ({ ok: true } as const) : ({ ok: false, error: started.error ?? 'spawn-failed' } as const)
969
+ },
970
+ // 模式切换:starting the tunnel stops the managed Caddy.
971
+ stopCaddy: () => caddyManager?.stop(),
972
+ },
973
+ },
974
+ input,
975
+ ),
976
+ stop: () => cloudflaredManager.stop(),
977
+ status: () => (cloudflaredManager.isRunning() ? 'running' as const : cloudflaredManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
978
+ },
979
+ }),
980
+ verify: { start: (input) => verification.start(input), check: () => verification.check() },
981
+ getRuntime: () => buildRuntime(),
982
+ trustProxy: {
983
+ resolved: () => {
984
+ const snapshot = resolveConfig(config, readFileConfig(), process.env)
985
+ return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies }
986
+ },
987
+ write: async (expected, list) => {
988
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
989
+ const file = join(home, 'lan-gate.config.json')
990
+ const current = readFileConfig()
991
+ // Concurrent-write guard (GW-fix-2/L2): the route resolved
992
+ // the list moments ago; if the file layer moved since (a
993
+ // parallel trust click, a hand edit), refuse instead of
994
+ // clobbering the other writer's entry. Both sides compare
995
+ // normalized so an illegal file value reads as "empty", the
996
+ // same thing resolveConfig saw.
997
+ const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : ''
998
+ if (parseProxyList(now).join(',') !== parseProxyList(expected).join(',')) return false
999
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1000
+ await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 })
1001
+ await rename(tmp, file)
1002
+ return true
1003
+ },
1004
+ },
1005
+ }),
1006
+ })
1007
+ // H1: the row's disposal must not orphan the managed children — the
1008
+ // Caddy/cloudflared processes die WITH the row that spawned them,
1009
+ // before the route registration unregisters.
1010
+ return () => {
1011
+ caddyManager?.stop()
1012
+ cloudflaredManager?.stop()
1013
+ unregister()
1014
+ }
1015
+ }, 'dsh-zen-remote: wizard routes')
1016
+ })
792
1017
  // The idle sweeper (T22c): every minute, hand the table the CURRENT
793
1018
  // idleHours and let it close sessions quiet past that. idleHours is a
794
1019
  // volatile row field, so getIdleHours re-resolves from the SAME row
package/src/intercept.ts CHANGED
@@ -1330,6 +1330,10 @@ interface MergedGlobalStreamDeps {
1330
1330
  * session" (T62); the merged stream re-judges immediately instead of
1331
1331
  * waiting for a frame or the fallback poll. Returns the disposer. */
1332
1332
  watchCurrentSession(fn: () => void): () => void
1333
+ /** Subscribe one callback to {@link InterceptHandle.uninstall} so a live
1334
+ * merged stream removes its remote groups and archives/hides its remote
1335
+ * sessions when the plugin unloads or switches role from client to host. */
1336
+ watchUninstall(fn: () => void): () => void
1333
1337
  /** T63: the summary-sync hub. The WORKSPACE route reports the virtual
1334
1338
  * session ids each forwarded frame batch carried, so the events leg's
1335
1339
  * sync can announce list rows the UI has no summary for; the control
@@ -1430,6 +1434,24 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1430
1434
  for (const frame of merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)) channel.push(frame)
1431
1435
  }
1432
1436
  const offCurrentSession = deps.watchCurrentSession(informCurrentSession)
1437
+ const offUninstall = deps.watchUninstall(() => {
1438
+ try {
1439
+ // When the client intercept is uninstalled (e.g. role switched from
1440
+ // client to host), transition to `unpaired` (and ensure a readable
1441
+ // current-session state so all remote sessions move into the archived
1442
+ // set and are hidden from the default sidebar view while staying in
1443
+ // their workspace group so they never become 「未分组」 strays).
1444
+ const read = deps.getCurrentSession()
1445
+ merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)
1446
+ applyAnnotation('unpaired')
1447
+ merger.onRemoteDown()
1448
+ currentController?.abort()
1449
+ } catch {
1450
+ // ignore teardown errors
1451
+ } finally {
1452
+ flushWaiters()
1453
+ }
1454
+ })
1433
1455
  let sessionPoll: unknown = undefined
1434
1456
  const pollCurrentSession = (): void => {
1435
1457
  sessionPoll = undefined
@@ -1471,11 +1493,21 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1471
1493
  // persisted server-side, so a re-pair to the SAME server keeps the
1472
1494
  // virtual prefix — and the UI's removedIds blacklist never clears
1473
1495
  // during a page's life, so a remove here would make the group
1474
- // un-revivable. Handle it exactly like a remote death: keep the
1475
- // shown state, cut the in-flight leg; the re-pair lands as `online`
1476
- // and the pump's wait resumes under the SAME identity.
1496
+ // un-revivable. Handle it like a remote death: keep the shown
1497
+ // workspace group, archive/hide the remote sessions via
1498
+ // applyAnnotation → onStatusChanged (when `unpaired` or when
1499
+ // current-session is readable), and cut the in-flight leg; the
1500
+ // re-pair lands as `online` and restores the sessions under the SAME
1501
+ // identity.
1502
+ const read = deps.getCurrentSession()
1503
+ if (state === 'unpaired' && read.kind === 'unavailable') {
1504
+ merger.setCurrentSession(undefined)
1505
+ } else {
1506
+ informCurrentSession()
1507
+ }
1477
1508
  applyAnnotation(annotationOf(state))
1478
1509
  merger.onRemoteDown()
1510
+ ensureSessionPoll()
1479
1511
  currentController?.abort()
1480
1512
  return
1481
1513
  }
@@ -1703,6 +1735,7 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1703
1735
  alive = false
1704
1736
  if (sessionPoll !== undefined) clock.clearTimeout(sessionPoll)
1705
1737
  offCurrentSession()
1738
+ offUninstall()
1706
1739
  offState()
1707
1740
  if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
1708
1741
  currentController?.abort()
@@ -2400,6 +2433,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2400
2433
  currentSessionListeners.add(fn)
2401
2434
  return () => { currentSessionListeners.delete(fn) }
2402
2435
  }
2436
+ const uninstallListeners = new Set<() => void>()
2437
+ const watchUninstall = (fn: () => void): (() => void) => {
2438
+ uninstallListeners.add(fn)
2439
+ return () => { uninstallListeners.delete(fn) }
2440
+ }
2403
2441
  const gateway = raw as Record<string | symbol, unknown>
2404
2442
  const shape = checkGatewayShape(raw)
2405
2443
  const counters = { openWireStream: 0, dispatchRpc: 0 }
@@ -3301,6 +3339,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3301
3339
  clock,
3302
3340
  getCurrentSession,
3303
3341
  watchCurrentSession,
3342
+ watchUninstall,
3304
3343
  summarySync,
3305
3344
  })
3306
3345
  })()
@@ -3378,6 +3417,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3378
3417
  uninstall() {
3379
3418
  if (!installed) return
3380
3419
  installed = false
3420
+ for (const listener of [...uninstallListeners]) listener()
3381
3421
  // The catalog watcher dies with the wrap (T52-fix2): a late online
3382
3422
  // transition after this point belongs to another generation.
3383
3423
  offCatalogWatch?.()
@@ -419,19 +419,43 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
419
419
  return currentOriginal() !== id
420
420
  }
421
421
 
422
- /** T58: re-derive {@link hiddenSessions} from the live tombstone set —
423
- * tombstones hide unless they ARE the current session (or no readable
424
- * current is known, or the tombstone is inside the T65 hide grace);
425
- * live-carried ids and ids that (re-)became the current session leave the
426
- * set. Since T65 the hide/reveal transitions move ONLY the archived set —
427
- * the session stays in its group's forwarded copy either way — so the
428
- * caller emits just the merged archived frame when anything changed. */
422
+ /** Whether the relay status is an unpair or token revocation — while in
423
+ * either state, every known remote session is archived/hidden (except the
424
+ * currently open session when current session is readable, so its open page
425
+ * is not kicked home by `clearArchivedCurrent`). Reconnecting (`none`) or
426
+ * re-sharing automatically reveals them again. */
427
+ function isPairingDisconnected(): boolean {
428
+ return annotation === 'unpaired' || annotation === 'revoked'
429
+ }
430
+
431
+ /** T58: re-derive {@link hiddenSessions} from the live tombstone set (and
432
+ * when unpaired/revoked with a readable current session, every known remote
433
+ * session) — tombstones hide unless they ARE the current session (or no
434
+ * readable current is known, or the tombstone is inside the T65 hide
435
+ * grace); live-carried ids and ids that (re-)became the current session
436
+ * leave the set when paired. Since T65 the hide/reveal transitions move
437
+ * ONLY the archived set — the session stays in its group's forwarded copy
438
+ * either way — so the caller emits just the merged archived frame when
439
+ * anything changed. */
429
440
  function syncHiddenSessions(): { hidden: string[]; revealed: string[] } {
430
441
  const hidden: string[] = []
431
442
  const revealed: string[] = []
443
+ const disconnected = isPairingDisconnected()
444
+ const current = currentOriginal()
432
445
  for (const [id, home] of sessionHome) {
433
446
  const record = remote.get(home)
434
447
  if (record === undefined) continue
448
+ if (disconnected) {
449
+ if (currentReadable && current !== id) {
450
+ if (!hiddenSessions.has(id)) {
451
+ hiddenSessions.add(id)
452
+ hidden.push(id)
453
+ }
454
+ } else if (hiddenSessions.delete(id)) {
455
+ revealed.push(id)
456
+ }
457
+ continue
458
+ }
435
459
  if (stringList(record.sessionIds).includes(id)) {
436
460
  if (hiddenSessions.delete(id)) revealed.push(id)
437
461
  continue
@@ -794,7 +818,10 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
794
818
 
795
819
  onStatusChanged(): unknown[] {
796
820
  if (!localSeen || remote.size === 0) return []
797
- return upsertFrames()
821
+ const changed = syncHiddenSessions()
822
+ const out = upsertFrames()
823
+ if (changed.hidden.length > 0 || changed.revealed.length > 0) out.push(mergedArchivedFrame())
824
+ return out
798
825
  },
799
826
 
800
827
  setCurrentSession(sessionId: string | undefined): unknown[] {
@@ -811,13 +838,15 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
811
838
  },
812
839
 
813
840
  get hasPendingHide(): boolean {
814
- // Some tombstone is being kept visible: it is not in the hidden set
815
- // (either it IS the current session, or no readable current made any
816
- // decision yet) — the caller keeps polling until it hides.
841
+ // Some tombstone (or unpaired/revoked session) is being kept visible:
842
+ // it is not in the hidden set (either it IS the current session, or no
843
+ // readable current made any decision yet) — the caller keeps polling
844
+ // until it hides.
845
+ const disconnected = isPairingDisconnected()
817
846
  for (const [id, home] of sessionHome) {
818
847
  if (hiddenSessions.has(id)) continue
819
848
  const record = remote.get(home)
820
- if (record !== undefined && !stringList(record.sessionIds).includes(id)) return true
849
+ if (record !== undefined && (disconnected || !stringList(record.sessionIds).includes(id))) return true
821
850
  }
822
851
  return false
823
852
  },