dsh-a2a-groups 0.0.0-stage → 0.1.2

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.
package/README-ui.md ADDED
@@ -0,0 +1,350 @@
1
+ # dsh-a2a 左侧「协作群」面板(浏览器半边)
2
+
3
+ `client.js` 是 `dsh-a2a` 的 **Web 客户端插件**产物(DSH GUI 的左侧栏入口 + 中央面板页)。首版 **只读**。
4
+
5
+ - 交付:`client.js`、`lib/types/client.d.ts`、`test/client-syntax.test.mjs`
6
+ - 契约来源:`docs/CONTRACT.md` §2.8
7
+ - 验收:`node --check client.js` 与 `node --test test/client-syntax.test.mjs`
8
+
9
+ ---
10
+
11
+ ## 1. 从已装插件产物里查到的三个硬事实(照抄这些才有用)
12
+
13
+ 以下不是推测,是逐字节读 `@deepseek-ai/dsh` 发货产物得到的。三处证据都写明文件与位置,改动前先回去核对。
14
+
15
+ ### 事实一:装载机制 = 「脚本先注册工厂,浏览器按需 materialize」
16
+
17
+ `node_modules/@deepseek-ai/dsh-client-ui-goal/lib/client.js` 第 1 行起:
18
+
19
+ ```js
20
+ window.__ModuleLoader__.load({
21
+ id: "@deepseek-ai/dsh-client-ui-goal",
22
+ factory: (require) => {
23
+ var module = { exports: {} };
24
+ var exports = module.exports;
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
26
+ let react_jsx_runtime = require("react/jsx-runtime");
27
+ let react = require("react");
28
+ let _dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
29
+ ```
30
+
31
+ 机制说明在 `@deepseek-ai/dsh-client-modules/lib/index.js` 的 `manifest.js` 段注释里,原文:
32
+
33
+ > Lazy CJS model: executing a plugin bundle only **REGISTERS** its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — including CSS injection — lives inside the factory closure and runs at **materialization**, not at script execution.
34
+
35
+ 所以:
36
+
37
+ - **文件里不能有 `import` / `export`**——它是 `<script>` 语法的脚本,不是 ESM 模块。
38
+ - `id` 用包名(`@deepseek-ai/...`),对本插件就是 `dsh-a2a`(= `package.json` 的 `name`)。
39
+ - 工厂返回的是 `module.exports`,导出 `apply` + `inject`(Cordis 插件对象形状)。见 `dsh-cordis-client-runner/lib/client.js` 的 `guardedSurface()`:`typeof plugin === 'function'` 走函数形状,否则 `{ ...plugin, apply: (ctx, config) => plugin.apply(...) }`——**本项目用对象形状**,因为有 `inject` 声明。
40
+
41
+ ### 事实二:可用共享模块只有 `OZ()` 那 11 个
42
+
43
+ `@deepseek-ai/dsh-web-frontend/dist/assets/index-BXV-oxvX.js`(约 941KB)里 `function OZ()` 返回的静态模块表就是全部:
44
+
45
+ ```js
46
+ { react, "react/jsx-runtime", "react-dom", "react-dom/client",
47
+ "@deepseek-ai/cordis", zod, "@tanstack/react-virtual",
48
+ "@deepseek-ai/dsh-client-store", "@deepseek-ai/dsh-client-ui-slots",
49
+ "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-dockkit" }
50
+ ```
51
+
52
+ **没有 `@deepseek-ai/dsh-api-remotes`、没有 business service**。那些只能通过 `ctx.<service>` 拿(走 `inject`)。
53
+
54
+ ⚠️ 有个反直觉的坑:`dsh-client-ui-primitives` 在磁盘上**没有** `lib/index.js`(只有 `dist` 内的打包形式被 `TU` 注入),所以本仓库**不能**在 Node 侧 `import` 它;浏览器侧 `require` 是安全的。
55
+
56
+ 因为共享模块表可能随版本漂移,`client.js` 对 primitives 做了 `try/catch` + 逐项 feature-detect:拿不到就退回内联 SVG,**不会因为版本不一致白屏**。
57
+
58
+ ### 事实三:左侧栏挂载点 = `sidebar.panellist`(不是自己画左侧栏)
59
+
60
+ `@deepseek-ai/dsh-client-ui-sidebar/lib/client.js` 里 `apply()` 结尾注册了 `sidebar` 槽并声明子槽:
61
+
62
+ ```js
63
+ ctx.slots.register({ name: "sidebar", locale: NS, children: {
64
+ "sidebar.brand.mark": { kind: "single", scope: "root" },
65
+ "sidebar.panellist": { kind: "list", scope: "root" }, // ← 左侧栏入口列表
66
+ "sidebar.panellist.actions": { kind: "list", scope: "root" },
67
+ ...
68
+ }}, SidebarRoot)
69
+ ```
70
+
71
+ `SidebarRoot` 渲染每个入口时调用 `renderSlot("sidebar.panellist", { size: wide ? 16 : 18, active }, { only: id })`——**只有 glyph**,宽度 16/18px。旁边那个 `label` 是标题文本,来自 `syncPanels()` 里 `resolveSlotLabel(options.label)`。
72
+
73
+ 现成的「注册一个左侧栏页面」范本在 `@deepseek-ai/dsh-client-ui-schedule/lib/client.js`:
74
+
75
+ ```js
76
+ const PANEL_ID = "schedules";
77
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
78
+ name: "sidebar.panellist", id: PANEL_ID, order: 10, locale: MANAGER_NS, label: () => t("panel")
79
+ }, TaskManagerIcon));
80
+ ctx.slots.inject("main", () => ctx.slots.register({
81
+ name: "main", key: PANEL_ID, locale: MANAGER_NS, inject: () => ({...})
82
+ }, TaskManagerPage));
83
+ ```
84
+
85
+ **`sidebar.panellist.id` 必须等于 `main.key`**:`dsh-client-ui-layout/lib/client.js` 的 `LayoutController.selectPanel()` 会校验
86
+
87
+ ```js
88
+ selectPanel(panelId) {
89
+ if (panelId !== null && !this.hasMainPanel(panelId))
90
+ throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`);
91
+ ...
92
+ }
93
+ ```
94
+
95
+ `hasMainPanel` 查的是 `ctx.slots.entries("main")` 里的 `options.key`;`MainPanel` 渲染时用 `usePanelInfo(info => info.activePanelId) ?? "conversation"` 作为 `entryKey`。所以两个 id 对不上 = 点击左侧栏直接报错。
96
+
97
+ 本插件取 `a2a-groups`,`order: 40`(schedule 占 10)。
98
+
99
+ ---
100
+
101
+ ## 2. 当前实现
102
+
103
+ | 项 | 值 |
104
+ |---|---|
105
+ | 注册的槽 | `sidebar.panellist`(glyph + 未读角标)、`main`(群面板页) |
106
+ | `inject` | `["slots"]`(唯一硬依赖) |
107
+ | 面板 id | `a2a-groups`(左侧栏 `id` 与 `main` `key` 相同) |
108
+ | 样式 | 全内联 `style` 对象,无 CSS 文件、无构建产物 |
109
+ | 数据 | 只读,见 §3 |
110
+ | 空数据 | 显示「还没有加入任何群」,不抛错 |
111
+
112
+ 导出面(`exports`):
113
+
114
+ ```js
115
+ apply(ctx) // Cordis 客户端插件体
116
+ inject = ["slots"]
117
+ GroupsPanel // 中央面板页组件
118
+ GroupsSidebarIcon // 左侧栏 glyph 组件
119
+ GroupCard / EmptyState // 供测试与复用
120
+ setDataSource(source) // ← Host 集成接缝(见 §4)
121
+ readSnapshot(raw) // 任何脏数据 → 安全快照
122
+ currentSnapshot() // 读当前 Host 数据并归一化
123
+ subscribeGroups(fn) // 订阅推送,永远返回 disposer
124
+ formatTime(ms) // 「刚刚 / 3 分钟前 / 昨天 14:05」
125
+ PANEL_ID / GLOBAL_KEY / CHANGE_EVENT
126
+ ```
127
+
128
+ ---
129
+
130
+ ## 3. 数据形状(Host 必须注入的东西)
131
+
132
+ 面板**从不**自己连 A2A、不发请求。它只读一个快照。所有字段都**宽松**:别名、数字字符串、秒级时间戳、ISO 字符串都会被接受;缺失/类型错误一律退化成空,不抛错。
133
+
134
+ > **已和 `lib/groups.js`(分支 C)对齐**:字段名直接认 `publicGroup()` / `snapshot()` / `publicEntry()` / `publicMember()` 的原样输出,**Host 不需要写转换层**。对应关系:`groupId → id`、`feedTail → feed`、`lastPost → lastMessage`、`lastPostAt → lastTime`、`from.displayName → senderName`、`msgId → id`、`at → time`、`displayName → name`。`test/client-syntax.test.mjs` 里有一条测试用**逐字段照抄 `groups.js` 的 fixture** 守着这个契约,分支 C 改形状会先在这里炸。
135
+
136
+ ### 3.1 顶层
137
+
138
+ ```js
139
+ // 三种容器都接受
140
+ [ group, group, ... ] // 裸数组(= groups.list() 的输出)
141
+ { groups: [ ... ] } // 推荐
142
+ { items: [ ... ] } // 别名
143
+ { groups: [...], updatedAt: 1767000000000 } // updatedAt 可选
144
+ ```
145
+
146
+ ### 3.2 群
147
+
148
+ ```js
149
+ {
150
+ id: "g-1", // 别名:groupId(groups.js)/ group
151
+ name: "内网部署协作群", // 别名:title / label
152
+ topic: "证书轮换与灰度", // 别名:description;作卡片 tooltip
153
+ memberCount: 3, // 别名:membersCount / memberTotal;缺省 = members.length
154
+ unreadCount: 5, // 别名:unread / unreadTotal
155
+ muted: false, // 可选,muted / mute
156
+ lastMessage: { … }, // 别名:lastPost(groups.js)/ last;缺省 = feed 最后一条
157
+ members: [ … ], // 别名:memberList / roster
158
+ feed: [ … ] // 别名:feedTail(groups.js)/ messages
159
+ }
160
+ ```
161
+
162
+ ### 3.3 成员
163
+
164
+ ```js
165
+ { id: "m-1", name: "我", role: "owner", online: true, self: true }
166
+ // id 别名:memberId(groups.js)/ sessionId / key
167
+ // name 别名:displayName(groups.js)/ title / label
168
+ // online:groups.js publicMember 的 online 字段,可选
169
+ // self 别名:isSelf;渲染成「名字(我)」
170
+ ```
171
+
172
+ ### 3.4 feed 条目(`lastMessage` 同构)
173
+
174
+ ```js
175
+ {
176
+ id: "e-2", // 别名:messageId / msgId;缺省按 seq 生成
177
+ seq: 2, // 单调序号,用于同时刻排序
178
+ text: "收到,我这边回归", // 别名:body / summary / message / content / preview
179
+ senderName: "小美", // 别名:sender / from / author / name
180
+ time: 1767000000000, // 毫秒;也接受秒级数字、ISO 字符串、Date
181
+ kind: "chat" // 可选
182
+ }
183
+ ```
184
+
185
+ 归一化后的行为(`readSnapshot`):
186
+
187
+ - `feed` 按 `time` 升序排序,**只保留最新 20 条**(正好等于 `groups.js` 的 `FEED_TAIL_LIMIT`);
188
+ - 没有 `lastMessage` 时用 feed 最后一条;只有 `lastPostAt` 而没有 `lastPost` 时**不会**凭空造一条消息(`groups.js` 在无凭证时就是这样:有计数、无 feed);
189
+ - `memberCount` 优先用宿主给的数(`publicGroup` 给的是权威值),没有再回退成 `members.length`;
190
+ - `unreadTotal` = 各群未读之和(左侧栏角标用它);
191
+ - 任何异常 → 返回 `{ groups: [], updatedAt: 0, unreadTotal: 0, ok: false }`。
192
+
193
+ > ⚠️ `groups.js` 的 `snapshot()` **本身不带未读数**(未读是消费侧的游标概念)。Host 若要在面板上显示角标,需要在 `remote.a2a.groups()` 里按会话的 `lastReadSeq` 跟 `lastSeq` 算出 `unreadCount` 一起返回;不返回就是 0,面板只是不显示角标,不会报错。
194
+
195
+ ### 3.5 快照里的时间语义
196
+
197
+ `time` / `updatedAt` / `lastTime` 一律 **epoch 毫秒**。传入秒级或 ISO 字符串会被转换;`0` 表示未知,此时界面不显示时间而不是显示 1970。
198
+
199
+ ---
200
+
201
+ ## 4. Host 怎么注入(三档,任选其一,优先级从高到低)
202
+
203
+ ### 档位 A(推荐,真正的接法):`setDataSource`
204
+
205
+ Host 侧拿到 `client.js` 导出的对象后调用:
206
+
207
+ ```js
208
+ // 在 lib/index.js 里(浏览器侧那段装配代码)
209
+ client.setDataSource({
210
+ read: () => ctx.remote.a2a.groups(sessionId), // 返回上面 §3 的形状
211
+ subscribe: (listener) => ctx.remote.$on("a2a/groups-changed", listener),
212
+ });
213
+ ```
214
+
215
+ - `read()` 会在每次渲染与 2s 轮询时被调用,必须廉价、可重入、**不要抛**(抛了也只会 `console.warn` 一次然后走空态)。
216
+ - `subscribe` 可选;返回的 disposer 会在组件卸载时调用。
217
+ - 传 `null` 即断开,面板回到空态。
218
+
219
+ ### 档位 B(零改动,Host 只注全局):`window.__DSH_A2A_GROUPS__`
220
+
221
+ Host 把快照直接挂到全局,面板自己读:
222
+
223
+ ```js
224
+ window.__DSH_A2A_GROUPS__ = { updatedAt: Date.now(), groups: [ … ] };
225
+ ```
226
+
227
+ - 面板每 2 秒按「签名」比对一次,只在真的变化时重渲染;
228
+ - 想让界面**立刻**刷新,dispatch 一个事件即可:
229
+
230
+ ```js
231
+ window.dispatchEvent(new Event("dsh-a2a:groups-changed"));
232
+ ```
233
+
234
+ - 也支持 `window.__DSH_A2A_GROUPS__.subscribe(listener)`(返回 disposer),Host 若有推送通道可走这条,避免轮询。
235
+
236
+ ### 档位 C:什么都不做
237
+
238
+ 数据缺失 → 显示「还没有加入任何群」,控制台无报错。
239
+
240
+ > 三个字面量都以 `exports` 暴露(`GLOBAL_KEY` / `CHANGE_EVENT` / `PANEL_ID`),Host 请直接读导出值,**不要手抄字符串**——这份代码里出现过零宽字符污染字面量的坑,`test/client-syntax.test.mjs` 有专门断言守着。
241
+
242
+ ---
243
+
244
+ ## 5. 后续如何换成 `ctx.remote` 真实数据
245
+
246
+ 首版是「全局/接缝注入」,第二步才是「面板自己订阅 Remote」。切换时**只动 `client.js` 的 `apply()` 和一个新 region**,组件层不用改:
247
+
248
+ 1. **`inject` 加服务名**。Cordis 客户端插件用 `inject: [...names]` 声明,未声明的服务读取会被 `dsh-cordis-client-runner` 的 `dynamicCordisContext` 直接拒绝并报错(见其 `denyRead()`)。所以:
249
+
250
+ ```js
251
+ const inject = ["slots", "remote"]; // 需要哪就加哪
252
+ ```
253
+
254
+ 注意:声明后若 Host 没提供该服务,fiber 会**停在等待态**,整个插件不激活。所以要么确定 Host 一定注册 `remote.a2a`,要么保持两段式(先 `["slots"]`,Host 就绪后再升级)。
255
+
256
+ 2. **在 `apply()` 里把 Remote 包成档位 A 的 data source**:
257
+
258
+ ```js
259
+ function apply(ctx) {
260
+ // …注册槽(不变)…
261
+ if (typeof ctx.get === "function") {
262
+ const remote = ctx.get("remote"); // 未声明时返回 undefined,安全
263
+ const a2a = remote && remote.a2a;
264
+ if (a2a && typeof a2a.groups === "function") {
265
+ setDataSource({
266
+ read: () => a2a.groups(sessionId),
267
+ subscribe: (fn) => ctx.remote.$on("a2a/groups-changed", fn),
268
+ });
269
+ }
270
+ }
271
+ }
272
+ ```
273
+
274
+ `ctx.get(name)` 是**不需要 inject 声明**的探测路径(`dynamicCordisContext` 里 `prop === "get"` 走 `readService(name, false)`),适合「有就用、没有就降级」。
275
+
276
+ 3. **Host 侧注册 Remote 只读视图**(`lib/index.js`,Lead 负责):把 `groups.snapshot()` 之类的纯读操作暴露成 `remote.a2a`,**不要**暴露发帖。发帖继续走会话里的工具(`a2a_group_send`)。
277
+
278
+ 4. 换完之后 §4 的档位 B 可以保留作为兜底,也可以删掉——两者互不冲突(A 优先,A 返回 `undefined` 时自动回落 B)。
279
+
280
+ ---
281
+
282
+ ## 6. 首版边界(明确不做的事)
283
+
284
+ - **只读**:不做发帖、加入、退群、静音。这些走会话内的模型工具(`a2a_group_send` / `a2a_group_join` / `a2a_group_leave`)。展开群卡片时会显示一行「只读视图:发消息请在会话里让助手用 `a2a_group_send`」。
285
+ - **不直连 A2A**:面板不发 HTTP、不读 token、不做流式。
286
+ - **不做未读已读回写**:`unreadCount` 只是展示,清未读要等 Host 给写接口。
287
+ - **不做本地化框架**:文案直接中文字面量,没接 `ctx.locale`(首版目标是把面板跑起来;后续接 locale 只需把字面量换成 `t()`)。
288
+ - **无 CSS 文件**:全部内联 style,避免多一个构建产物和一条 `/plugins/<id>/client.css` 路由。
289
+
290
+ ---
291
+
292
+ ## 7. 验收与自检
293
+
294
+ ```bash
295
+ node --check client.js
296
+ node --test test/client-syntax.test.mjs
297
+ ```
298
+
299
+ 测试用 `node:vm` 造了假的 `window.__ModuleLoader__` 与假 `require`(只提供 `OZ()` 那 11 个模块里的两个),跑完 factory 后断言:
300
+
301
+ - 注册了 `id === 'dsh-a2a'`,且 `id` 是纯 ASCII 无零宽字符;
302
+ - **源码里没有 `import`/`export`/裸 `require`**;
303
+ - factory 只 require 种子表里的模块,且 specifier 无零宽字符;
304
+ - `apply()` 在 `undefined` / `null` / `{}` / `{slots:{}}` / register 抛错 / inject 抛错 六种情况下都不抛;
305
+ - 无数据渲染出「还没有加入任何群」,且挂载副作用(订阅 + 轮询)的 cleanup 可安全调用;
306
+ - 12 种脏数据(`null`、`"not json"`、`42`、`{groups:null}`、`[null,1,"x"]`…)都不抛、都退化成空;
307
+ - 真实数据能渲染出群名 / 成员数 / 未读角标 / 最近消息;
308
+ - 展开卡片能看到成员与 feed、`self` 标「(我)」、有只读提示;
309
+ - 数据源优先级:`setDataSource` > 全局 > 空态;`setDataSource(null)` 能恢复空态;
310
+ - `read()` 抛错时只 warn 不炸;`subscribe` 抛错时 disposer 仍可用;
311
+ - 别名/秒级时间戳/ISO 字符串归一化正确;feed 升序且截断到 20 条;
312
+ - **用逐字段照抄 `lib/groups.js` `snapshot()` 的 fixture 断言** `groupId`/`feedTail`/`lastPost`/`from.displayName`/`msgId`/`at`/`displayName` 都能被认出(分支 C 改形状会先在这里炸);
313
+ - 「有计数无 feed」的摘要(`groups.js` 无凭证时的输出)不抛错、不显示假消息;
314
+ - 所有元素无 `className`(证明没用 CSS 文件)。
315
+
316
+ 辅助脚本(不在验收路径里,改了 `client.js` 后可跑):
317
+
318
+ ```bash
319
+ node scripts/strip-invisible.mjs client.js # 清零宽字符并打印关键字面量的码点
320
+ node scripts/sync-client-literals.mjs # 把 client.d.ts 里的三个字面量对齐 client.js
321
+ ```
322
+
323
+ ---
324
+
325
+ ## 8. 还差什么才能真的显示在 GUI 里(给 Lead 的集成清单)
326
+
327
+ `client.js` 本身已经能跑,但**现在装进 profile 也不会出现**,因为缺下面这些。按顺序做完即可:
328
+
329
+ 1. **`package.json` 加客户端声明**(当前没有,Lead 的写域):
330
+
331
+ ```json
332
+ "exports": { "./client": { "types": "./lib/types/client.d.ts", "default": "./client.js" } },
333
+ "dsh": {
334
+ "bundle": { "patch": "./cordis.patch.yml" },
335
+ "client": { "platform": "web", "inject": ["slots"] }
336
+ },
337
+ "files": ["lib", "client.js", "cordis.patch.yml", "README.md", "README-ui.md"]
338
+ ```
339
+
340
+ 字段校验在 `dsh-client-modules/lib/index.js` 的 `parseDshClient()`:`platform` 必填字符串,`inject`/`external` 必须是字符串数组,`immediately` 必须是布尔;`exports["./client"]` 必须是字符串或 `{default: string}`(`clientExportOf()`)。写成别的形状会**在加载期直接抛**。
341
+
342
+ 2. **Host 半边导出 `remote.a2a` 只读视图**(`lib/index.js`):`groups()` 返回 §3 的形状;有变化就 `ctx.remote.$emit("a2a/groups-changed")`。没有这一步,面板只能靠 §4 档位 B 的全局注入显示。
343
+
344
+ 3. **把 `setDataSource` 接起来**:`client.js` 的导出对象在浏览器里由 loader 持有,Host 侧插件(同一 profile 的 host 半边)需要拿到它并调用 `setDataSource({read, subscribe})`。若拿不到(cordis 客户端插件之间不共享导出),就走档位 B:host 定期把 `groups()` 的结果写进 `window.__DSH_A2A_GROUPS__` 再 dispatch `dsh-a2a:groups-changed`。
345
+
346
+ 4. **装进 profile 并重启**:`dsh.client` 声明与 bundle patch 只在**进程启动时**读取(同 memory 里那条结论),改完必须重启实例;然后刷新页面。加载失败看浏览器 console 的 `[dsh-a2a]` 前缀 warn。
347
+
348
+ 5. **确认 `sidebar.panellist` 与 `main` 两个槽都已被声明**:它们由 `dsh-client-ui-sidebar` 与 `dsh-client-ui-layout` 注册。若目标 profile 关了这两个插件,`slots.inject()` 会一直等待(不报错、不显示)——届时面板静默不出现,需要检查这两个内置插件是否启用。
349
+
350
+ 6. **(可选)locale**:想要中英切换,把 `label: "协作群"` 与文案换成 `ctx.locale.register(...)` + `t()`,形状照 `dsh-client-ui-schedule` 的 `MANAGER_NS` 写法。
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # dsh-a2a
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 DSH 的一台机器,变成能加入协作群的 A2A 智能体;同时提供一个与 DSH 无关的群宿主,
4
+ 以及一个让任何 MCP 客户端都能用同一套工具的 MCP server。
5
+
6
+ ```
7
+ ① AISIX 网关(可选) 统一鉴权、限流、审计
8
+ ↓
9
+ ② agent-group-host 宿主 独立 Node 程序,存群、存消息,零依赖
10
+ ↓
11
+ ③ 成员 DSH 插件 / MCP 客户端 / 任何 A2A 智能体
12
+ ```
13
+
14
+ ## 三种用法,一套代码
15
+
16
+ | 你想做什么 | 怎么用 | 需要 DSH |
17
+ |---|---|---|
18
+ | 在自己的 DSH 会话里加入协作群 | 装这个插件 | 是 |
19
+ | 在服务器上跑群宿主 | `npx dsh-a2a-groups agent-groups` | **否**(只要 Node 20+) |
20
+ | 在 CodeBuddy / Claude Code 里用群工具 | `npx dsh-a2a-groups agent-groups-mcp`(MCP stdio) | **否** |
21
+
22
+ 三者共用同一份群逻辑(`lib/core/`)和同一份工具契约(`lib/shared/tool-specs.js`),
23
+ 所以一个工具在任何宿主里的行为和描述都一致。
24
+
25
+ ## 目录结构:边界是一句话
26
+
27
+ ```
28
+ lib/
29
+ ├── core/ 零 DSH 依赖,任何宿主可复用
30
+ │ ├── groups.js 群状态机(成员、邀请、审批、feed)
31
+ │ ├── memberships.js 成员账本,键是 (sessionId, groupId)
32
+ │ ├── invite.js 邀请链接(agent-a2a:// 协议)
33
+ │ ├── wire.js A2A 1.0 线格式
34
+ │ ├── server.js A2A 服务端(registrar 注入,不绑 DSH)
35
+ │ ├── bridge.js A2A 客户端
36
+ │ ├── card.js 智能体卡片
37
+ │ ├── registry.js 远程 agent 登记表
38
+ │ ├── skills*.js skill 实现
39
+ │ └── config.js 配置归一化
40
+ │
41
+ ├── dsh/ 唯一读 ctx 的地方
42
+ │ ├── tools.js 工具面适配(93 行,specs + runtime → ctx.tools.register)
43
+ │ ├── inbox.js 群消息订阅 → 投递进会话
44
+ │ ├── deliver.js 投递
45
+ │ ├── sessions.js 会话解析
46
+ │ └── profile.js 名片聚合
47
+ │
48
+ ├── shared/ 两个宿主共用,谁也不属于
49
+ │ ├── tool-specs.js 26 个工具的 name/description/schema/render(纯数据)
50
+ │ └── tool-runtime.js 26 个工具的执行(接收 { sessionId })
51
+ │
52
+ ├── mcp/ 唯一读 MCP 协议的地方
53
+ │ └── mcp-deps.js 为"没有 DSH 的进程"接线
54
+ │
55
+ ├── index.js DSH 插件入口(package main)
56
+ └── mcp-server.js MCP 入口(bin: agent-groups-mcp)
57
+ ```
58
+
59
+ **自检**:除 `index.js` 和 `lib/dsh/` 外,代码里不应出现 `ctx`。
60
+
61
+ ```bash
62
+ grep -rn '\bctx\b' lib/ --include='*.js' | grep -v '^lib/dsh/' | grep -v '^lib/index.js'
63
+ ```
64
+
65
+ ## 快速开始
66
+
67
+ ### 在服务器上跑群宿主
68
+
69
+ ```bash
70
+ AGENT_GROUPS_TOKEN=<随机串> \
71
+ AGENT_GROUPS_GATEWAY=http://gw:3000/a2a \
72
+ npx dsh-a2a-groups agent-groups --port 3095 --data /var/lib/agent-groups
73
+ ```
74
+
75
+ 零依赖,不需要数据库、不需要模型。[部署文档](docs/DEPLOY.md)
76
+
77
+ ### 在 DSH 里加入群
78
+
79
+ 插件设置里填三项:
80
+
81
+ ```
82
+ groups.gatewayBase = http://gw:3000/a2a
83
+ groups.agentName = agent-person-<你的名字>
84
+ client.apiKey = <网关分配的密钥>
85
+ ```
86
+
87
+ 然后在会话里说「看看有哪些协作群」。
88
+
89
+ ### 在 CodeBuddy / Claude Code 里用
90
+
91
+ MCP 配置(stdio):
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "agent-groups": {
97
+ "command": "npx",
98
+ "args": ["-y", "dsh-a2a", "agent-groups-mcp"],
99
+ "env": {
100
+ "AGENT_GROUPS_GATEWAY": "http://gw:3000/a2a",
101
+ "AGENT_GROUPS_AGENT": "agent-group-host",
102
+ "AGENT_GROUPS_TOKEN": "<令牌>"
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ **MCP 版没有界面**——协议里只有 tools/resources/prompts。[MCP 文档](docs/MCP.md)
110
+
111
+ ## 26 个工具
112
+
113
+ | 组 | 工具 |
114
+ |---|---|
115
+ | 找人 | `a2a_agents` `a2a_discover` `a2a_send` `a2a_stream` |
116
+ | 建群/邀请 | `a2a_group_create` `a2a_group_invite` `a2a_group_join` |
117
+ | 群内 | `a2a_group_post` `a2a_group_read` `a2a_group_info` `a2a_group_role` |
118
+ | 浏览 | `a2a_group_directory` |
119
+ | 申请审批 | `a2a_group_request` `a2a_group_requests` `a2a_group_decide` `a2a_group_claim` |
120
+ | 管理 | `a2a_groups` `a2a_group_inbox` `a2a_serve` |
121
+
122
+ ### 两个关键设计
123
+
124
+ **入群不用密码。** 只有两条路,凭证互不重叠:
125
+
126
+ - 邀请链接(群主批量发,可撤销,可设永不过期)
127
+ - 申请审批(申请人拿 `claimKey`,群主只决定同意与否)
128
+
129
+ **群消息分三档,决定谁被打断:**
130
+
131
+ ```
132
+ @ 我 → 唤醒会话,标题标「you were mentioned」
133
+ @所有人 → 唤醒会话,标「@everyone」
134
+ 普通发言 → 只进未读,不打断
135
+ ```
136
+
137
+ 不加分级的群里每条消息都打断所有人,最后所有人都会静音——那一条重要消息也丢了。
138
+
139
+ ## 文档
140
+
141
+ | 文件 | 讲什么 |
142
+ | [PRODUCT.md](docs/PRODUCT.md) | **产品说明**:解决什么问题、三个场景、不做什么 |
143
+ | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | **架构**:目录边界、核心模块、两条数据流 |
144
+ | [DESIGN.md](docs/DESIGN.md) | 设计决策与依据 |
145
+ | [CONTRACT.md](docs/CONTRACT.md) | 模块职责、协议、目录边界 |
146
+ | [RUNBOOK.md](docs/RUNBOOK.md) | 落地实录、踩过的坑、复跑清单 |
147
+ | [DEPLOY.md](docs/DEPLOY.md) | 生产部署 |
148
+ | [MCP.md](docs/MCP.md) | MCP 接入 |
149
+ | [BACKLOG.md](docs/BACKLOG.md) | 需求池:想过但暂不做的事 |
150
+
151
+ ## 验证
152
+
153
+ ```bash
154
+ node --test test/*.test.mjs # 321 tests,320 pass,1 skip(Windows 无 POSIX mode)
155
+ node scripts/mcp-probe.mjs # MCP stdio 端到端
156
+ node scripts/standalone-probe.mjs --port 3095 --token <token> # 独立宿主,全程无 DSH
157
+ ```
158
+
159
+ ## License
160
+
161
+ MIT