dsh-zen-remote 2.0.3 → 2.2.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 (119) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +57 -0
  10. package/lib/activity.js +25 -0
  11. package/lib/activity.js.map +1 -1
  12. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  13. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  14. package/lib/adapters/dsh-better-sidebar/methods.js +221 -0
  15. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  16. package/lib/adapters/index.js +17 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/types.js +17 -0
  19. package/lib/adapters/types.js.map +1 -0
  20. package/lib/admin-routes.js +51 -0
  21. package/lib/admin-routes.js.map +1 -1
  22. package/lib/client-routes.js +18 -1
  23. package/lib/client-routes.js.map +1 -1
  24. package/lib/client.js +449 -45
  25. package/lib/config.js +27 -0
  26. package/lib/config.js.map +1 -1
  27. package/lib/http.js +10 -0
  28. package/lib/http.js.map +1 -1
  29. package/lib/index.js +273 -1
  30. package/lib/index.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +319 -2
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +633 -0
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/activity.d.ts +22 -0
  46. package/lib/types/activity.d.ts.map +1 -1
  47. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  48. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  49. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +58 -0
  50. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  51. package/lib/types/adapters/index.d.ts +17 -0
  52. package/lib/types/adapters/index.d.ts.map +1 -0
  53. package/lib/types/adapters/types.d.ts +42 -0
  54. package/lib/types/adapters/types.d.ts.map +1 -0
  55. package/lib/types/admin-routes.d.ts +16 -0
  56. package/lib/types/admin-routes.d.ts.map +1 -1
  57. package/lib/types/client/effects/remote-session.d.ts +0 -22
  58. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  59. package/lib/types/client/locales.d.ts +16 -0
  60. package/lib/types/client/locales.d.ts.map +1 -1
  61. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  62. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  63. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  64. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  65. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  66. package/lib/types/client-data/remote-status.d.ts +20 -0
  67. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  68. package/lib/types/client-data/settings-form.d.ts +37 -1
  69. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  70. package/lib/types/client-routes.d.ts +13 -0
  71. package/lib/types/client-routes.d.ts.map +1 -1
  72. package/lib/types/config.d.ts +8 -0
  73. package/lib/types/config.d.ts.map +1 -1
  74. package/lib/types/http.d.ts +7 -0
  75. package/lib/types/http.d.ts.map +1 -1
  76. package/lib/types/index.d.ts +4 -0
  77. package/lib/types/index.d.ts.map +1 -1
  78. package/lib/types/plugin-adapter-core.d.ts +263 -0
  79. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  80. package/lib/types/plugin-bridge.d.ts +187 -0
  81. package/lib/types/plugin-bridge.d.ts.map +1 -0
  82. package/lib/types/plugin-loopback.d.ts +104 -0
  83. package/lib/types/plugin-loopback.d.ts.map +1 -0
  84. package/lib/types/plugin-route-intercept.d.ts +114 -0
  85. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  86. package/lib/types/plugin-tunnel.d.ts +96 -0
  87. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  88. package/lib/types/relay-client.d.ts +76 -0
  89. package/lib/types/relay-client.d.ts.map +1 -1
  90. package/lib/types/relay-server.d.ts +32 -1
  91. package/lib/types/relay-server.d.ts.map +1 -1
  92. package/llms.txt +1 -0
  93. package/package.json +2 -2
  94. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  95. package/scripts/check-settings-form.mjs +3 -1
  96. package/src/activity.ts +32 -0
  97. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  98. package/src/adapters/dsh-better-sidebar/methods.ts +244 -0
  99. package/src/adapters/index.ts +19 -0
  100. package/src/adapters/types.ts +44 -0
  101. package/src/admin-routes.ts +64 -0
  102. package/src/client/effects/remote-session.ts +82 -10
  103. package/src/client/locales.ts +36 -0
  104. package/src/client/settings/SettingsSection.tsx +107 -3
  105. package/src/client/styles/remote-session.css.ts +70 -31
  106. package/src/client-data/plugin-bridge.ts +150 -0
  107. package/src/client-data/remote-status.ts +30 -0
  108. package/src/client-data/settings-form.ts +78 -0
  109. package/src/client-routes.ts +29 -1
  110. package/src/config.ts +32 -0
  111. package/src/http.ts +12 -0
  112. package/src/index.ts +279 -1
  113. package/src/plugin-adapter-core.ts +530 -0
  114. package/src/plugin-bridge.ts +435 -0
  115. package/src/plugin-loopback.ts +299 -0
  116. package/src/plugin-route-intercept.ts +1193 -0
  117. package/src/plugin-tunnel.ts +470 -0
  118. package/src/relay-client.ts +394 -2
  119. package/src/relay-server.ts +656 -1
package/src/index.ts CHANGED
@@ -38,7 +38,7 @@ declare module '@deepseek-ai/cordis' {
38
38
  }
39
39
  }
40
40
  import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
41
- import { createActivityTracker, createParentIndex, startSweeper } from './activity.js'
41
+ import { createActivityTracker, createParentIndex, seedParentIndexFromSnapshots, startSweeper, type PersistedSessionSnapshot } from './activity.js'
42
42
  import { ADMIN_ROUTE_PREFIX, createAdminHandler } from './admin-routes.js'
43
43
  import type { SessionQueryLike } from './admin-routes.js'
44
44
  import { normalizedRowServerUrl } from './client-pairing.js'
@@ -59,8 +59,22 @@ import { checkGatewayShape } from './intercept-shape.js'
59
59
  import type { GatewayShapeCheck } from './intercept-shape.js'
60
60
  import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
61
61
  import type { FetchRouteInterceptDiagnostics, FetchRouteInterceptHandle, FetchRouteShapeCheck } from './fetch-route-intercept.js'
62
+ import { checkWebServerShape, installPluginRouteIntercept } from './plugin-route-intercept.js'
63
+ import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebServerShapeCheck } from './plugin-route-intercept.js'
64
+ import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
65
+ import type { NormalizedAdapter } from './plugin-adapter-core.js'
66
+ import { ADAPTERS } from './adapters/index.js'
62
67
  import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
63
68
  import type { RelayGateway, RelayHandler } from './relay-server.js'
69
+ import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
70
+ import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
71
+ import { createPluginLoopback } from './plugin-loopback.js'
72
+ import { createPluginTunnel } from './plugin-tunnel.js'
73
+
74
+ // The plugin bridge's public types (PB-1), re-exported so another plugin's
75
+ // author can `import type { … } from 'dsh-zen-remote'` without reaching into
76
+ // lib/types. Type-only: no runtime code, no Context augmentation.
77
+ export type { BridgeDeclaration, ZenRemoteService } from './plugin-bridge.js'
64
78
 
65
79
  // The loader's config schema (settings form) and the role normalizer live in
66
80
  // src/config.ts next to the resolution they describe; re-exported so the
@@ -126,6 +140,22 @@ export function getRelayClient(): RelayClient | undefined {
126
140
  return clientRelayClient
127
141
  }
128
142
 
143
+ /**
144
+ * The plugin bridge's declaration registry (PB-1), created inside apply() on
145
+ * BOTH roles and kept on the module so later tasks (the handshake's plugins
146
+ * table, the client-side rewrite) can reach it without reaching into a
147
+ * context. Undefined in compositions that never ran this module's apply, and
148
+ * — identity-guarded below — after the row that owned a registry was
149
+ * unloaded or reloaded, so a reloaded row never leaves a stale table where
150
+ * the getter can hand it out (the T23a-fix discipline).
151
+ */
152
+ let bridgeRegistry: BridgeRegistry | undefined
153
+
154
+ /** The live bridge registry, if a plugin row is currently applied. */
155
+ export function getBridgeRegistry(): BridgeRegistry | undefined {
156
+ return bridgeRegistry
157
+ }
158
+
129
159
  /** Host half config. Every field below arrives through the loader as a
130
160
  * volatile live reference — a `{ get() }` wrapper over the stored value, not
131
161
  * the value itself (src/config.ts explains why every field is volatile) — so
@@ -461,6 +491,76 @@ export async function handleUpload(
461
491
  */
462
492
  export function apply(ctx: Context, config: MobileNavConfig = {}): void {
463
493
  const effective = resolveConfig(config, readFileConfig(), process.env)
494
+ // The plugin bridge's zenRemote service (PB-1), provided on BOTH roles
495
+ // before any role branch: a plugin that wants its panel data forwarded in
496
+ // remote sessions declares that against THIS process whichever role it
497
+ // runs as, and the declaration registry is the one piece of PB-1 both the
498
+ // server half (handshake plugins table) and the client half (route
499
+ // rewrite) will read. ctx.provide (cordis 4) registers the service owned
500
+ // by THIS row's fiber: visible to `inject(['zenRemote'])` dependents once
501
+ // the row is active, withdrawn when the row unloads — so a composition
502
+ // without zen-remote never fires those injects and a row reload swaps the
503
+ // service wholesale. The registry goes into the module slot BEFORE the
504
+ // provide so this apply's consumers never see a window without it; the
505
+ // effect's identity guard then clears the slot exactly when THIS row
506
+ // unloads (a reload installs the new registry before the old disposer
507
+ // runs, same shape as clientRelayClient). provide() throwing is not worth
508
+ // failing the row over — the bridge stays off, everything else keeps
509
+ // working — so it degrades to a warning like the session feeds do.
510
+ const registry = createBridgeRegistry({
511
+ warn: (text) => { ctx.logger?.warn('dsh-zen-remote plugin bridge: %s', text) },
512
+ })
513
+ bridgeRegistry = registry
514
+ try {
515
+ const service: ZenRemoteService = {
516
+ bridge: (declaration: BridgeDeclaration) => registry.register(declaration),
517
+ }
518
+ ctx.provide('zenRemote', service)
519
+ } catch (error) {
520
+ ctx.logger?.warn('dsh-zen-remote: cannot provide the "zenRemote" service: %s', message(error))
521
+ }
522
+ ctx.effect(() => () => {
523
+ if (bridgeRegistry === registry) bridgeRegistry = undefined
524
+ }, 'dsh-zen-remote: plugin bridge registry disposal')
525
+ // The adapters (PB-11): each one registers ONLY against its target's
526
+ // EXACT installed version, probed off the host's `pluginPackages` service
527
+ // (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
528
+ // `{name, version, dir, …}` or undefined; any other shape, a missing
529
+ // service or a probe failure reads as "not installed" and the adapter
530
+ // stays out). A plugin that registers its OWN cooperative declaration
531
+ // always wins: the adapter yields at registration time (logged), and the
532
+ // runtime re-checks the registry digest on every request. Both roles run
533
+ // this loop — the registry entries drive the handshake table, the bridge
534
+ // verdicts and the settings listing on either side, while the returned
535
+ // array feeds the two request halves.
536
+ const adapters: NormalizedAdapter[] = []
537
+ {
538
+ const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
539
+ for (const adapter of ADAPTERS) {
540
+ try {
541
+ const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
542
+ const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
543
+ const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
544
+ const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
545
+ ? (found as { version: string }).version
546
+ : undefined
547
+ if (installedVersion === undefined || installedVersion === '') {
548
+ adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
549
+ continue
550
+ }
551
+ const normalized = normalizeAdapter(adapter, installedVersion)
552
+ if (registry.get(normalized.plugin) !== undefined) {
553
+ adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
554
+ continue
555
+ }
556
+ registry.registerNormalized(normalized.declaration)
557
+ adapters.push(normalized)
558
+ ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
559
+ } catch (error) {
560
+ adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
561
+ }
562
+ }
563
+ }
464
564
  if (effective.values.role === 'host') {
465
565
  // A row that switched from client to host must not leave a stale relay
466
566
  // client where getRelayClient() can find it (T23a-fix).
@@ -557,6 +657,32 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
557
657
  const agents = (agentsCtx as Context & { agents: { list(): readonly AgentStatusLike[] } }).agents
558
658
  restoreBusy(store, () => agents.list())
559
659
  })
660
+ // Restart repair (2026-10-13): the parent index rebuilds only from LIVE
661
+ // session/event traffic, so after a host restart every already-stored
662
+ // sidechat / subagent child lost its parent link and the relay answered
663
+ // `not-shared` for a session whose share it used to inherit through the
664
+ // ancestor chain. Seed the index once from the persistence service's
665
+ // snapshot list — headers only, event logs are never read.
666
+ try {
667
+ ctx.inject(['sessionPersistence'], (persistenceCtx) => {
668
+ const persistence = (persistenceCtx as Context & {
669
+ sessionPersistence?: { list(): Promise<readonly PersistedSessionSnapshot[]> } | undefined
670
+ }).sessionPersistence
671
+ if (persistence === undefined) {
672
+ ctx.logger.warn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event')
673
+ return
674
+ }
675
+ void persistence
676
+ .list()
677
+ .then((snapshots) => {
678
+ const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots)
679
+ if (recorded > 0) ctx.logger.info?.('dsh-zen-remote: restored %d persisted parent links', recorded)
680
+ })
681
+ .catch((error: unknown) => ctx.logger.warn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)))
682
+ })
683
+ } catch (error) {
684
+ ctx.logger.warn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
685
+ }
560
686
  // Settings-surface admin routes (T14). The browser cannot call the
561
687
  // gateway's loopback-only admin API from DSH's origin, so the host
562
688
  // process re-exposes it same-origin and the handler calls the gateway AS
@@ -597,6 +723,16 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
597
723
  // composition lacks the service.
598
724
  viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
599
725
  sessionQuery: () => webCtx.reflect.get('sessionQuery') as SessionQueryLike | undefined,
726
+ // The plugin bridge listing (PB-5): the SAME registry this apply
727
+ // built and the SAME live consent reader the relay's plugins table
728
+ // uses — one source of truth per question. PB-11: the listing
729
+ // marks the plugins an adapter stands in for (live — a plugin
730
+ // that registered its own declaration stops being marked).
731
+ pluginBridge: {
732
+ registry,
733
+ getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
734
+ adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
735
+ },
600
736
  }),
601
737
  }), 'dsh-zen-remote: admin routes')
602
738
  })
@@ -645,6 +781,32 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
645
781
  // ~7 ms per computation against a full registry; a failed compute
646
782
  // lands as the empty map inside the relay handler's own guard.
647
783
  relayCtx.effect(() => {
784
+ // The plugin bridge's loopback facts (PB-3), both read PER REQUEST
785
+ // through the reflection layer: the web server may have restarted on
786
+ // another port or protocol, and the connection service's
787
+ // authenticatedUrl — the token-exchange entry the lan-gate wiring
788
+ // also reads (0.1.1 hosts lack the method entirely) — lives on a
789
+ // service deliberately NOT in this inject's declaration list (same
790
+ // reason as getApiFetch above: adding it would keep the whole relay
791
+ // prefix off compositions that lack it).
792
+ const getLoopbackTarget = () => {
793
+ const webServer = relayCtx.reflect.get('webServer') as { protocol?: unknown; port?: unknown } | undefined
794
+ const port = typeof webServer?.port === 'number' && webServer.port > 0 ? webServer.port : undefined
795
+ if (port === undefined) throw new Error('the web server exposes no loopback port')
796
+ return { protocol: webServer?.protocol === 'https:' ? ('https:' as const) : ('http:' as const), port }
797
+ }
798
+ const getTokenUrl = () => {
799
+ const connection = relayCtx.reflect.get('connection') as
800
+ | { authenticatedUrl?: (base: string) => string | undefined }
801
+ | undefined
802
+ if (typeof connection?.authenticatedUrl !== 'function') return undefined
803
+ const { protocol, port } = getLoopbackTarget()
804
+ return connection.authenticatedUrl(`${protocol}//127.0.0.1:${port}`)
805
+ }
806
+ // One loopback executor for BOTH plugin halves (the HTTP forward and
807
+ // the PB-10 tunnel): a single cookie cache behind two consumers.
808
+ const pluginLoopback = createPluginLoopback({ getTarget: getLoopbackTarget, getTokenUrl })
809
+ const pluginConsent = () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
648
810
  const handler = createRelayHandler({
649
811
  secret: relaySecret,
650
812
  store,
@@ -662,6 +824,43 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
662
824
  serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
663
825
  dshVersion: resolveDshVersion(),
664
826
  fingerprints: () => computeFingerprints(relayCtx),
827
+ // PB-2: the plugin bridge table, rebuilt FRESH at every handshake
828
+ // from the PB-1 registry and the row's consent map — both live:
829
+ // the registry through this apply's own closure, the consent
830
+ // through resolveConfig on the SAME row object (a volatile field,
831
+ // read at use time per the hard constraint, never snapshotted).
832
+ plugins: () => {
833
+ const consent = resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent
834
+ const table: Record<string, ServerPluginEntry> = {}
835
+ for (const declaration of registry.list()) {
836
+ table[declaration.plugin] = {
837
+ version: declaration.version,
838
+ digest: declaration.digest,
839
+ serverScopeConsent: consent[declaration.plugin] === true,
840
+ }
841
+ }
842
+ return table
843
+ },
844
+ },
845
+ // PB-3: the forwarding half of the plugin bridge — the registry
846
+ // this apply built (the zenRemote service's table), the consent
847
+ // map read LIVE per request (a volatile row field), and the
848
+ // loopback executor over the wiring's two reflection getters.
849
+ // PB-10: the WebSocket byte tunnel's server half shares the SAME
850
+ // loopback (one cookie cache) and the same live consent; its
851
+ // reachability judgment rides the store directly (the handler's
852
+ // own isAccessible closure is not visible out here).
853
+ pluginBridge: {
854
+ registry,
855
+ getConsent: pluginConsent,
856
+ loopback: pluginLoopback,
857
+ adapters,
858
+ tunnel: createPluginTunnel({
859
+ bridge: { registry, getConsent: pluginConsent, loopback: pluginLoopback },
860
+ getTarget: getLoopbackTarget,
861
+ isAccessible: (sessionId) => store.isAccessible(sessionId, parentIndex.parentOf),
862
+ heartbeatMs: 15_000,
863
+ }),
665
864
  },
666
865
  })
667
866
  const unregister = relayCtx.webServer.register({
@@ -735,6 +934,15 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
735
934
  computeOwnFingerprints: () => computeFingerprints(ctx),
736
935
  })
737
936
  clientRelayClient = relayClient
937
+ // The plugin bridge's live verdict (PB-2), the CLIENT role's job — a host
938
+ // has no remote sessions of its own to bridge anything into. Computed PER
939
+ // CALL (never a stored conclusion): the local registry is this apply's
940
+ // own closure, the server's table is the LAST handshake's `plugins`
941
+ // (undefined until one succeeded — an old server reads server-too-old),
942
+ // and `online` is the client's CURRENT state — so a registry change, a
943
+ // fresh handshake or a link flip shows up at the very next read.
944
+ const pluginBridgeStatus = (): BridgeStatus[] =>
945
+ compareBridges(registry.list(), relayClient.handshakeInfo?.plugins, relayClient.state === 'online')
738
946
  // A stopped or reloaded row must not leave a stale instance where the
739
947
  // module getter can hand it out (T23a-fix), and its reconnect machinery
740
948
  // (T43) must not outlive the row either: stop() cancels the armed retry
@@ -931,6 +1139,74 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
931
1139
  if (fetchRouteHandle === handle) fetchRouteHandle = undefined
932
1140
  }, 'dsh-zen-remote: fetch-route intercept')
933
1141
  })
1142
+ // The plugin bridge's route interception (PB-4): the local webServer's
1143
+ // `match` wrap forwarding bridged-plugin requests for REMOTE sessions
1144
+ // through the relay. Injected on `webServer` alone — the only service
1145
+ // it reads — and the raw instance comes off the reflection layer's
1146
+ // symbols lookup, the same face the typert and fetch-route wraps use.
1147
+ // The diagnostics flow to the client status route like theirs, refused
1148
+ // installs included.
1149
+ let pluginRouteHandle: PluginRouteInterceptHandle | undefined
1150
+ let pluginRouteRefused: WebServerShapeCheck | undefined
1151
+ const pluginRouteDiagnostics = (): PluginRouteInterceptDiagnostics | undefined => {
1152
+ if (pluginRouteHandle !== undefined) return pluginRouteHandle.diagnostics()
1153
+ if (pluginRouteRefused !== undefined) {
1154
+ return {
1155
+ installed: false,
1156
+ shape: pluginRouteRefused,
1157
+ forwarded: 0,
1158
+ refused: 0,
1159
+ refusalsByReason: {},
1160
+ recentFailures: [],
1161
+ adapterForwarded: 0,
1162
+ adapterResponseRewrites: 0,
1163
+ adapterResponsePassthroughs: 0,
1164
+ tunnelsCurrent: 0,
1165
+ tunnelsOpened: 0,
1166
+ tunnelsClosed: 0,
1167
+ }
1168
+ }
1169
+ return undefined
1170
+ }
1171
+ ctx.inject(['webServer'], (wsCtx) => {
1172
+ const logWarn = (...args: Parameters<Context['logger']['warn']>): void => { ctx.logger?.warn(...args) }
1173
+ const proxy = (wsCtx as Context & { webServer?: unknown }).webServer
1174
+ const raw = proxy !== null && typeof proxy === 'object'
1175
+ ? (proxy as unknown as Record<PropertyKey, unknown>)[symbols.original]
1176
+ : undefined
1177
+ if (raw === null || typeof raw !== 'object') {
1178
+ pluginRouteRefused = { ok: false, reasons: ['original: webServer[symbols.original] is not an object'] }
1179
+ logWarn('dsh-zen-remote plugin-route intercept not installed: %s', pluginRouteRefused.reasons[0])
1180
+ return
1181
+ }
1182
+ // Shape first, like every wrap: a failed check leaves the webServer
1183
+ // exactly as it was — local routing untouched, remote bridging off.
1184
+ const shape = checkWebServerShape(raw)
1185
+ if (!shape.ok) {
1186
+ pluginRouteRefused = shape
1187
+ logWarn('dsh-zen-remote plugin-route intercept not installed: %s', shape.reasons.join('; '))
1188
+ return
1189
+ }
1190
+ const handle = installPluginRouteIntercept({
1191
+ webServer: raw,
1192
+ registry,
1193
+ relay: relayClient,
1194
+ getServerId: () => relayClient.handshakeInfo?.serverId,
1195
+ getStatus: pluginBridgeStatus,
1196
+ adapters,
1197
+ // PB-12: the browser's currently open session — a VIRTUAL id means
1198
+ // a remote panel is the request's origin, the signal the adapter
1199
+ // routes' deny hooks read (better-sidebar's open.external carries
1200
+ // no session field of its own).
1201
+ getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
1202
+ log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1203
+ })
1204
+ pluginRouteHandle = handle
1205
+ wsCtx.effect(() => () => {
1206
+ handle.uninstall()
1207
+ if (pluginRouteHandle === handle) pluginRouteHandle = undefined
1208
+ }, 'dsh-zen-remote: plugin route intercept')
1209
+ })
934
1210
  ctx.inject(['webServer', 'connection'], (clientCtx) => {
935
1211
  clientCtx.effect(() => clientCtx.webServer.register({
936
1212
  kind: 'prefix',
@@ -941,6 +1217,8 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
941
1217
  getRelayClient: () => relayClient,
942
1218
  getIntercept: interceptDiagnostics,
943
1219
  getFetchRouteIntercept: fetchRouteDiagnostics,
1220
+ getPluginRouteIntercept: pluginRouteDiagnostics,
1221
+ getPluginBridgeStatus: pluginBridgeStatus,
944
1222
  onCurrentSession,
945
1223
  }),
946
1224
  }), 'dsh-zen-remote: client routes')