dsh-zen-remote 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) 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 +63 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/intercept.js +5 -2
  30. package/lib/intercept.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 +340 -4
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +665 -7
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  46. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  47. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  48. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  49. package/lib/types/adapters/index.d.ts +17 -0
  50. package/lib/types/adapters/index.d.ts.map +1 -0
  51. package/lib/types/adapters/types.d.ts +42 -0
  52. package/lib/types/adapters/types.d.ts.map +1 -0
  53. package/lib/types/admin-routes.d.ts +16 -0
  54. package/lib/types/admin-routes.d.ts.map +1 -1
  55. package/lib/types/client/effects/remote-session.d.ts +0 -22
  56. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  57. package/lib/types/client/locales.d.ts +16 -0
  58. package/lib/types/client/locales.d.ts.map +1 -1
  59. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  60. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  61. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  62. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  63. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  64. package/lib/types/client-data/remote-status.d.ts +20 -0
  65. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  66. package/lib/types/client-data/settings-form.d.ts +37 -1
  67. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  68. package/lib/types/client-routes.d.ts +13 -0
  69. package/lib/types/client-routes.d.ts.map +1 -1
  70. package/lib/types/config.d.ts +8 -0
  71. package/lib/types/config.d.ts.map +1 -1
  72. package/lib/types/http.d.ts +7 -0
  73. package/lib/types/http.d.ts.map +1 -1
  74. package/lib/types/index.d.ts +4 -0
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/intercept.d.ts.map +1 -1
  77. package/lib/types/plugin-adapter-core.d.ts +263 -0
  78. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  79. package/lib/types/plugin-bridge.d.ts +187 -0
  80. package/lib/types/plugin-bridge.d.ts.map +1 -0
  81. package/lib/types/plugin-loopback.d.ts +104 -0
  82. package/lib/types/plugin-loopback.d.ts.map +1 -0
  83. package/lib/types/plugin-route-intercept.d.ts +114 -0
  84. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  85. package/lib/types/plugin-tunnel.d.ts +96 -0
  86. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  87. package/lib/types/relay-client.d.ts +81 -1
  88. package/lib/types/relay-client.d.ts.map +1 -1
  89. package/lib/types/relay-server.d.ts +32 -1
  90. package/lib/types/relay-server.d.ts.map +1 -1
  91. package/llms.txt +1 -0
  92. package/package.json +2 -2
  93. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  94. package/scripts/check-settings-form.mjs +3 -1
  95. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  96. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  97. package/src/adapters/index.ts +19 -0
  98. package/src/adapters/types.ts +44 -0
  99. package/src/admin-routes.ts +64 -0
  100. package/src/client/effects/remote-session.ts +82 -10
  101. package/src/client/locales.ts +36 -0
  102. package/src/client/settings/SettingsSection.tsx +107 -3
  103. package/src/client/styles/remote-session.css.ts +70 -31
  104. package/src/client-data/plugin-bridge.ts +150 -0
  105. package/src/client-data/remote-status.ts +30 -0
  106. package/src/client-data/settings-form.ts +78 -0
  107. package/src/client-routes.ts +29 -1
  108. package/src/config.ts +32 -0
  109. package/src/http.ts +12 -0
  110. package/src/index.ts +252 -0
  111. package/src/intercept.ts +5 -2
  112. package/src/plugin-adapter-core.ts +530 -0
  113. package/src/plugin-bridge.ts +435 -0
  114. package/src/plugin-loopback.ts +299 -0
  115. package/src/plugin-route-intercept.ts +1193 -0
  116. package/src/plugin-tunnel.ts +470 -0
  117. package/src/relay-client.ts +413 -4
  118. package/src/relay-server.ts +687 -9
@@ -253,6 +253,22 @@ export const zh = {
253
253
  'settings.client.diagRecentFailures': '最近的远程调用失败',
254
254
  'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
255
255
  'settings.client.diagCompatCalls': '最近的不兼容调用',
256
+ // --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
257
+ 'settings.bridge.title': '插件桥',
258
+ 'settings.bridge.empty': '暂无插件登记插件桥',
259
+ 'settings.bridge.loading': '正在读取插件桥登记…',
260
+ 'settings.bridge.loadFailed': '插件桥列表读取失败(旧版服务端或经网关打开的页面)',
261
+ 'settings.bridge.sessionOnly': '随会话共享转发',
262
+ 'settings.bridge.consentToggle': '允许转发整机数据',
263
+ 'settings.bridge.consentHint': '打开后,已配对的桌面应用端可在远程会话里读取该插件的整机数据。',
264
+ 'settings.bridge.stateBridged': '已桥接',
265
+ 'settings.bridge.stateNotOnServer': '服务端未安装',
266
+ 'settings.bridge.stateVersionMismatch': '版本不同(服务端 {version})',
267
+ 'settings.bridge.stateDeclarationMismatch': '声明不一致',
268
+ 'settings.bridge.stateConsentOff': '服务端主人未允许整机数据',
269
+ 'settings.bridge.stateOffline': '中继离线',
270
+ 'settings.bridge.stateServerTooOld': '服务端 zen-remote 版本过旧',
271
+ 'settings.bridge.interceptOff': '本机改道未启用:{reason}',
256
272
  // --- T34 子客户端远程状态:标题行图标、横幅、关闭远程 ---
257
273
  'remoteStatusOnline': '远程会话 · 已连接',
258
274
  'remoteStatusOffline': '服务端离线,正在重连',
@@ -269,6 +285,9 @@ export const zh = {
269
285
  'remoteStatusOfflineClick': '服务端离线,无法操作',
270
286
  'remoteStatusUnshareFail': '关闭远程失败,请稍后再试',
271
287
  'remoteStatusUnshareFailCode': '关闭远程失败({code}),请稍后再试',
288
+ // --- PB-14: the unbridged better-sidebar note (a CSS variable the
289
+ // remote-session stylesheet spends as the empty tab's ::before copy) ---
290
+ 'remoteBridgeUnbridgedNote': 'better-sidebar 未在远程会话桥接(服务端未装同版本或未允许),文件与改动请以会话内消息为准',
272
291
  // 远程分组标题的后缀(后台 merge-streams 拿不到界面语言,固定用中文);
273
292
  // 这里登记英文对照,供界面侧需要复述同样状态时使用。
274
293
  'remoteGroupOffline': '(离线)',
@@ -524,6 +543,22 @@ export const en: Record<MobileNavKey, string> = {
524
543
  'settings.client.diagRecentFailures': 'Recent remote call failures',
525
544
  'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
526
545
  'settings.client.diagCompatCalls': 'Recent incompatible calls',
546
+ // --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
547
+ 'settings.bridge.title': 'Plugin bridge',
548
+ 'settings.bridge.empty': 'No plugin has registered a bridge yet',
549
+ 'settings.bridge.loading': 'Loading the bridge registrations…',
550
+ 'settings.bridge.loadFailed': 'The bridge listing failed to load (an older server, or the page opened through the gateway)',
551
+ 'settings.bridge.sessionOnly': 'Forwards with shared sessions',
552
+ 'settings.bridge.consentToggle': 'Allow forwarding whole-machine data',
553
+ 'settings.bridge.consentHint': 'When on, paired desktop apps can read this plugin\u2019s whole-machine data in remote sessions.',
554
+ 'settings.bridge.stateBridged': 'Bridged',
555
+ 'settings.bridge.stateNotOnServer': 'Not installed on the server',
556
+ 'settings.bridge.stateVersionMismatch': 'Version differs (server {version})',
557
+ 'settings.bridge.stateDeclarationMismatch': 'Declaration mismatch',
558
+ 'settings.bridge.stateConsentOff': 'Server owner has not allowed whole-machine data',
559
+ 'settings.bridge.stateOffline': 'Relay offline',
560
+ 'settings.bridge.stateServerTooOld': 'Server zen-remote is too old',
561
+ 'settings.bridge.interceptOff': 'Local rerouting not enabled: {reason}',
527
562
  // --- T34 remote-status icon, composer banner, unshare ---
528
563
  'remoteStatusOnline': 'Remote session · connected',
529
564
  'remoteStatusOffline': 'Server offline — reconnecting',
@@ -540,6 +575,7 @@ export const en: Record<MobileNavKey, string> = {
540
575
  'remoteStatusOfflineClick': 'The server is offline — nothing to do',
541
576
  'remoteStatusUnshareFail': 'Closing the remote failed — try again later',
542
577
  'remoteStatusUnshareFailCode': 'Closing the remote failed ({code}) — try again later',
578
+ 'remoteBridgeUnbridgedNote': 'better-sidebar is not bridged for this remote session (the server lacks the same version or has not allowed it) — treat files and changes as session messages only',
543
579
  // Remote group title suffixes (the background merger cannot know the UI
544
580
  // language and always uses the Chinese forms); the English equivalents
545
581
  // live here for UI surfaces that restate the same status.
@@ -35,11 +35,12 @@
35
35
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
36
36
  import type { ReactNode } from 'react'
37
37
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
38
- import { Button, SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives'
38
+ import { Button, SettingsForm, SettingsValueField, Switch } from '@deepseek-ai/dsh-client-ui-primitives'
39
39
  import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives'
40
40
  import {
41
41
  ADMIN_ACTION_ROUTE,
42
42
  ADMIN_PAIR_ROUTE,
43
+ ADMIN_PLUGIN_BRIDGE_ROUTE,
43
44
  ADMIN_PUSH_TEST_ROUTE,
44
45
  ADMIN_STATUS_ROUTE,
45
46
  CLIENT_CLAIM_ROUTE,
@@ -55,6 +56,8 @@ import {
55
56
  normalizePairingCode,
56
57
  shouldFollowServerDeviceName,
57
58
  } from '../../client-data/settings-form.ts'
59
+ import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts'
60
+ import type { PluginBridgeStatusView } from '../../client-data/plugin-bridge.ts'
58
61
  import type {
59
62
  AdminStatusBody,
60
63
  ClaimRouteBody,
@@ -374,6 +377,40 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
374
377
  ? deriveSettingsView(load.body, { now, rowUser: config.rowUser() })
375
378
  : undefined
376
379
  const clientRole = config.savedRoleIsClient()
380
+
381
+ // The plugin bridge's registered-plugin listing (PB-5), host page only:
382
+ // loaded once per page life and refreshed whenever a save lands (the
383
+ // consent staged by the toggles below rides that very save). Starts in
384
+ // the loading state — the section renders the loading copy until the
385
+ // first answer lands (or the load fails, which is its own state, never
386
+ // the empty one); a composition without the route stays loading.
387
+ // PB-9-fix: the listing is a THREE-state read — loading, failed (an old
388
+ // build, or the page opened through the gateway where the listing is
389
+ // refused), or an answer that carries zero rows. Only the third means
390
+ // "nothing registered"; the first two must not masquerade as it.
391
+ const [bridgeLoad, setBridgeLoad] = useState<{ state: 'loading' | 'error' | 'ready', rows: { plugin: string, version: string, hasServerScope: boolean, consent: boolean }[] }>({ state: 'loading', rows: [] })
392
+ const loadBridge = useCallback(() => {
393
+ setBridgeLoad((prev) => ({ state: 'loading', rows: prev.rows }))
394
+ fetch(ADMIN_PLUGIN_BRIDGE_ROUTE)
395
+ .then((res) => {
396
+ if (!res.ok) return Promise.reject(new Error(String(res.status)))
397
+ return res.json() as Promise<{ value?: { plugins?: unknown } }>
398
+ })
399
+ .then((body) => {
400
+ const plugins = body?.value?.plugins
401
+ if (!Array.isArray(plugins)) return
402
+ setBridgeLoad({
403
+ state: 'ready',
404
+ rows: plugins.filter((row): row is { plugin: string, version: string, hasServerScope: boolean, consent: boolean } =>
405
+ row !== null && typeof row === 'object' && typeof (row as { plugin?: unknown }).plugin === 'string'),
406
+ })
407
+ })
408
+ .catch(() => { setBridgeLoad((prev) => ({ state: 'error', rows: prev.rows })) })
409
+ }, [])
410
+ useEffect(() => {
411
+ if (!clientRole) loadBridge()
412
+ }, [clientRole, loadBridge])
413
+ const bridgeState = bridgeSectionState(bridgeLoad.state, bridgeLoad.rows)
377
414
  const statusPoll = config.statusPoll()
378
415
 
379
416
  useEffect(() => {
@@ -817,6 +854,9 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
817
854
  else setClientLoad({ state: 'loading' })
818
855
  const refresh = config.savedRoleIsClient() ? loadClientStatus : loadStatus
819
856
  refresh()
857
+ // The plugin bridge listing re-reads too (PB-5): the consent toggles
858
+ // stage through the very save that just landed.
859
+ if (!config.savedRoleIsClient()) loadBridge()
820
860
  // Immediate pull + 1.5s covers a hot-only save. A save that reloads the
821
861
  // plugin row (T17) restarts the row and the gateway child with it — the
822
862
  // reload and the gateway's rebinding of its port both take longer than
@@ -901,6 +941,49 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
901
941
  <h3 className="zr-settings-card-title">{t('settings.shareTitle')}</h3>
902
942
  {text(form.idleHours, 'idleHours', t('settings.fieldIdleHours'), t('settings.fieldIdleHoursHint'), t('settings.invalidHours'), true)}
903
943
  {bool(form.autoShareNewSessions, 'autoShareNewSessions', t('settings.fieldAutoShare'), t('settings.fieldAutoShareHint'))}
944
+
945
+ {/* 插件桥 (PB-5): the consent toggles stage the NEXT consent map
946
+ over the SAME form — one save button, one revision fence —
947
+ reading the CURRENT map off the staged (or displayed) draft
948
+ so consecutive toggles compose. */}
949
+ <h3 className="zr-settings-card-title">{t('settings.bridge.title')}</h3>
950
+ {bridgeState === 'loading' && <p className="zr-settings-hint">{t('settings.bridge.loading')}</p>}
951
+ {bridgeState === 'error' && <p className="zr-settings-hint" data-invalid="true">{t('settings.bridge.loadFailed')}</p>}
952
+ {bridgeState === 'empty' && <p className="zr-settings-hint">{t('settings.bridge.empty')}</p>}
953
+ {bridgeLoad.rows.map((row) => {
954
+ let current: Record<string, boolean> = {}
955
+ try {
956
+ const parsed: unknown = JSON.parse(form.pluginBridgeConsent.text)
957
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
958
+ current = parsed as Record<string, boolean>
959
+ }
960
+ } catch { /* a malformed draft reads as nothing consented */ }
961
+ return (
962
+ <div className="zr-settings-field" key={row.plugin}>
963
+ <div className="zr-settings-head">
964
+ <span className="zr-settings-head-title">{row.plugin}</span>
965
+ <span className="zr-settings-badge">{row.version}</span>
966
+ </div>
967
+ {row.hasServerScope ? (
968
+ <>
969
+ <div className="zr-settings-head">
970
+ <label>{t('settings.bridge.consentToggle')}</label>
971
+ <Switch
972
+ checked={current[row.plugin] === true}
973
+ onChange={(checked) => {
974
+ config.stage('pluginBridgeConsent', JSON.stringify(togglePluginConsent(current, row.plugin, checked)))
975
+ }}
976
+ label={t('settings.bridge.consentToggle')}
977
+ />
978
+ </div>
979
+ <p className="zr-settings-hint">{t('settings.bridge.consentHint')}</p>
980
+ </>
981
+ ) : (
982
+ <p className="zr-settings-hint">{t('settings.bridge.sessionOnly')}</p>
983
+ )}
984
+ </div>
985
+ )
986
+ })}
904
987
  </>
905
988
  )}
906
989
  </SettingsForm>
@@ -1073,8 +1156,9 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1073
1156
  client/status body. The interceptor (T23b) and compat (T42)
1074
1157
  groups render only when the response carried their fields —
1075
1158
  earlier servers answer neither, and the section itself only
1076
- exists when there is at least one line to show. */}
1077
- {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined) && (
1159
+ exists when there is at least one line to show. PB-5 adds the
1160
+ bridge rows and the rerouting note on the same presence gate. */}
1161
+ {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined) && (
1078
1162
  <>
1079
1163
  <h3 className="zr-settings-card-title">{t('settings.client.diagTitle')}</h3>
1080
1164
  {clientView.lastError !== '' && (
@@ -1132,6 +1216,26 @@ function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) {
1132
1216
  )}
1133
1217
  </>
1134
1218
  )}
1219
+ {(clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && (
1220
+ <div className="zr-settings-field">
1221
+ <div className="zr-settings-head">
1222
+ <label>{t('settings.bridge.title')}</label>
1223
+ </div>
1224
+ {clientView.pluginBridge.map((row: PluginBridgeStatusView) => (
1225
+ <p key={row.plugin} className="zr-settings-hint">
1226
+ {row.plugin} · {row.localVersion !== '' ? `v${row.localVersion}` : '—'} ·{' '}
1227
+ {row.stateKey === 'settings.bridge.stateVersionMismatch'
1228
+ ? t(row.stateKey, { version: row.serverVersion ?? '?' })
1229
+ : t(row.stateKey)}
1230
+ </p>
1231
+ ))}
1232
+ </div>
1233
+ )}
1234
+ {clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && (
1235
+ <p className="zr-settings-hint" data-invalid="true">
1236
+ {t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' })}
1237
+ </p>
1238
+ )}
1135
1239
  </>
1136
1240
  )}
1137
1241
  </div>
@@ -32,17 +32,23 @@ html[data-zr-remote-session="1"] [data-open-target] {
32
32
  display: none !important;
33
33
  }
34
34
 
35
- /* ---------- remote session: hide dsh-better-sidebar's file surfaces (T53) ----------
36
- dsh-better-sidebar 0.24.x is the second big "open the machine's files" UI.
37
- Every tab it registers (files/editor, git changes, subagent/tasks, side
38
- chat, the markdown/html/code file viewers) builds its requests from a
39
- {sessionId, cwd} scope and POSTs them to the plugin's own /sidebar/api/*
40
- routes on whichever machine answers — in a remote session that is the
41
- SUB-CLIENT's machine (client.js call() ~3111, fetchUpload ~3162,
42
- encodeHtmlUrl /sidebar/html/ ~3069-3414), so the tree would show same-named
43
- LOCAL files as if they were the session's and an edit would WRITE locally.
44
- Per the T53 decision the interfaces are hidden, not proxied: the relay
45
- carries no /sidebar routes and none are added.
35
+ /* ---------- remote session: hide dsh-better-sidebar's file surfaces (T53, two regimes since PB-14) ----------
36
+ dsh-better-sidebar 0.25.0 is the second big "open the machine's files" UI
37
+ AND, since PB-12/13, the first ADAPTER-BRIDGED one: at exactly 0.25.0 its
38
+ /sidebar/api routes, its two WebSockets and its side chat cross the byte
39
+ tunnel and read/write the SERVER's data, gated on the whole-machine-data
40
+ consent. The hiding below therefore applies ONLY while the plugin is NOT
41
+ bridged — the :not([data-zr-bridge-dsh-better-sidebar="1"]) stamp the
42
+ remote-status poll writes; see the PB-14 note above): bridged, the panels
43
+ are remote-aware and stay visible; unbridged (a different version, the
44
+ server without the adapter's consent), every tab it registers (files/
45
+ editor, git changes, subagent/tasks, side chat, the markdown/html/code
46
+ file viewers) builds its requests from a {sessionId, cwd} scope and POSTs
47
+ them to the plugin's own /sidebar/api/* routes on whichever machine
48
+ answers — in a remote session that is the SUB-CLIENT's machine (client.js
49
+ call() ~3111, fetchUpload ~3162, encodeHtmlUrl /sidebar/html/ ~3069-3414),
50
+ so the tree would show same-named LOCAL files as if they were the
51
+ session's and an edit would WRITE locally.
46
52
 
47
53
  Anchors, all the plugin's or the host's own rendered contracts (no hashed
48
54
  class names; the one suffix selector, _chipIcon, is unique among the
@@ -51,18 +57,21 @@ html[data-zr-remote-session="1"] [data-open-target] {
51
57
  @liustack/modsearch / @wxg-prc-cpg/browser-skill-dsh-plugin (modsearch /
52
58
  browser-skill) / dshmarket and every @deepseek-ai sidebar package for it):
53
59
 
54
- HONEST LIMITS (T53-fix): everything here is display:none ONLY. The plugin's
60
+ HONEST LIMITS (T53-fix, adjusted PB-14): unbridged, the plugin's
55
61
  components stay mounted and keep silently POSTing READ-ONLY /sidebar/api
56
62
  requests to the local machine (session.cwd, changes.ops, the tree reads a
57
63
  mounted tab refreshes with) — the data never leaves that machine, and no
58
- write can happen because every write control is inside the hidden surfaces.
59
- Known boundary: a dsh-resource://file/** address opened through
60
- sidebarRight.openResource in a remote session (file links in chat,
61
- deliverables, @-references, skills) is CLAIMED by the plugin's editor
62
- registration (client.js ~21377: patterns ["dsh-resource://file/**"]) before
63
- the host preview sees it — the right sidebar expands, the rules below hide
64
- the tab's body and chip, and an EMPTY panel remains. Root fix needs
65
- zen-remote's own remote file tree; separate discussion.
64
+ write can happen because every write control is inside the hidden
65
+ surfaces (the tab body hides its CHILDREN and spends the note copy as a
66
+ ::before, so an already-open tab reads WHY it is empty instead of
67
+ sitting blank). Known boundary: a dsh-resource://file/** address opened
68
+ through sidebarRight.openResource in a remote session (file links in
69
+ chat, deliverables, @-references, skills) is CLAIMED by the plugin's
70
+ editor registration (client.js ~21377: patterns ["dsh-resource://file/**"])
71
+ before the host preview sees it — the right sidebar expands, the rules
72
+ below hide the tab's body content and chip, and the note fills the panel.
73
+ Bridged (0.25.0 + consent), none of this applies: the tab reads the
74
+ server's data through the adapter and stays visible whole.
66
75
 
67
76
  - [data-dsh-bottom-toggle] — the header-utilities button that expands the
68
77
  bottom workbench (client.js BottomDockToggle ~20863; slot registration
@@ -135,31 +144,61 @@ html[data-zr-remote-session="1"] [data-open-target] {
135
144
  attribute and this rule hides it too (cosmetic; the page stays reachable
136
145
  through its shortcut command path and the plugin's takeover is the
137
146
  "files" implementation on every deployment this plugin pairs with
138
- better-sidebar anyway). */
139
- html[data-zr-remote-session="1"] [data-dsh-bottom-toggle] {
147
+ better-sidebar anyway).
148
+
149
+ /* PB-14: every T53 rule below carries
150
+ :not([data-zr-bridge-dsh-better-sidebar="1"]) — when the adapter
151
+ (PB-12) bridges the plugin, the panels read the SERVER's data through
152
+ the byte tunnel and MUST stay visible; the hiding applies only to the
153
+ unbridged combination, and the empty tab body then carries a note
154
+ instead of blankness. The stamp is written by
155
+ effects/remote-session.ts off the remote-status poll's bridgedPlugins
156
+ list (client-data/remote-status.ts shouldStampBridgeAttribute). */
157
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-bottom-toggle] {
140
158
  display: none !important;
141
159
  }
142
- html[data-zr-remote-session="1"] [data-dsh-panel-host] {
160
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-panel-host] {
143
161
  display: none !important;
144
162
  }
145
- html[data-zr-remote-session="1"] {
163
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) {
146
164
  --dsh-sidebar-height: 0px !important;
147
165
  }
148
- html[data-zr-remote-session="1"] [data-dsh-native-tab-host] {
166
+ /* The unbridged tab body: hide the plugin's own content and spend the
167
+ note copy as a ::before — an already-open tab reads WHY it is empty
168
+ (0.25.0 keeps tabs mounted, so a stale body would otherwise sit blank).
169
+ --zr-bridge-note is set on <html> by the stamp effect, in the
170
+ browser's language; unset (effect not running yet) the content falls
171
+ through and nothing renders. */
172
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-native-tab-host] > * {
173
+ display: none !important;
174
+ }
175
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-native-tab-host]::before {
176
+ content: var(--zr-bridge-note);
177
+ display: block;
178
+ padding: 24px 16px;
179
+ font-size: 13px;
180
+ line-height: 1.7;
181
+ color: var(--dsh-fg-muted, #6b7280);
182
+ word-break: break-word;
183
+ }
184
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dockkit-tab]:has([class$="_chipIcon"]) {
149
185
  display: none !important;
150
186
  }
151
- html[data-zr-remote-session="1"] [data-dockkit-tab]:has([class$="_chipIcon"]) {
187
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-floating-window]:has([data-window-body]) {
152
188
  display: none !important;
153
189
  }
154
- html[data-zr-remote-session="1"] [data-floating-window]:has([data-window-body]) {
190
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="git"],
191
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="subagent"],
192
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="sidechat"] {
155
193
  display: none !important;
156
194
  }
157
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="git"],
158
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="subagent"],
159
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="sidechat"] {
195
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-sidebar-right-guide-entry="files"]:not([aria-keyshortcuts]) {
160
196
  display: none !important;
161
197
  }
162
- html[data-zr-remote-session="1"] [data-sidebar-right-guide-entry="files"]:not([aria-keyshortcuts]) {
198
+ /* 0.25.0's dock fallback — the floating quick-open button the plugin pins
199
+ for empty workspaces. Same treatment as every other open-the-machine's-
200
+ files control: hidden while unbridged, untouched when bridged. */
201
+ html[data-zr-remote-session="1"]:not([data-zr-bridge-dsh-better-sidebar="1"]) [data-dsh-dock-fallback] {
163
202
  display: none !important;
164
203
  }
165
204
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The plugin bridge's settings-page pure pieces (PB-5): the status → copy
3
+ * mapping the 子客户端诊断区 renders, and the consent-toggle arithmetic the
4
+ * 主服务端插件桥 section stages. Pure by the settings-form discipline — no
5
+ * fetch, no clock, no context — so scripts/check-plugin-bridge-ui.mjs pins
6
+ * every copy and every toggle shape.
7
+ *
8
+ * Deliberately import-free: this file rides the BROWSER bundle, and the
9
+ * bridge's own module (src/plugin-bridge.ts) drags node:crypto in. The
10
+ * status shape is therefore restated structurally here — it mirrors
11
+ * lib/plugin-bridge.js's `BridgeStatus` verbatim, and a drifted wire is
12
+ * handled by the tolerant mapping, not by the type system.
13
+ */
14
+
15
+ /** One plugin's bridge verdict, exactly the shape `client/status`'s
16
+ * `pluginBridge` array carries (PB-2). */
17
+ export interface PluginBridgeStatusLike {
18
+ plugin: string
19
+ bridged: boolean
20
+ reason?: string
21
+ localVersion: string
22
+ serverVersion?: string
23
+ }
24
+
25
+ /** One row of the 诊断区's 插件桥 group: the plugin, its LOCAL version, and
26
+ * the copy key (plus the interpolation value) the locale renders. */
27
+ export interface PluginBridgeStatusView {
28
+ plugin: string
29
+ localVersion: string
30
+ /** The locale key under `settings.bridge.state*`. */
31
+ stateKey: PluginBridgeStateKey
32
+ /** The server's version, when the verdict names one (版本不同's copy). */
33
+ serverVersion: string | undefined
34
+ }
35
+
36
+ export type PluginBridgeStateKey =
37
+ | 'settings.bridge.stateBridged'
38
+ | 'settings.bridge.stateNotOnServer'
39
+ | 'settings.bridge.stateVersionMismatch'
40
+ | 'settings.bridge.stateDeclarationMismatch'
41
+ | 'settings.bridge.stateConsentOff'
42
+ | 'settings.bridge.stateOffline'
43
+ | 'settings.bridge.stateServerTooOld'
44
+
45
+ /** The reason → copy-key table, one entry per BridgeReason plus the
46
+ * everything-else fallback (an unknown reason from a newer server reads as
47
+ * 未桥接-generic — the offline copy, honest without inventing a claim). */
48
+ const REASON_KEYS: Readonly<Record<string, PluginBridgeStateKey>> = {
49
+ 'not-on-server': 'settings.bridge.stateNotOnServer',
50
+ 'version-mismatch': 'settings.bridge.stateVersionMismatch',
51
+ 'declaration-mismatch': 'settings.bridge.stateDeclarationMismatch',
52
+ 'consent-off': 'settings.bridge.stateConsentOff',
53
+ offline: 'settings.bridge.stateOffline',
54
+ 'server-too-old': 'settings.bridge.stateServerTooOld',
55
+ }
56
+
57
+ /** Tolerant read of one `pluginBridge` wire array into the render rows,
58
+ * input order kept (the route already sorts by plugin name). A malformed
59
+ * entry is skipped, never thrown into the settings page. */
60
+ export function derivePluginBridgeStatuses(value: unknown): PluginBridgeStatusView[] {
61
+ if (!Array.isArray(value)) return []
62
+ const rows: PluginBridgeStatusView[] = []
63
+ for (const entry of value) {
64
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
65
+ const record = entry as Record<string, unknown>
66
+ if (typeof record.plugin !== 'string' || record.plugin === '') continue
67
+ const bridged = record.bridged === true
68
+ const reason = typeof record.reason === 'string' ? record.reason : undefined
69
+ rows.push({
70
+ plugin: record.plugin,
71
+ localVersion: typeof record.localVersion === 'string' ? record.localVersion : '',
72
+ stateKey: bridged
73
+ ? 'settings.bridge.stateBridged'
74
+ : reason !== undefined && REASON_KEYS[reason] !== undefined
75
+ ? REASON_KEYS[reason]
76
+ : 'settings.bridge.stateOffline',
77
+ serverVersion: typeof record.serverVersion === 'string' ? record.serverVersion : undefined,
78
+ })
79
+ }
80
+ return rows
81
+ }
82
+
83
+ /**
84
+ * The three-state resolution for the host settings page's 插件桥 list
85
+ * (PB-9-fix): a fetch that has not answered is LOADING, a failed or
86
+ * refused fetch is an ERROR — and only a successful answer with zero rows
87
+ * means "no plugin has registered a bridge". Collapsing the first two into
88
+ * the empty copy would tell the user nothing is bridged while the answer is
89
+ * simply not in yet (or the page was opened through the gateway, where the
90
+ * listing is refused). Pure so the check pins all four outcomes.
91
+ */
92
+ export function bridgeSectionState(load: 'loading' | 'error' | 'ready', rows: unknown): 'loading' | 'error' | 'empty' | 'plugins' {
93
+ if (load === 'loading') return 'loading'
94
+ if (load === 'error') return 'error'
95
+ return Array.isArray(rows) && rows.length > 0 ? 'plugins' : 'empty'
96
+ }
97
+
98
+ /**
99
+ * One consent-toggle click: the NEXT whole-machine-data consent map, staged
100
+ * over the row's `pluginBridgeConsent` field. Only `plugin`'s key moves —
101
+ * every other key (including entries for plugins no longer registered; the
102
+ * settings page deliberately never prunes what it does not render) is
103
+ * carried over verbatim, in its existing key order. An illegal current map
104
+ * (a hand-edited row) reads as the empty map: the toggle then writes exactly
105
+ * one key, and the junk is left for the row validator to keep refusing.
106
+ */
107
+ export function togglePluginConsent(current: unknown, plugin: string, allow: boolean): Record<string, boolean> {
108
+ const next: Record<string, boolean> = {}
109
+ if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
110
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
111
+ if (typeof value === 'boolean') next[key] = value
112
+ }
113
+ }
114
+ next[plugin] = allow
115
+ return next
116
+ }
117
+
118
+ /** One row of the host settings page's 插件桥 list, as the admin listing
119
+ * answers it (PB-5: plugin/version/hasServerScope/consent; PB-11 adds the
120
+ * adapter mark). */
121
+ export interface AdminBridgeRowView {
122
+ plugin: string
123
+ version: string
124
+ hasServerScope: boolean
125
+ consent: boolean
126
+ /** True when zen-remote's own adapter stands in for this plugin (the
127
+ * row renders「由 zen-remote 适配」); a wiring that predates adapters
128
+ * never sets it. */
129
+ adapted: boolean
130
+ }
131
+
132
+ /**
133
+ * Tolerant read of one admin listing row into the render shape (PB-11).
134
+ * A malformed row is `undefined`, never a throw — the settings page skips
135
+ * it the same way {@link derivePluginBridgeStatuses} skips a bad verdict.
136
+ * `adapted` defaults to false: an older server's answer simply reads as
137
+ * "nothing is adapted".
138
+ */
139
+ export function readAdminBridgeRow(row: unknown): AdminBridgeRowView | undefined {
140
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return undefined
141
+ const record = row as Record<string, unknown>
142
+ if (typeof record.plugin !== 'string' || record.plugin === '') return undefined
143
+ return {
144
+ plugin: record.plugin,
145
+ version: typeof record.version === 'string' ? record.version : '',
146
+ hasServerScope: record.hasServerScope === true,
147
+ consent: record.consent === true,
148
+ adapted: record.adapted === true,
149
+ }
150
+ }
@@ -51,6 +51,11 @@ export interface RemoteStatusView {
51
51
  versionMismatch: boolean
52
52
  serverName: string
53
53
  closed: Readonly<Record<string, RemoteClosedReason>>
54
+ /** The plugins whose bridge verdict is bridged RIGHT NOW (PB-2; sorted
55
+ * server-side). A garbage or missing field degrades to the empty list —
56
+ * nothing is ever considered bridged on a shape the parse does not
57
+ * trust. */
58
+ bridgedPlugins: readonly string[]
54
59
  /** When this view was parsed off a GET body. */
55
60
  asOf: number
56
61
  }
@@ -80,10 +85,35 @@ export function parseRemoteStatusBody(body: unknown, now: number): RemoteStatusV
80
85
  versionMismatch: record.versionMismatch === true,
81
86
  serverName: typeof record.serverName === 'string' ? record.serverName : '',
82
87
  closed,
88
+ bridgedPlugins: Array.isArray(record.bridgedPlugins)
89
+ ? record.bridgedPlugins.filter((name): name is string => typeof name === 'string' && name !== '')
90
+ : [],
83
91
  asOf: now,
84
92
  }
85
93
  }
86
94
 
95
+ // --- the bridge stamp (PB-14) -----------------------------------------------
96
+
97
+ /**
98
+ * Whether ONE plugin's bridge verdict is bridged RIGHT NOW, judged off the
99
+ * `bridgedPlugins` list the remote-status body carries. Tolerant by the
100
+ * same discipline as the parse: a garbage list (an older server that never
101
+ * learned the field, a broken body) reads as "not bridged" — the hiding
102
+ * rules stay in force unless the server positively says otherwise, never
103
+ * the reverse. This is the pure core behind the
104
+ * `html[data-zr-bridge-<plugin>]` stamp the remote-session stylesheet
105
+ * spends.
106
+ */
107
+ export function shouldStampBridgeAttribute(bridgedPlugins: unknown, plugin: string): boolean {
108
+ return Array.isArray(bridgedPlugins) && bridgedPlugins.includes(plugin)
109
+ }
110
+
111
+ /** The plugin the bridge stamp names. */
112
+ export const BRIDGE_STAMP_PLUGIN = 'dsh-better-sidebar'
113
+
114
+ /** The html attribute the stamp writes (`data-zr-bridge-dsh-better-sidebar`). */
115
+ export const BRIDGE_STAMP_ATTRIBUTE = `data-zr-bridge-${BRIDGE_STAMP_PLUGIN}`
116
+
87
117
  // --- the icon's states -------------------------------------------------------
88
118
 
89
119
  /** The locale keys `describeRemoteStatus` needs. */