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/activity.ts CHANGED
@@ -160,6 +160,38 @@ export function createParentIndex(): ParentIndex {
160
160
  }
161
161
  }
162
162
 
163
+ /** One persisted-session snapshot as `SessionPersistence.list()` hands it
164
+ * back: the detached header is all the parent index needs (`id`,
165
+ * `origin: 'subagent'`, `parentSession` — dsh-session's SessionHeader).
166
+ * Structural on purpose: the service's full snapshot carries revision and
167
+ * sizes we do not care about. */
168
+ export interface PersistedSessionSnapshot {
169
+ header?: { id?: unknown; origin?: unknown; parentSession?: unknown } | undefined
170
+ }
171
+
172
+ /**
173
+ * Seed a parent index from persisted-session snapshots (2026-10-13 fix):
174
+ * the index otherwise rebuilds only from LIVE `session/event` traffic, so
175
+ * after a host restart every already-stored sidechat / subagent child lost
176
+ * its parent link and the relay answered `not-shared` for sessions whose
177
+ * share it used to inherit. Feeding `SessionPersistence.list()`'s snapshots
178
+ * through `observe` restores the chain; it reads headers only, never event
179
+ * logs. Returns how many parent links the pass recorded.
180
+ */
181
+ export function seedParentIndexFromSnapshots(index: ParentIndex, snapshots: readonly PersistedSessionSnapshot[]): number {
182
+ let recorded = 0
183
+ for (const snapshot of snapshots) {
184
+ const header = snapshot?.header
185
+ if (header?.origin !== 'subagent') continue
186
+ const id = header.id
187
+ if (typeof id !== 'string') continue
188
+ const isNew = index.parentOf(id) === undefined
189
+ index.observe({ id, header })
190
+ if (isNew && index.parentOf(id) !== undefined) recorded += 1
191
+ }
192
+ return recorded
193
+ }
194
+
163
195
  /** Duck-typed timer halves: Node's setInterval/clearInterval at runtime,
164
196
  * fakes in tests. Loose on purpose — the real NodeJS.Timeout type would
165
197
  * fight the contravariant `clearInterval` parameter under strictFunctionTypes. */
@@ -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,244 @@
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
+ * 信封:`/sidebar/api` 的成功响应**全部**是 `{ok:true, value:<handler
30
+ * 返回值>}`(lib/index.js:889-896 的 `writeOk`),上面引用的行号都是
31
+ * handler 层、不是线上形状——响应改写器一律经 `rewriteEnveloped` 先剥
32
+ * 信封再改写再包回去(2026-10-13 修复:sidechat.start 的 childId 曾在
33
+ * 顶层找不到而漏虚拟化,浏览器拿真实 id 发后续调用被判本地、落到子
34
+ * 客户端本机报 session not found)。
35
+ */
36
+
37
+ /** 响应改写器的上下文(框架的 AdapterResponseContext 的结构性子集)。 */
38
+ export interface SidebarRewriteContext {
39
+ virtualize(id: string): string
40
+ isVirtual(value: unknown): boolean
41
+ }
42
+
43
+ /** 改写信封内的 value(信封外层的 ok 等字段原样保留)。 */
44
+ type SidebarInnerRewrite = (value: unknown, ctx: SidebarRewriteContext) => unknown
45
+
46
+ /** 剥 `{ok:true, value}` 信封 → 改写 value → 包回去;无信封原样改写。
47
+ * `writeOk`(lib/index.js:889)给每个 /sidebar/api 成功响应都包了这层。 */
48
+ function rewriteEnveloped(body: unknown, inner: SidebarInnerRewrite, ctx: SidebarRewriteContext): unknown {
49
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
50
+ const record = body as Record<string, unknown>
51
+ if (record.ok === true && record.value !== null && typeof record.value === 'object' && !Array.isArray(record.value)) {
52
+ return { ...record, value: inner(record.value, ctx) }
53
+ }
54
+ }
55
+ return inner(body, ctx)
56
+ }
57
+
58
+ /** 一个 `/sidebar/api` 方法的处置。 */
59
+ export interface SidebarMethodSpec {
60
+ /** 该方法在 JSON 体顶层的会话字段名(settings.* 无会话字段,缺省)。 */
61
+ sessionField?: 'sessionId' | 'rootSessionId' | 'childId'
62
+ /** 读还是写(写方法在远程会话里执行的是主服务端的磁盘与 git)。 */
63
+ kind: 'read' | 'write'
64
+ /** 无会话数据的方法(settings.*):定位结果为空,天然留本机。 */
65
+ local?: true
66
+ /** 响应改写器:把响应里的真实会话 id 换回虚拟 id。 */
67
+ rewrite?: (body: unknown, ctx: SidebarRewriteContext) => unknown
68
+ }
69
+
70
+ /** 无改写的普通转发方法的简写工厂。 */
71
+ const forward = (kind: 'read' | 'write', sessionField: 'sessionId' | 'rootSessionId' | 'childId' = 'sessionId'): SidebarMethodSpec => ({ kind, sessionField })
72
+
73
+ /** 0.25.0 的全部方法。 */
74
+ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
75
+ // ---- 会话(lib/index.js:6319-6333)----
76
+ 'session.phase': forward('read'),
77
+ 'session.cwd': {
78
+ kind: 'read',
79
+ sessionField: 'sessionId',
80
+ // 请求的 sessionId 原样回显(lib/index.js:6327)。
81
+ rewrite: (body, ctx) => rewriteEnveloped(body, (v) => rewriteTopLevel(v, 'sessionId', ctx), ctx),
82
+ },
83
+
84
+ // ---- 文件(lib/index.js:6334-6442)----
85
+ 'fs.tree': forward('read'),
86
+ 'fs.trees': forward('read'),
87
+ 'fs.search': forward('read'),
88
+ 'fs.read': forward('read'),
89
+ 'fs.write': forward('write'),
90
+ 'fs.rename': forward('write'),
91
+ 'fs.mkdir': forward('write'),
92
+ 'fs.createFile': forward('write'),
93
+ 'fs.remove': forward('write'),
94
+
95
+ // ---- git(lib/index.js:6443-6613)----
96
+ 'git.worktrees': forward('read'),
97
+ 'git.status': forward('read'),
98
+ 'git.diff': forward('read'),
99
+ 'git.diff-head': forward('read'),
100
+ 'git.branch': forward('read'),
101
+ 'git.log': forward('read'),
102
+ 'git.commit-diff': forward('read'),
103
+ 'git.show': forward('read'),
104
+ 'git.blame': forward('read'),
105
+ 'git.stage': forward('write'),
106
+ 'git.unstage': forward('write'),
107
+ 'git.commit': forward('write'),
108
+ 'git.push': forward('write'),
109
+ 'git.pull': forward('write'),
110
+ 'git.checkout': forward('write'),
111
+ 'git.discard': forward('write'),
112
+ 'git.revert': forward('write'),
113
+ 'git.cherry-pick': forward('write'),
114
+ // 提交信息生成:消耗主服务端的模型额度(产品决定:跟随会话,接)。
115
+ 'git.suggest-message': forward('write'),
116
+ // 模型目录:sessionId 可选(lib/index.js:6573-6602、6614-6626)。
117
+ 'git.commit-model': forward('read'),
118
+ 'git.models': forward('read'),
119
+
120
+ // ---- 变更流(lib/index.js:6627-6649)----
121
+ // 事件对象属于该会话自身的流,不带会话 id —— 不改写。
122
+ 'changes.ops': forward('read'),
123
+
124
+ // ---- 子代理 / 工作流 / 团队(lib/index.js:4482-4662、4770-4789)----
125
+ 'subagents.live': {
126
+ kind: 'read',
127
+ sessionField: 'rootSessionId',
128
+ // 响应的 live 映射以会话 id 为键(lib/index.js:4513)。
129
+ rewrite: (body, ctx) => rewriteEnveloped(body, rewriteLiveKeys, ctx),
130
+ },
131
+ 'workflows.list': {
132
+ kind: 'read',
133
+ sessionField: 'rootSessionId',
134
+ // 每行的 originSessionId(4666)与 members[].childId(4686)。
135
+ rewrite: (body, ctx) => rewriteEnveloped(body, rewriteWorkflowsBody, ctx),
136
+ },
137
+ // 响应是 {ok, value:<任务记录>},不带会话 id —— 不改写。
138
+ 'teams.taskCreate': forward('write', 'rootSessionId'),
139
+ 'teams.taskUpdate': forward('write', 'rootSessionId'),
140
+
141
+ // ---- 归档(lib/index.js:6663-6686;响应不带会话 id)----
142
+ 'archive.build': forward('write'),
143
+ 'archive.status': forward('read'),
144
+
145
+ // ---- 设置(lib/index.js:6666-6682)----
146
+ // 无会话字段:定位结果为空,远程判定天然放行本机 —— 表里登记只为
147
+ // 文档完整与「未知方法」比对(真实存在,只是不转发)。
148
+ 'settings.get': { kind: 'read', local: true },
149
+ 'settings.update': { kind: 'write', local: true },
150
+
151
+ // ---- 外部打开(lib/index.js:6683-6687)----
152
+ // 在宿主机起进程:远程会话拒绝(适配器路由的 deny 钩子按「浏览器当前
153
+ // 会话是虚拟 id」拒绝;这里登记是让方法比对认识它)。请求体不带会话
154
+ // 字段(client.js:9383-9392 只发 action/path/url)。
155
+ 'open.external': { kind: 'write', sessionField: 'sessionId' },
156
+
157
+ // ---- 侧聊(lib/index.js:4971-5140,buildSidechatApi)----
158
+ // PB-13 起转发:sidechat 的子会话带 `meta.parentSession = 调用会话 id`
159
+ // (lib/index.js:5012),zen-remote 的共享按祖先链判定,所以侧聊线程
160
+ // 自动继承父会话的共享——闭环是 start 的响应 childId 换成虚拟 id,
161
+ // 浏览器拿它发后续调用时经体定位回本适配器,服务端按继承校验放行。
162
+ 'sidechat.start': {
163
+ kind: 'write',
164
+ sessionField: 'sessionId',
165
+ // 响应 `{ childId }`(lib/index.js:5050,外面还有 writeOk 信封)——
166
+ // 服务端真实 id 换虚拟。
167
+ rewrite: (body, ctx) => rewriteEnveloped(body, (v) => rewriteTopLevel(v, 'childId', ctx), ctx),
168
+ },
169
+ 'sidechat.prompt': forward('write', 'childId'),
170
+ 'sidechat.cancel': forward('write', 'childId'),
171
+ 'sidechat.dispose': forward('write', 'childId'),
172
+ 'sidechat.info': forward('read', 'childId'),
173
+ 'sidechat.events': forward('read', 'childId'),
174
+ }
175
+
176
+ /** `/sidebar/api` 的路径前缀(方法名跟在后面,lib/index.js:6888)。 */
177
+ export const SIDEBAR_API_PREFIX = '/sidebar/api/'
178
+
179
+ /** 从请求 pathname 取出 API 方法名(`/sidebar/api/fs.tree` → `fs.tree`)。 */
180
+ export function sidebarMethodNameOf(pathname: string): string | undefined {
181
+ if (!pathname.startsWith(SIDEBAR_API_PREFIX)) return undefined
182
+ const name = pathname.slice(SIDEBAR_API_PREFIX.length)
183
+ if (name === '' || name.includes('/')) return undefined
184
+ return name
185
+ }
186
+
187
+ /** 顶层字段的虚拟化改写(session.cwd 的形状)。 */
188
+ function rewriteTopLevel(body: unknown, field: string, ctx: SidebarRewriteContext): unknown {
189
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
190
+ const record = body as Record<string, unknown>
191
+ const value = record[field]
192
+ if (typeof value !== 'string' || value === '' || ctx.isVirtual(value)) return body
193
+ return { ...record, [field]: ctx.virtualize(value) }
194
+ }
195
+
196
+ /** subagents.live 的 value(信封内):live 映射的键是会话 id。 */
197
+ function rewriteLiveKeys(value: unknown, ctx: SidebarRewriteContext): unknown {
198
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value
199
+ const live = (value as Record<string, unknown>).live
200
+ if (live === null || typeof live !== 'object' || Array.isArray(live)) return value
201
+ const rewritten: Record<string, unknown> = {}
202
+ for (const [id, view] of Object.entries(live as Record<string, unknown>)) {
203
+ rewritten[ctx.isVirtual(id) ? id : ctx.virtualize(id)] = view
204
+ }
205
+ return { ...(value as Record<string, unknown>), live: rewritten }
206
+ }
207
+
208
+ /** workflows.list 的 value(信封内):runs[].originSessionId 等。 */
209
+ function rewriteWorkflowsBody(value: unknown, ctx: SidebarRewriteContext): unknown {
210
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value
211
+ const runs = (value as Record<string, unknown>).runs
212
+ if (!Array.isArray(runs)) return value
213
+ return {
214
+ ...(value as Record<string, unknown>),
215
+ runs: runs.map((run) => rewriteWorkflowRun(run, ctx)),
216
+ }
217
+ }
218
+
219
+ /** 一行工作流运行的改写(originSessionId + phases[].members[].childId)。 */
220
+ function rewriteWorkflowRun(run: unknown, ctx: SidebarRewriteContext): unknown {
221
+ if (run === null || typeof run !== 'object' || Array.isArray(run)) return run
222
+ const record = run as Record<string, unknown>
223
+ const rewritten: Record<string, unknown> = { ...record }
224
+ const origin = record.originSessionId
225
+ if (typeof origin === 'string' && origin !== '' && !ctx.isVirtual(origin)) rewritten.originSessionId = ctx.virtualize(origin)
226
+ if (Array.isArray(record.phases)) {
227
+ rewritten.phases = record.phases.map((phase) => {
228
+ if (phase === null || typeof phase !== 'object' || Array.isArray(phase)) return phase
229
+ const phaseRecord = phase as Record<string, unknown>
230
+ if (!Array.isArray(phaseRecord.members)) return phase
231
+ return {
232
+ ...phaseRecord,
233
+ members: phaseRecord.members.map((member) => {
234
+ if (member === null || typeof member !== 'object' || Array.isArray(member)) return member
235
+ const memberRecord = member as Record<string, unknown>
236
+ const childId = memberRecord.childId
237
+ if (typeof childId !== 'string' || childId === '' || ctx.isVirtual(childId)) return member
238
+ return { ...memberRecord, childId: ctx.virtualize(childId) }
239
+ }),
240
+ }
241
+ })
242
+ }
243
+ return rewritten
244
+ }
@@ -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')