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
@@ -456,7 +456,9 @@ test('the latest-wins gate drops tickets that are no longer newest (T15-fix 4)',
456
456
 
457
457
  test('deriveClientStatusView maps every probe state and tolerates garbage', () => {
458
458
  // T59: the view carries the device's own name ('' when the body had none).
459
- const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined }
459
+ // PB-5: the bridge rows ([] — the body carried none) and the rerouting
460
+ // note (undefined) ride the same tolerant derivation.
461
+ const bare = { state: 'unpaired', serverUrl: '', serverName: '', deviceName: '', nextRetryAt: undefined, lastError: '', intercept: undefined, compat: undefined, pluginBridge: [], pluginRouteIntercept: undefined }
460
462
  assert.deepEqual(deriveClientStatusView({ state: 'unpaired' }), bare)
461
463
  assert.deepEqual(deriveClientStatusView({ state: 'connected', serverUrl: 'http://192.168.1.10:3088' }), {
462
464
  ...bare,
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The dsh-better-sidebar adapter (PB-12), written against EXACTLY
3
+ * version 0.25.0. Everything it knows about the plugin came off
4
+ * `/tmp/bs-versions/0.25.0/lib/index.js` (the installed copy at
5
+ * `~/.dsh/profiles/desktop/node_modules/dsh-better-sidebar` is the same
6
+ * build); the method table lives in `methods.ts` beside this file, and the
7
+ * test suite re-extracts the plugin's real method-name set to catch a
8
+ * silently drifted version.
9
+ *
10
+ * Product decisions (2026-10-10): reads AND writes both forward — a
11
+ * bridged better-sidebar is whole-server disk access, git writes, and
12
+ * model spend (commit-message generation), documented as such; NO path
13
+ * fencing; `open.external` refuses for a remote session; `settings.*` and
14
+ * `/sidebar/bundle` stay local; the adapter registers as whole-machine
15
+ * data (consent per plugin, default off).
16
+ *
17
+ * The `cwd` handling is the one subtle part: better-sidebar resolves a
18
+ * request's working directory as the session's own `header.cwd` first and
19
+ * only falls back to a `cwd` field the CLIENT supplied (lib/index.js:6295,
20
+ * 6075-6099). A remote panel must not steer the server by path, so the
21
+ * server hook strips `cwd` — from the JSON body (`/sidebar/api`) and from
22
+ * the query (`/sidebar/file`, `/sidebar/upload`) — and lets the backend
23
+ * resolve from the (validated, real) session id.
24
+ */
25
+
26
+ import type { BridgeAdapter } from '../types.js'
27
+ import { isVirtual } from '../../virtual-id.js'
28
+ import { SIDEBAR_METHODS, sidebarMethodNameOf } from './methods.js'
29
+
30
+ /** The relay's upload budget (`MAX_UPLOAD_BYTES` in lib/relay-server.js).
31
+ * Inlined rather than imported — an adapter may only lean on pure utility
32
+ * modules — and pinned to the original by a test in
33
+ * test/adapter-better-sidebar.test.cjs, which fails the pair apart when
34
+ * either side moves. */
35
+ const RELAY_UPLOAD_CAP_BYTES = 100 * 1024 * 1024
36
+
37
+ /** better-sidebar's own JSON body bound (lib/index.js:863
38
+ * `MAX_BODY_BYTES = 1 << 20`) — the adapter forwards at most what the
39
+ * backend would accept anyway. */
40
+ const SIDEBAR_MAX_BODY_BYTES = 1024 * 1024
41
+
42
+ /** The method name out of a `/sidebar/api/*` pathname, or `''`. */
43
+ const apiMethodOf = (pathname: string): string => sidebarMethodNameOf(pathname) ?? ''
44
+
45
+ export const dshBetterSidebarAdapter: BridgeAdapter = {
46
+ plugin: 'dsh-better-sidebar',
47
+ versions: ['0.25.0'],
48
+ routes: [
49
+ {
50
+ // The JSON API: the method name rides the path tail, the session
51
+ // fields ride the body top level (sessionId / rootSessionId /
52
+ // childId — the sidechat calls).
53
+ path: '/sidebar/api',
54
+ match: 'prefix',
55
+ methods: ['POST'],
56
+ maxBody: SIDEBAR_MAX_BODY_BYTES,
57
+ locator: { in: 'json-body', fields: ['sessionId', 'rootSessionId', 'childId'] },
58
+ deny: ({ pathname, currentSessionId }) => {
59
+ const name = apiMethodOf(pathname)
60
+ if (name === '') return undefined
61
+ // Opening apps/URLs on the HOST is a remote-panel no: refused when
62
+ // the browser is currently looking at a REMOTE session (the
63
+ // request body carries no session field of its own — client.js
64
+ // 9383-9392 — so the classify step could never tell). A LOCAL
65
+ // session's open.external never reaches here as a refusal and
66
+ // stays on the local machine, exactly as without zen-remote.
67
+ // (The sidechat methods left this hook in PB-13: their child
68
+ // sessions inherit the parent's share through `meta.parentSession`,
69
+ // so they forward like everything else.)
70
+ if (name === 'open.external' && currentSessionId !== undefined && isVirtual(currentSessionId)) {
71
+ return 'remote-open-unsupported'
72
+ }
73
+ return undefined
74
+ },
75
+ },
76
+ // The media route: better-sidebar registers a PREFIX, the adapter only
77
+ // claims the EXACT documented path — a sub-path (if a future version
78
+ // grows one) stays local rather than being guessed at.
79
+ { path: '/sidebar/file', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
80
+ // The archive download (lib/index.js:6949-6985).
81
+ { path: '/sidebar/archive', match: 'exact', methods: ['GET'], locator: { in: 'query', name: 'sessionId' } },
82
+ // The raw upload (lib/index.js:6905-6948): binary bytes, capped by the
83
+ // relay's own upload budget.
84
+ { path: '/sidebar/upload', match: 'exact', methods: ['POST'], maxBody: RELAY_UPLOAD_CAP_BYTES, locator: { in: 'query', name: 'sessionId' } },
85
+ // The HTML preview (lib/index.js:7026+): the session id is the FIRST
86
+ // path segment after the prefix; the rest of the segments are the
87
+ // preview file's relative path. Same-prefix relative resources ride
88
+ // along (they hit the same prefix route with the same segment layout).
89
+ { path: '/sidebar/html', match: 'prefix', methods: ['GET'], locator: { in: 'path-segment', index: 0 } },
90
+ // Lazy chunks: client code, session-independent, LOCAL forever.
91
+ { path: '/sidebar/bundle', match: 'prefix', methods: ['GET'], locator: { in: 'query', name: 'sessionId' }, local: true },
92
+ // The two WebSockets (PB-13, both `registerUpgrade` EXACT registrations
93
+ // in lib/index.js:7066-7091): the agent-opens push feed and the
94
+ // file-tree watch. Both carry the session id in the query; both ride
95
+ // PB-10's byte tunnel untouched — neither side parses frames, and the
96
+ // messages need no rewriting (agent-opens pushes carry the server-real
97
+ // sessionId but the browser never reads it — client.js:23618-23640
98
+ // uses its own connect-time id for the scope; fs-watch frames carry
99
+ // file paths, not session ids).
100
+ { path: '/sidebar/ws/agent-opens', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
101
+ { path: '/sidebar/ws/fs-watch', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
102
+ ],
103
+ client: {
104
+ request: (ctx) => {
105
+ const name = apiMethodOf(ctx.pathname)
106
+ // Belt over the deny hook's suspenders: a refused method refuses
107
+ // even if a future plugin version starts carrying session fields
108
+ // on it (which would flip it into the remote branch).
109
+ if (name === 'open.external') return { allow: false, reason: 'remote-open-unsupported' }
110
+ // Unknown method: refuse — never guess what a drifted version means.
111
+ if (name !== '' && SIDEBAR_METHODS[name] === undefined) return { allow: false, reason: 'adapter-method-unknown' }
112
+ return { allow: true }
113
+ },
114
+ response: (ctx) => {
115
+ const name = apiMethodOf(ctx.pathname)
116
+ if (name === '') return ctx.body
117
+ const spec = SIDEBAR_METHODS[name]
118
+ if (spec === undefined || spec.rewrite === undefined) return ctx.body
119
+ return spec.rewrite(ctx.body, ctx)
120
+ },
121
+ },
122
+ server: {
123
+ request: (ctx) => {
124
+ // Strip the client-supplied cwd: the backend resolves the working
125
+ // directory from the (already validated) session id's own header,
126
+ // falling back to the persisted record — a remote panel never
127
+ // steers the server by path.
128
+ if (ctx.pathname === '/sidebar/file' || ctx.pathname === '/sidebar/upload') {
129
+ if (ctx.query.get('cwd') !== null) {
130
+ const stripped = new URLSearchParams(ctx.query)
131
+ stripped.delete('cwd')
132
+ return { allow: true, query: stripped }
133
+ }
134
+ return { allow: true }
135
+ }
136
+ if (ctx.route.path === '/sidebar/api' && ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body) && 'cwd' in (ctx.body as Record<string, unknown>)) {
137
+ const stripped = { ...(ctx.body as Record<string, unknown>) }
138
+ delete stripped.cwd
139
+ return { allow: true, body: stripped }
140
+ }
141
+ return { allow: true }
142
+ },
143
+ },
144
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * better-sidebar 0.25.0 的 `/sidebar/api` 方法表(PB-12)——适配器唯一
3
+ * 认识的接口清单,逐条对着 `/tmp/bs-versions/0.25.0/lib/index.js` 复核
4
+ * (行号见各条注释,以 0.25.0 的构建产物为准)。表里没有的方法在远程
5
+ * 会话里一律拒绝(reason `adapter-method-unknown`):宁可少转发,不可
6
+ * 误转发——`test/adapter-better-sidebar.test.cjs` 会把源码里的实际方法
7
+ * 名集合提取出来与这张表逐一比对,防止插件升级后漏记。
8
+ *
9
+ * 会话字段:绝大多数方法在 JSON 体顶层带 `sessionId`(client.js 的
10
+ * scopePayload,lib/client.js:3866);子代理与工作流面板带
11
+ * `rootSessionId`;sidechat 的后续调用带 `childId`(本单一律拒绝)。
12
+ *
13
+ * 响应里带会话 id 的位置(核实于 0.25.0 源码,全部只有三处):
14
+ * - `session.cwd` 把请求的 `sessionId` 原样回显(lib/index.js:6327);
15
+ * - `subagents.live` 的响应以会话 id 为键(lib/index.js:4513
16
+ * `live[sessionId] = view`,根会话与全部子会话);
17
+ * - `workflows.list` 的每行 `originSessionId`(lib/index.js:4666)与
18
+ * `phases[].members[].childId`(lib/index.js:4686)。
19
+ * `teams.taskCreate`/`taskUpdate` 的响应是 `{ok, value:<任务记录>}`
20
+ * (lib/index.js:4607-4612、4650-4655),任务记录不带会话 id;
21
+ * `changes.ops` 返回 `{events, lastSeq}`,事件属于该会话自身的流
22
+ * (tool/call、tool/result),对象上没有会话 id 字段;
23
+ * `archive.build` 返回 `{id, entries}`(lib/index.js:3470-3473)、
24
+ * `archive.status` 返回 `{state, done, total, bytes, error?}`
25
+ * (lib/index.js:3479-3485)——都不带会话 id。这四类不改写。
26
+ * `sidechat.start` 返回 `{childId}`(lib/index.js:5050)——PB-13 起
27
+ * 虚拟化(子客户端拿它发后续调用)。
28
+ */
29
+
30
+ /** 响应改写器的上下文(框架的 AdapterResponseContext 的结构性子集)。 */
31
+ export interface SidebarRewriteContext {
32
+ virtualize(id: string): string
33
+ isVirtual(value: unknown): boolean
34
+ }
35
+
36
+ /** 一个 `/sidebar/api` 方法的处置。 */
37
+ export interface SidebarMethodSpec {
38
+ /** 该方法在 JSON 体顶层的会话字段名(settings.* 无会话字段,缺省)。 */
39
+ sessionField?: 'sessionId' | 'rootSessionId' | 'childId'
40
+ /** 读还是写(写方法在远程会话里执行的是主服务端的磁盘与 git)。 */
41
+ kind: 'read' | 'write'
42
+ /** 无会话数据的方法(settings.*):定位结果为空,天然留本机。 */
43
+ local?: true
44
+ /** 响应改写器:把响应里的真实会话 id 换回虚拟 id。 */
45
+ rewrite?: (body: unknown, ctx: SidebarRewriteContext) => unknown
46
+ }
47
+
48
+ /** 无改写的普通转发方法的简写工厂。 */
49
+ const forward = (kind: 'read' | 'write', sessionField: 'sessionId' | 'rootSessionId' | 'childId' = 'sessionId'): SidebarMethodSpec => ({ kind, sessionField })
50
+
51
+ /** 0.25.0 的全部方法。 */
52
+ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
53
+ // ---- 会话(lib/index.js:6319-6333)----
54
+ 'session.phase': forward('read'),
55
+ 'session.cwd': {
56
+ kind: 'read',
57
+ sessionField: 'sessionId',
58
+ // 请求的 sessionId 原样回显(lib/index.js:6327)。
59
+ rewrite: (body, ctx) => rewriteTopLevel(body, 'sessionId', ctx),
60
+ },
61
+
62
+ // ---- 文件(lib/index.js:6334-6442)----
63
+ 'fs.tree': forward('read'),
64
+ 'fs.trees': forward('read'),
65
+ 'fs.search': forward('read'),
66
+ 'fs.read': forward('read'),
67
+ 'fs.write': forward('write'),
68
+ 'fs.rename': forward('write'),
69
+ 'fs.mkdir': forward('write'),
70
+ 'fs.createFile': forward('write'),
71
+ 'fs.remove': forward('write'),
72
+
73
+ // ---- git(lib/index.js:6443-6613)----
74
+ 'git.worktrees': forward('read'),
75
+ 'git.status': forward('read'),
76
+ 'git.diff': forward('read'),
77
+ 'git.diff-head': forward('read'),
78
+ 'git.branch': forward('read'),
79
+ 'git.log': forward('read'),
80
+ 'git.commit-diff': forward('read'),
81
+ 'git.show': forward('read'),
82
+ 'git.blame': forward('read'),
83
+ 'git.stage': forward('write'),
84
+ 'git.unstage': forward('write'),
85
+ 'git.commit': forward('write'),
86
+ 'git.push': forward('write'),
87
+ 'git.pull': forward('write'),
88
+ 'git.checkout': forward('write'),
89
+ 'git.discard': forward('write'),
90
+ 'git.revert': forward('write'),
91
+ 'git.cherry-pick': forward('write'),
92
+ // 提交信息生成:消耗主服务端的模型额度(产品决定:跟随会话,接)。
93
+ 'git.suggest-message': forward('write'),
94
+ // 模型目录:sessionId 可选(lib/index.js:6573-6602、6614-6626)。
95
+ 'git.commit-model': forward('read'),
96
+ 'git.models': forward('read'),
97
+
98
+ // ---- 变更流(lib/index.js:6627-6649)----
99
+ // 事件对象属于该会话自身的流,不带会话 id —— 不改写。
100
+ 'changes.ops': forward('read'),
101
+
102
+ // ---- 子代理 / 工作流 / 团队(lib/index.js:4482-4662、4770-4789)----
103
+ 'subagents.live': {
104
+ kind: 'read',
105
+ sessionField: 'rootSessionId',
106
+ // 响应的 live 映射以会话 id 为键(lib/index.js:4513)。
107
+ rewrite: (body, ctx) => {
108
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
109
+ const live = (body as Record<string, unknown>).live
110
+ if (live === null || typeof live !== 'object' || Array.isArray(live)) return body
111
+ const rewritten: Record<string, unknown> = {}
112
+ for (const [id, view] of Object.entries(live as Record<string, unknown>)) {
113
+ rewritten[ctx.isVirtual(id) ? id : ctx.virtualize(id)] = view
114
+ }
115
+ return { ...(body as Record<string, unknown>), live: rewritten }
116
+ },
117
+ },
118
+ 'workflows.list': {
119
+ kind: 'read',
120
+ sessionField: 'rootSessionId',
121
+ // 每行的 originSessionId(4666)与 members[].childId(4686)。
122
+ rewrite: (body, ctx) => {
123
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
124
+ const runs = (body as Record<string, unknown>).runs
125
+ if (!Array.isArray(runs)) return body
126
+ return {
127
+ ...(body as Record<string, unknown>),
128
+ runs: runs.map((run) => rewriteWorkflowRun(run, ctx)),
129
+ }
130
+ },
131
+ },
132
+ // 响应是 {ok, value:<任务记录>},不带会话 id —— 不改写。
133
+ 'teams.taskCreate': forward('write', 'rootSessionId'),
134
+ 'teams.taskUpdate': forward('write', 'rootSessionId'),
135
+
136
+ // ---- 归档(lib/index.js:6663-6686;响应不带会话 id)----
137
+ 'archive.build': forward('write'),
138
+ 'archive.status': forward('read'),
139
+
140
+ // ---- 设置(lib/index.js:6666-6682)----
141
+ // 无会话字段:定位结果为空,远程判定天然放行本机 —— 表里登记只为
142
+ // 文档完整与「未知方法」比对(真实存在,只是不转发)。
143
+ 'settings.get': { kind: 'read', local: true },
144
+ 'settings.update': { kind: 'write', local: true },
145
+
146
+ // ---- 外部打开(lib/index.js:6683-6687)----
147
+ // 在宿主机起进程:远程会话拒绝(适配器路由的 deny 钩子按「浏览器当前
148
+ // 会话是虚拟 id」拒绝;这里登记是让方法比对认识它)。请求体不带会话
149
+ // 字段(client.js:9383-9392 只发 action/path/url)。
150
+ 'open.external': { kind: 'write', sessionField: 'sessionId' },
151
+
152
+ // ---- 侧聊(lib/index.js:4971-5140,buildSidechatApi)----
153
+ // PB-13 起转发:sidechat 的子会话带 `meta.parentSession = 调用会话 id`
154
+ // (lib/index.js:5012),zen-remote 的共享按祖先链判定,所以侧聊线程
155
+ // 自动继承父会话的共享——闭环是 start 的响应 childId 换成虚拟 id,
156
+ // 浏览器拿它发后续调用时经体定位回本适配器,服务端按继承校验放行。
157
+ 'sidechat.start': {
158
+ kind: 'write',
159
+ sessionField: 'sessionId',
160
+ // 响应 `{ childId }`(lib/index.js:5050)——服务端真实 id 换虚拟。
161
+ rewrite: (body, ctx) => rewriteTopLevel(body, 'childId', ctx),
162
+ },
163
+ 'sidechat.prompt': forward('write', 'childId'),
164
+ 'sidechat.cancel': forward('write', 'childId'),
165
+ 'sidechat.dispose': forward('write', 'childId'),
166
+ 'sidechat.info': forward('read', 'childId'),
167
+ 'sidechat.events': forward('read', 'childId'),
168
+ }
169
+
170
+ /** `/sidebar/api` 的路径前缀(方法名跟在后面,lib/index.js:6888)。 */
171
+ export const SIDEBAR_API_PREFIX = '/sidebar/api/'
172
+
173
+ /** 从请求 pathname 取出 API 方法名(`/sidebar/api/fs.tree` → `fs.tree`)。 */
174
+ export function sidebarMethodNameOf(pathname: string): string | undefined {
175
+ if (!pathname.startsWith(SIDEBAR_API_PREFIX)) return undefined
176
+ const name = pathname.slice(SIDEBAR_API_PREFIX.length)
177
+ if (name === '' || name.includes('/')) return undefined
178
+ return name
179
+ }
180
+
181
+ /** 顶层字段的虚拟化改写(session.cwd 的形状)。 */
182
+ function rewriteTopLevel(body: unknown, field: string, ctx: SidebarRewriteContext): unknown {
183
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
184
+ const record = body as Record<string, unknown>
185
+ const value = record[field]
186
+ if (typeof value !== 'string' || value === '' || ctx.isVirtual(value)) return body
187
+ return { ...record, [field]: ctx.virtualize(value) }
188
+ }
189
+
190
+ /** 一行工作流运行的改写(originSessionId + phases[].members[].childId)。 */
191
+ function rewriteWorkflowRun(run: unknown, ctx: SidebarRewriteContext): unknown {
192
+ if (run === null || typeof run !== 'object' || Array.isArray(run)) return run
193
+ const record = run as Record<string, unknown>
194
+ const rewritten: Record<string, unknown> = { ...record }
195
+ const origin = record.originSessionId
196
+ if (typeof origin === 'string' && origin !== '' && !ctx.isVirtual(origin)) rewritten.originSessionId = ctx.virtualize(origin)
197
+ if (Array.isArray(record.phases)) {
198
+ rewritten.phases = record.phases.map((phase) => {
199
+ if (phase === null || typeof phase !== 'object' || Array.isArray(phase)) return phase
200
+ const phaseRecord = phase as Record<string, unknown>
201
+ if (!Array.isArray(phaseRecord.members)) return phase
202
+ return {
203
+ ...phaseRecord,
204
+ members: phaseRecord.members.map((member) => {
205
+ if (member === null || typeof member !== 'object' || Array.isArray(member)) return member
206
+ const memberRecord = member as Record<string, unknown>
207
+ const childId = memberRecord.childId
208
+ if (typeof childId !== 'string' || childId === '' || ctx.isVirtual(childId)) return member
209
+ return { ...memberRecord, childId: ctx.virtualize(childId) }
210
+ }),
211
+ }
212
+ })
213
+ }
214
+ return rewritten
215
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The adapter registry — the ONE door between `src/adapters/` and the rest
3
+ * of the plugin. Every adapter implementation lives in its own
4
+ * subdirectory (`src/adapters/<plugin-name>/`), is imported here, and adds
5
+ * exactly one element to the array. Nothing outside `src/index.ts` (the
6
+ * wiring) may import from this directory, and no file anywhere may import
7
+ * an adapter subdirectory — both pinned by test/adapters-pluggable.test.cjs.
8
+ * Deleting this whole directory leaves the plugin fully working: the wiring
9
+ * parameter defaults to `[]`.
10
+ *
11
+ * PB-11 ships the FRAMEWORK only: the array stays EMPTY, the first real
12
+ * adapter (better-sidebar) arrives with PB-12.
13
+ */
14
+
15
+ import type { BridgeAdapter } from './types.js'
16
+ import { dshBetterSidebarAdapter } from './dsh-better-sidebar/index.js'
17
+
18
+ /** Every adapter zen-remote ships, in registration order. */
19
+ export const ADAPTERS: readonly BridgeAdapter[] = [dshBetterSidebarAdapter]
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The adapter-author face of the plugin bridge's adapter framework
3
+ * (PB-11). An adapter is zen-remote-internal code for ONE third-party
4
+ * plugin at its EXACT version: it declares which of that plugin's routes
5
+ * carry a session id and WHERE, and — optionally — hooks the two ends to
6
+ * refuse, strip or rewrite what the pure id relocation cannot express.
7
+ * Everything here sits on the framework shapes from
8
+ * `../plugin-adapter-core.js`; the framework itself never imports this
9
+ * file (only `src/index.ts` imports the ADAPTERS array from
10
+ * `./adapters/index.js`), which is what makes the whole directory
11
+ * removable without touching any other feature.
12
+ *
13
+ * An adapter implementation may import `types.ts` and pure utilities such
14
+ * as `../virtual-id.js` — nothing else.
15
+ */
16
+
17
+ import type { AdapterRoute, AdapterRequestContext, AdapterResponseContext, AdapterVerdict } from '../plugin-adapter-core.js'
18
+
19
+ export type { AdapterRoute, AdapterRequestContext, AdapterResponseContext, AdapterVerdict }
20
+
21
+ /** One pluggable adapter. `versions` lists the EXACT plugin versions this
22
+ * adapter was written against — the installed version must be among them
23
+ * or the adapter does not register at all (a best-guess rewrite against a
24
+ * moving schema is how ids get corrupted). A plugin that registers its OWN
25
+ * cooperative declaration always wins; the adapter yields. */
26
+ export interface BridgeAdapter {
27
+ /** The target plugin's npm package name. */
28
+ plugin: string
29
+ /** The checked exact versions; an installed version outside this list
30
+ * means "not adapted". */
31
+ versions: readonly string[]
32
+ routes: readonly AdapterRoute[]
33
+ /** The sub-client half: rewrite or refuse on the way OUT to the relay,
34
+ * and map the answer's real session ids back to virtual form. */
35
+ client?: {
36
+ request?(ctx: AdapterRequestContext): AdapterVerdict
37
+ response?(ctx: AdapterResponseContext): unknown
38
+ }
39
+ /** The main-server half: refuse or strip (e.g. a `cwd` field) before
40
+ * the loopback reaches the plugin. */
41
+ server?: {
42
+ request?(ctx: AdapterRequestContext): AdapterVerdict
43
+ }
44
+ }
@@ -41,6 +41,7 @@ import { request as httpRequest } from 'node:http'
41
41
  import type { IncomingMessage, ServerResponse } from 'node:http'
42
42
  import type { ConfigSource, ZenRemoteConfig } from './config.js'
43
43
  import { responseJson, sameOriginPost } from './http.js'
44
+ import type { BridgeRegistry } from './plugin-bridge.js'
44
45
  import type { RelayGateway } from './relay-server.js'
45
46
  import { restoreBusy } from './share-ops.js'
46
47
  import type { AgentStatusLike } from './share-ops.js'
@@ -66,6 +67,10 @@ export const ADMIN_PUSH_TEST_ROUTE = `${ADMIN_ROUTE_PREFIX}/push-test`
66
67
  * POST `{action, sessionId?}`: share / unshare / unshare-all (T33a). */
67
68
  export const ADMIN_SHARES_ROUTE = `${ADMIN_ROUTE_PREFIX}/shares`
68
69
 
70
+ /** GET: the plugin bridge's registered plugins with their whole-machine-data
71
+ * consent, for the settings page's 插件桥 section (PB-5). */
72
+ export const ADMIN_PLUGIN_BRIDGE_ROUTE = `${ADMIN_ROUTE_PREFIX}/plugin-bridge`
73
+
69
74
  /** Longest wait for one gateway round-trip. */
70
75
  const GATEWAY_TIMEOUT_MS = 5000
71
76
 
@@ -161,6 +166,18 @@ export interface AdminHandlerOptions {
161
166
  * durable header through it to judge subagent sessions; absent (or a
162
167
  * failing read) falls back to the projection identity. */
163
168
  sessionQuery?: () => SessionQueryLike | undefined
169
+ /** The plugin bridge's server half (PB-5): the declaration registry and
170
+ * the LIVE consent reader backing the plugin-bridge listing. Absent — a
171
+ * composition that never built the registry — and the route answers 404
172
+ * like any unknown admin path. */
173
+ pluginBridge?: {
174
+ registry: BridgeRegistry
175
+ getConsent: () => Record<string, boolean>
176
+ /** The plugins an ADAPTER currently stands in for (PB-11), read live
177
+ * per listing — a plugin that registered its own declaration stops
178
+ * being marked. Absent on a wiring that predates adapters. */
179
+ adaptedPlugins?: () => ReadonlySet<string>
180
+ }
164
181
  }
165
182
 
166
183
  /** The slice of the sessionQuery service the share action reads (RT
@@ -574,6 +591,53 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
574
591
  responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
575
592
  return
576
593
  }
594
+ if (route === ADMIN_PLUGIN_BRIDGE_ROUTE) {
595
+ // No registry, no route: a composition that never built the plugin
596
+ // bridge answers like any unknown admin path.
597
+ if (options.pluginBridge === undefined) {
598
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown admin route' } })
599
+ return
600
+ }
601
+ if (method !== 'GET') {
602
+ res.setHeader('Allow', 'GET')
603
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
604
+ return
605
+ }
606
+ // Wall 2, the pair/action rule: this listing feeds a MANAGEMENT
607
+ // surface (the consent toggles below it), so a request that arrived
608
+ // through the gateway is refused — consent is granted on the server
609
+ // machine, by its owner, period.
610
+ if (req.headers['x-zen-remote-via'] !== undefined) {
611
+ responseJson(res, 403, {
612
+ ok: false,
613
+ error: { code: 'via-gateway', message: '插件桥设置只能在服务端本机操作' },
614
+ })
615
+ return
616
+ }
617
+ // The consent source is a live row read; a broken one reads as the
618
+ // empty map (nothing consented) instead of failing the listing.
619
+ let consent: Record<string, boolean> = {}
620
+ try {
621
+ consent = options.pluginBridge.getConsent()
622
+ } catch {
623
+ consent = {}
624
+ }
625
+ // registry.list() arrives plugin-sorted; the map keeps that order.
626
+ // PB-11: `adapted` marks the entries an ADAPTER stands in for —
627
+ // zen-remote-internal forwarding for a plugin that never registered
628
+ // anything — so the settings page can say「由 zen-remote 适配」.
629
+ // Absent (a wiring that predates adapters) reads as none.
630
+ const adapted = options.pluginBridge.adaptedPlugins?.() ?? new Set<string>()
631
+ const plugins = options.pluginBridge.registry.list().map((declaration) => ({
632
+ plugin: declaration.plugin,
633
+ version: declaration.version,
634
+ hasServerScope: declaration.routes.some((route) => route.scope === 'server'),
635
+ consent: consent[declaration.plugin] === true,
636
+ adapted: adapted.has(declaration.plugin),
637
+ }))
638
+ responseJson(res, 200, { ok: true, value: { plugins } })
639
+ return
640
+ }
577
641
  if (route === ADMIN_PAIR_ROUTE || route === ADMIN_ACTION_ROUTE || route === ADMIN_PUSH_TEST_ROUTE) {
578
642
  if (method !== 'POST') {
579
643
  res.setHeader('Allow', 'POST')
@@ -2,6 +2,8 @@ import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
2
2
  import { mainSessionIdOf } from '../compat/types.ts'
3
3
  import { isVirtual } from '../../virtual-id.ts'
4
4
  import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
5
+ import { BRIDGE_STAMP_ATTRIBUTE, BRIDGE_STAMP_PLUGIN, getRemoteStatusStore, shouldStampBridgeAttribute, subscribeIfVirtual } from '../../client-data/remote-status.ts'
6
+ import { en, zh } from '../locales.ts'
5
7
 
6
8
  /**
7
9
  * The remote-session marker (T41b): the CURRENT main-view session id, read
@@ -25,6 +27,29 @@ import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
25
27
  * attribute must track the current session regardless of which panel is
26
28
  * mounted, and an attribute + stylesheet survives every re-render untouched.
27
29
  */
30
+
31
+ /** The CURRENT main-view session id, the publishMain way (see the module
32
+ * comment). Shared by the session marker and the bridge stamp — both key on
33
+ * the same "which session is on screen" answer. */
34
+ function currentMainSessionIdOf(ctx: ClientContext): string | undefined {
35
+ const { byId } = ctx.sessions.list.getSnapshot()
36
+ const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown } } } | undefined
37
+ const current = uiSession?.current?.value?.key
38
+ const rows = byId as Readonly<Record<string, MobileSessionRow>>
39
+ return typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
40
+ ? current
41
+ : mainSessionIdOf(byId)
42
+ }
43
+
44
+ /** The unbridged note copy in the BROWSER's language (the plugin's own
45
+ * namespace carries both dictionaries; the framework `t` seat is not
46
+ * reachable from a non-React effect, so the navigator's language picks —
47
+ * `zh*` reads Chinese, everything else English). */
48
+ function bridgeNoteOf(language: string | undefined): string {
49
+ const key = 'remoteBridgeUnbridgedNote' as const
50
+ return typeof language === 'string' && language.startsWith('zh') ? zh[key] : en[key]
51
+ }
52
+
28
53
  export function installRemoteSessionGuard(ctx: ClientContext): void {
29
54
  ctx.effect(() => {
30
55
  const tag = document.createElement('style')
@@ -39,16 +64,7 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
39
64
 
40
65
  ctx.effect(() => {
41
66
  const apply = (): void => {
42
- const { byId } = ctx.sessions.list.getSnapshot()
43
- // publishMain's tie-break (see the module comment): prefer the still-
44
- // retained current main binding, else the first mainView row.
45
- const uiSession = ctx.get('uiSession') as { current?: { value?: { key?: unknown }, subscribe?: (listener: () => void) => () => void } } | undefined
46
- const current = uiSession?.current?.value?.key
47
- const rows = byId as Readonly<Record<string, MobileSessionRow>>
48
- const sessionId =
49
- typeof current === 'string' && (rows[current]?.retainedBy?.mainView ?? 0) > 0
50
- ? current
51
- : mainSessionIdOf(byId)
67
+ const sessionId = currentMainSessionIdOf(ctx)
52
68
  const root = document.documentElement
53
69
  if (sessionId !== undefined && isVirtual(sessionId)) root.setAttribute('data-zr-remote-session', '1')
54
70
  else root.removeAttribute('data-zr-remote-session')
@@ -65,4 +81,60 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
65
81
  document.documentElement.removeAttribute('data-zr-remote-session')
66
82
  }
67
83
  }, 'dsh-zen-remote: remote-session attribute')
84
+
85
+ // The bridge stamp (PB-14): while a REMOTE session is on screen, the
86
+ // remote-status poll's `bridgedPlugins` decides whether better-sidebar is
87
+ // bridged RIGHT NOW — bridged, the T53 hiding rules lift (the panels read
88
+ // the server's data through the adapter) and the html carries
89
+ // `data-zr-bridge-dsh-better-sidebar="1"`; not bridged, the rules stay
90
+ // and the html carries the unbridged note copy as a CSS variable the
91
+ // stylesheet spends on the empty tab body. The subscription rides
92
+ // subscribeIfVirtual — a LOCAL session (or a host page) never polls, and
93
+ // the attribute comes off; better-sidebar not installed or the server not
94
+ // paired simply never appear in `bridgedPlugins`, so the stamp never
95
+ // fires from this side alone.
96
+ ctx.effect(() => {
97
+ const root = document.documentElement
98
+ const store = getRemoteStatusStore()
99
+ let offStore: (() => void) | undefined
100
+ const clear = (): void => {
101
+ root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
102
+ root.style.removeProperty('--zr-bridge-note')
103
+ }
104
+ const applyBridge = (): void => {
105
+ const view = store.getSnapshot().view
106
+ if (shouldStampBridgeAttribute(view?.bridgedPlugins, BRIDGE_STAMP_PLUGIN)) {
107
+ root.setAttribute(BRIDGE_STAMP_ATTRIBUTE, '1')
108
+ root.style.removeProperty('--zr-bridge-note')
109
+ } else {
110
+ root.removeAttribute(BRIDGE_STAMP_ATTRIBUTE)
111
+ // JSON.stringify's quotes are a legal CSS string — the stylesheet's
112
+ // ::before spends it as `content: var(--zr-bridge-note)`.
113
+ root.style.setProperty('--zr-bridge-note', JSON.stringify(bridgeNoteOf(navigator.language)))
114
+ }
115
+ }
116
+ const retune = (): void => {
117
+ const sessionId = currentMainSessionIdOf(ctx)
118
+ const virtualNow = sessionId !== undefined && isVirtual(sessionId)
119
+ if (virtualNow && offStore === undefined && typeof sessionId === 'string') {
120
+ offStore = subscribeIfVirtual(store, sessionId, applyBridge)
121
+ applyBridge()
122
+ return
123
+ }
124
+ if (!virtualNow && offStore !== undefined) {
125
+ offStore()
126
+ offStore = undefined
127
+ clear()
128
+ }
129
+ }
130
+ retune()
131
+ const offs: Array<() => void> = [ctx.sessions.list.subscribe(retune)]
132
+ const current = (ctx.get('uiSession') as { current?: { subscribe?: (listener: () => void) => () => void } } | undefined)?.current
133
+ if (typeof current?.subscribe === 'function') offs.push(current.subscribe(retune))
134
+ return () => {
135
+ for (const off of offs) off()
136
+ offStore?.()
137
+ clear()
138
+ }
139
+ }, 'dsh-zen-remote: bridge stamp')
68
140
  }