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 +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — A2A protocol for DSH, both directions, plus invite-only groups.
|
|
3
|
+
*
|
|
4
|
+
* Three capabilities, one plugin, because they share one insight: a DSH Session
|
|
5
|
+
* is already an agent, and A2A is just a way to address it from outside.
|
|
6
|
+
*
|
|
7
|
+
* **Server** — this instance publishes itself as a standard A2A agent at
|
|
8
|
+
* `/a2a`. An inbound task becomes a real Session turn (the left-hand "task
|
|
9
|
+
* message received" card, never a bubble that looks like the user typed it),
|
|
10
|
+
* and the Session's answer becomes the artifact.
|
|
11
|
+
*
|
|
12
|
+
* **Client** — `a2a_*` tools let a Session discover and call other agents
|
|
13
|
+
* through a gateway or directly.
|
|
14
|
+
*
|
|
15
|
+
* **Groups** — a group is a DSH instance that registered itself as an A2A
|
|
16
|
+
* agent and keeps an invite-only membership list. That is the whole design:
|
|
17
|
+
* no new server, no new database, no new auth scheme. AISIX decides who may
|
|
18
|
+
* reach the agent; the group decides who is a member; the Session decides what
|
|
19
|
+
* to do about it.
|
|
20
|
+
*
|
|
21
|
+
* The plugin imports nothing but `node:*` and its own files. Bare
|
|
22
|
+
* `@deepseek-ai/...` specifiers only resolve for plugins loaded from inside the
|
|
23
|
+
* DSH installation; a profile or workspace plugin is loaded by plain Node
|
|
24
|
+
* resolution, so it must stay self-contained to load from any location. Every
|
|
25
|
+
* Host capability arrives through the injected `ctx`.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-a2a
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The RPC channel the panel uses to write.
|
|
34
|
+
*
|
|
35
|
+
* A single absolute prefix, per `HostConnectionRpc.handle`. Endpoints live under it
|
|
36
|
+
* (`/a2a-panel/profile`), so one registration serves every panel write.
|
|
37
|
+
*/
|
|
38
|
+
const PANEL_RPC_CHANNEL = '/a2a-panel';
|
|
39
|
+
|
|
40
|
+
import { DEFAULT_CONFIG, normalizeConfig, resolveHome } from './core/config.js';
|
|
41
|
+
import { createServer } from './core/server.js';
|
|
42
|
+
import { createBridge } from './core/bridge.js';
|
|
43
|
+
import { createRegistry } from './core/registry.js';
|
|
44
|
+
import { createDeliverer } from './dsh/deliver.js';
|
|
45
|
+
import { createGroupStore } from './core/groups.js';
|
|
46
|
+
import { createMembershipLedger } from './core/memberships.js';
|
|
47
|
+
import { createSessions } from './dsh/sessions.js';
|
|
48
|
+
import { createSkillRegistry } from './core/skills.js';
|
|
49
|
+
import { buildProfile } from './dsh/profile.js';
|
|
50
|
+
import { createPingSkill, createSessionAskSkill } from './core/skills-session.js';
|
|
51
|
+
import { createGroupSkills } from './core/skills-group.js';
|
|
52
|
+
import { createInbox } from './dsh/inbox.js';
|
|
53
|
+
import { registerTools } from './dsh/tools.js';
|
|
54
|
+
import { PROMPT_SECTION, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER } from './dsh/prompt.js';
|
|
55
|
+
import { TaskStore } from './core/wire.js';
|
|
56
|
+
|
|
57
|
+
/** Cordis plugin name. */
|
|
58
|
+
export const name = 'a2a';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Services this plugin needs before it can contribute anything.
|
|
62
|
+
*
|
|
63
|
+
* Cordis treats a bare `ctx.<name>` read as a *declared dependency* and throws
|
|
64
|
+
* for anything not listed here, so this list is the plugin's whole Host contract:
|
|
65
|
+
*
|
|
66
|
+
* - `webServer` — what makes this instance reachable at `/a2a`;
|
|
67
|
+
* - `sessionController` — what makes an inbound message become a real turn;
|
|
68
|
+
* - `systemPrompt` — where the etiquette section is registered (only needed when
|
|
69
|
+
* `promptSection` is on, but the read happens at mount time);
|
|
70
|
+
* - `tools` — the registry `a2a_*` tools are registered on.
|
|
71
|
+
*
|
|
72
|
+
* A Host missing any of these simply does not mount this row, which is better
|
|
73
|
+
* than mounting an agent that cannot answer or a tool surface that cannot be
|
|
74
|
+
* called. Found the hard way: a real Host rejects the mount with "cannot get
|
|
75
|
+
* property X without inject", while a plain test double silently allows it.
|
|
76
|
+
*/
|
|
77
|
+
export const inject = ['webServer', 'sessionController', 'systemPrompt', 'tools', 'connection'];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mount A2A on one Host.
|
|
81
|
+
*
|
|
82
|
+
* Everything is assembled here and nowhere else: the store, the registry, the
|
|
83
|
+
* bridge and the server have no knowledge of each other, and this function is
|
|
84
|
+
* the single place their wiring is decided. That keeps each module testable with
|
|
85
|
+
* fakes and makes "why is this setting doing nothing" a question with one place
|
|
86
|
+
* to look.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} ctx - plugin context.
|
|
89
|
+
* @param {object} [config] - plugin configuration from the profile patch.
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
export function apply(ctx, config) {
|
|
93
|
+
const settings = normalizeConfig(config);
|
|
94
|
+
if (!settings.enabled) {
|
|
95
|
+
info(ctx, 'a2a is disabled by configuration');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const logger = ctx.logger;
|
|
100
|
+
const home = resolveHome(settings);
|
|
101
|
+
const clock = () => Date.now();
|
|
102
|
+
|
|
103
|
+
// --- durable state -------------------------------------------------------
|
|
104
|
+
const groups = createGroupStore({ home, logger, clock, config: settings });
|
|
105
|
+
const registry = createRegistry({ home, logger, clock });
|
|
106
|
+
// The membership ledger is separate from the group store on purpose: the group
|
|
107
|
+
// store holds what a HOST knows about its groups (and therefore only hashes),
|
|
108
|
+
// while the ledger holds what THIS INSTANCE must present (and therefore the
|
|
109
|
+
// credential itself). Two different trust boundaries, two files.
|
|
110
|
+
const identities = createMembershipLedger({ home, logger, clock });
|
|
111
|
+
const tasks = new TaskStore({
|
|
112
|
+
ttlMs: settings.server.taskTimeoutMs * 4,
|
|
113
|
+
persistPath: null,
|
|
114
|
+
clock,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- Host-facing pieces --------------------------------------------------
|
|
118
|
+
const sessions = createSessions({ ctx, config: settings, logger, clock });
|
|
119
|
+
const deliverer = createDeliverer({ ctx, logger, config: settings, clock });
|
|
120
|
+
const bridge = createBridge({ logger, config: settings, clock });
|
|
121
|
+
|
|
122
|
+
// --- skills --------------------------------------------------------------
|
|
123
|
+
const skills = createSkillRegistry();
|
|
124
|
+
skills.register(createPingSkill({
|
|
125
|
+
version: pluginVersion(),
|
|
126
|
+
sessionIdOf: () => sessions.resolveBoundSessionId(),
|
|
127
|
+
}));
|
|
128
|
+
if (settings.server.skills.session) {
|
|
129
|
+
skills.register(createSessionAskSkill({ deliverer, sessions, logger, config: settings }));
|
|
130
|
+
}
|
|
131
|
+
if (settings.server.skills.group) {
|
|
132
|
+
for (const skill of createGroupSkills({ store: groups, identities, config: settings, logger, clock })) {
|
|
133
|
+
skills.register(skill);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- server --------------------------------------------------------------
|
|
138
|
+
const server = createServer({
|
|
139
|
+
ctx,
|
|
140
|
+
logger,
|
|
141
|
+
config: settings,
|
|
142
|
+
deliverer,
|
|
143
|
+
tasks,
|
|
144
|
+
skills,
|
|
145
|
+
// Layer B: the identity in the card is derived from memberships on every read,
|
|
146
|
+
// so joining a group or changing a role shows up the next time a peer
|
|
147
|
+
// discovers this agent. Nothing to maintain, nothing to go stale.
|
|
148
|
+
profileProvider: () => buildProfile({ identities, store: groups, config: settings }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- group inbox (subscription → Session delivery) -----------------------
|
|
152
|
+
const inbox = createInbox({
|
|
153
|
+
ctx, logger, config: settings, clock,
|
|
154
|
+
groups, identities, registry, bridge, deliverer, sessions,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const disposers = [];
|
|
158
|
+
if (settings.server.enabled) {
|
|
159
|
+
const detach = server.attach();
|
|
160
|
+
if (typeof detach === 'function') disposers.push(detach);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- tool surface --------------------------------------------------------
|
|
164
|
+
if (settings.tools) {
|
|
165
|
+
registerTools(ctx, {
|
|
166
|
+
config: settings, registry, bridge, store: groups, identities, server, sessions, logger, inbox,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- prompt section ------------------------------------------------------
|
|
171
|
+
if (settings.promptSection) {
|
|
172
|
+
ctx.inject(['systemPrompt'], (scoped) => {
|
|
173
|
+
scoped.systemPrompt.section({
|
|
174
|
+
name: PROMPT_SECTION_NAME,
|
|
175
|
+
order: PROMPT_SECTION_ORDER,
|
|
176
|
+
interpolate: false,
|
|
177
|
+
text: PROMPT_SECTION,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- browser panel data --------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** How many recent posts the panel shows per group. */
|
|
185
|
+
const PANEL_FEED_TAIL = 8;
|
|
186
|
+
// The 「协作群」 panel is a browser bundle that cannot call this Host directly,
|
|
187
|
+
// so the Host publishes its groups where the panel reads them. The channel is
|
|
188
|
+
// the webserver's structured index-injection table, not a hand-written script:
|
|
189
|
+
// the table's rows are JSON-serializable data rendered by the shell, which keeps
|
|
190
|
+
// this side free of escaping rules and keeps the value a value.
|
|
191
|
+
//
|
|
192
|
+
// Read on every index render, so a page refresh always sees the current groups
|
|
193
|
+
// and a group created after boot appears without a restart. Pushing into an
|
|
194
|
+
// open page is the panel's own 2s signature poll — one mechanism, not two.
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Write one person's group-scoped self-description, on behalf of the panel.
|
|
198
|
+
*
|
|
199
|
+
* The panel cannot call a tool: it renders before any Session exists and holds no member credential.
|
|
200
|
+
* This runs in the Host process, where the ledger that holds that credential lives — so the write
|
|
201
|
+
* happens with the same authority a Session turn would have, without the credential ever leaving.
|
|
202
|
+
*
|
|
203
|
+
* Scope is deliberately narrow: the caller names ONE group and this writes only the profile of the
|
|
204
|
+
* person this instance already is in that group. There is no path here that names somebody else.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} payload - `{ groupId, sessionId?, about?, tags?, headline?`.
|
|
207
|
+
* @returns {Promise<object>} a Connection result.
|
|
208
|
+
*/
|
|
209
|
+
const setPanelProfile = async (payload) => {
|
|
210
|
+
const body = payload !== null && typeof payload === 'object' ? payload : {};
|
|
211
|
+
const groupId = typeof body.groupId === 'string' ? body.groupId.trim() : '';
|
|
212
|
+
if (groupId === '') {
|
|
213
|
+
return { ok: false, error: { code: 'bad_argument', message: 'groupId is required', details: {} } };
|
|
214
|
+
}
|
|
215
|
+
// Which membership to act as. The panel sends the Session it belongs to; without one there is no
|
|
216
|
+
// honest answer, because a person can hold different credentials per Session.
|
|
217
|
+
const records = identities.list({ groupId });
|
|
218
|
+
const record = body.sessionId === undefined
|
|
219
|
+
? records[0]
|
|
220
|
+
: records.find((entry) => entry.sessionId === body.sessionId) ?? records[0];
|
|
221
|
+
if (record === undefined) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
error: { code: 'not_member', message: 'this instance is not in that group', details: { groupId } },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
if (record.local === true) {
|
|
229
|
+
const saved = groups.setProfile({
|
|
230
|
+
groupId,
|
|
231
|
+
member: { personId: record.personId, personKey: record.personKey },
|
|
232
|
+
about: body.about,
|
|
233
|
+
tags: body.tags,
|
|
234
|
+
});
|
|
235
|
+
// A group-independent headline, when the panel edits the personal profile.
|
|
236
|
+
if (body.headline !== undefined) {
|
|
237
|
+
groups.setPersonProfile({
|
|
238
|
+
groupId,
|
|
239
|
+
member: { personId: record.personId, personKey: record.personKey },
|
|
240
|
+
headline: body.headline,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
await groups.flush();
|
|
244
|
+
} else {
|
|
245
|
+
// A remote group: the host that owns it holds the authority, so the write goes there.
|
|
246
|
+
await bridge.send(
|
|
247
|
+
{
|
|
248
|
+
baseUrl: record.baseUrl,
|
|
249
|
+
apiKey: record.apiKey ?? config.client.apiKey,
|
|
250
|
+
forwardProto: config.client.forwardProto,
|
|
251
|
+
protocolVersion: config.client.protocolVersion,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
skill: 'group/role',
|
|
255
|
+
text: 'set role',
|
|
256
|
+
data: {
|
|
257
|
+
groupId,
|
|
258
|
+
personId: record.personId,
|
|
259
|
+
personKey: record.personKey,
|
|
260
|
+
about: body.about,
|
|
261
|
+
tags: body.tags,
|
|
262
|
+
},
|
|
263
|
+
timeoutMs: config.client.timeoutMs,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
// The headline is a PERSON fact, so it needs its own skill: `group/role` writes one membership,
|
|
267
|
+
// while the value that appears on every colleague card lives on the person row.
|
|
268
|
+
if (body.headline !== undefined) {
|
|
269
|
+
await bridge.send(
|
|
270
|
+
{
|
|
271
|
+
baseUrl: record.baseUrl,
|
|
272
|
+
apiKey: record.apiKey ?? config.client.apiKey,
|
|
273
|
+
forwardProto: config.client.forwardProto,
|
|
274
|
+
protocolVersion: config.client.protocolVersion,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
skill: 'person/profile',
|
|
278
|
+
text: 'set headline',
|
|
279
|
+
data: {
|
|
280
|
+
groupId,
|
|
281
|
+
personId: record.personId,
|
|
282
|
+
personKey: record.personKey,
|
|
283
|
+
headline: body.headline,
|
|
284
|
+
},
|
|
285
|
+
timeoutMs: config.client.timeoutMs,
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// `changed: false` means the value was already that — worth telling the reader, because the feed
|
|
291
|
+
// no longer announces no-op saves and "已保存" for an unchanged value would be misleading.
|
|
292
|
+
return { ok: true, value: { saved: true, changed: saved?.changed !== false, groupId, at: clock() } };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { ok: false, error: { code: 'write_failed', message: describe(error), details: { groupId } } };
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Invite one person to one group, on behalf of the panel.
|
|
300
|
+
*
|
|
301
|
+
* Reuses `person/send` semantics rather than minting an invitation here: two paths that mint invites would
|
|
302
|
+
* eventually disagree about `maxUses` or expiry, and the disagreement would be visible only to whoever got
|
|
303
|
+
* the wrong link.
|
|
304
|
+
*
|
|
305
|
+
* @param {object} payload - `{ groupId, sessionId?, toPersonId }`.
|
|
306
|
+
* @returns {Promise<object>} a Connection result with `{ dmId, online }`.
|
|
307
|
+
*/
|
|
308
|
+
const setPanelInvite = async (payload) => {
|
|
309
|
+
const body = payload !== null && typeof payload === 'object' ? payload : {};
|
|
310
|
+
const groupId = typeof body.groupId === 'string' ? body.groupId.trim() : '';
|
|
311
|
+
const toPersonId = typeof body.toPersonId === 'string' ? body.toPersonId.trim() : '';
|
|
312
|
+
if (groupId === '') {
|
|
313
|
+
return { ok: false, error: { code: 'bad_argument', message: 'groupId is required', details: {} } };
|
|
314
|
+
}
|
|
315
|
+
if (toPersonId === '') {
|
|
316
|
+
return { ok: false, error: { code: 'bad_argument', message: 'toPersonId is required', details: {} } };
|
|
317
|
+
}
|
|
318
|
+
const records = identities.list({ groupId });
|
|
319
|
+
const record = body.sessionId === undefined
|
|
320
|
+
? records[0]
|
|
321
|
+
: records.find((entry) => entry.sessionId === body.sessionId) ?? records[0];
|
|
322
|
+
if (record === undefined) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
error: { code: 'not_member', message: 'this instance is not in that group', details: { groupId } },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
if (record.local === true) {
|
|
330
|
+
const sent = store.sendPersonMessage({
|
|
331
|
+
fromPersonId: record.personId,
|
|
332
|
+
toPersonId,
|
|
333
|
+
kind: 'group-invite',
|
|
334
|
+
text: '',
|
|
335
|
+
payload: undefined,
|
|
336
|
+
});
|
|
337
|
+
await store.flush();
|
|
338
|
+
return { ok: true, value: { dmId: sent.dmId, online: store.isPersonOnline(toPersonId) } };
|
|
339
|
+
}
|
|
340
|
+
const result = await bridge.send(
|
|
341
|
+
{
|
|
342
|
+
baseUrl: record.baseUrl,
|
|
343
|
+
apiKey: record.apiKey ?? config.client.apiKey,
|
|
344
|
+
forwardProto: config.client.forwardProto,
|
|
345
|
+
protocolVersion: config.client.protocolVersion,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
skill: 'person/send',
|
|
349
|
+
text: 'send group-invite to ' + toPersonId,
|
|
350
|
+
data: {
|
|
351
|
+
groupId,
|
|
352
|
+
personId: record.personId,
|
|
353
|
+
personKey: record.personKey,
|
|
354
|
+
fromPersonId: record.personId,
|
|
355
|
+
toPersonId,
|
|
356
|
+
kind: 'group-invite',
|
|
357
|
+
},
|
|
358
|
+
timeoutMs: config.client.timeoutMs,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
const meta = result.data ?? {};
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
value: {
|
|
365
|
+
dmId: typeof meta.dmId === 'string' ? meta.dmId : '',
|
|
366
|
+
online: meta.online === true,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// Reported as a failed SEND, distinct from a failed invite: the person is told the invitation did not go
|
|
371
|
+
// out rather than being left to assume it did.
|
|
372
|
+
return { ok: false, error: { code: 'send_failed', message: describe(error), details: { groupId, toPersonId } } };
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const publishGroups = () => {
|
|
377
|
+
try {
|
|
378
|
+
const rows = [];
|
|
379
|
+
for (const record of identities.list()) {
|
|
380
|
+
const hostedHere = groups.has?.(record.groupId) === true;
|
|
381
|
+
const summary = hostedHere ? groups.snapshot({ groupId: record.groupId }) : undefined;
|
|
382
|
+
const lastSeq = summary?.lastSeq ?? record.lastSeq ?? 0;
|
|
383
|
+
|
|
384
|
+
// Only the routing facts of the last entry, never its words.
|
|
385
|
+
//
|
|
386
|
+
// This snapshot goes into the page HTML, which a proxy may cache, a screenshot may capture and
|
|
387
|
+
// an extension may read. A member is entitled to read the group; that is not the same as the
|
|
388
|
+
// body being written into every page render. It also used to leak ACROSS conversations: the
|
|
389
|
+
// snapshot is global while groups are bound per Session, so one conversation's messages showed
|
|
390
|
+
// up in another's page.
|
|
391
|
+
//
|
|
392
|
+
// What remains answers "should I open this?": who spoke, when, and how much is unread. Reading
|
|
393
|
+
// the actual feed is a deliberate act — `a2a_group_read` with a member credential — which is a
|
|
394
|
+
// different thing from every page load carrying the content.
|
|
395
|
+
const tail = summary === undefined
|
|
396
|
+
? (Array.isArray(record.feed) ? record.feed : [])
|
|
397
|
+
: (summary.feedTail ?? []);
|
|
398
|
+
const lastEntry = tail.length === 0 ? undefined : tail[tail.length - 1];
|
|
399
|
+
const lastFromName = lastEntry === undefined
|
|
400
|
+
? ''
|
|
401
|
+
: (typeof lastEntry.from === 'string' ? lastEntry.from : (lastEntry.from?.displayName ?? ''));
|
|
402
|
+
const lastIsSystem = lastEntry !== undefined && lastEntry.kind === 'system';
|
|
403
|
+
|
|
404
|
+
rows.push({
|
|
405
|
+
id: record.groupId,
|
|
406
|
+
groupId: record.groupId,
|
|
407
|
+
// Explicit so the panel can tell a membership row from a directory row without
|
|
408
|
+
// inferring it from the presence of a role.
|
|
409
|
+
joined: true,
|
|
410
|
+
discovered: false,
|
|
411
|
+
name: summary?.name ?? record.groupName ?? record.groupId,
|
|
412
|
+
topic: summary?.topic ?? '',
|
|
413
|
+
role: summary?.owner?.personId === record.personId ? 'owner' : record.role ?? 'member',
|
|
414
|
+
// A remote group has no local snapshot, so the counts come from what the
|
|
415
|
+
// member-side tool cached after calling group/info. Zero would read as
|
|
416
|
+
// \"this group is empty\" rather than \"not fetched yet\", which is the difference
|
|
417
|
+
// between a wrong answer and a missing one.
|
|
418
|
+
memberCount: summary?.memberCount ?? record.memberCount ?? 0,
|
|
419
|
+
onlineCount: summary?.onlineCount ?? record.onlineCount ?? 0,
|
|
420
|
+
// Unread is a consumer-side cursor: the group's head minus what this
|
|
421
|
+
// member has read. Floored at 0 because system entries (created /
|
|
422
|
+
// joined) can push the head past the stored watermark legitimately.
|
|
423
|
+
unreadCount: Math.max(0, lastSeq - (record.lastSeq ?? 0)),
|
|
424
|
+
lastSeq,
|
|
425
|
+
lastTime: summary?.lastPostAt ?? record.lastTime ?? 0,
|
|
426
|
+
// Which conversation this membership belongs to. The panel renders on the
|
|
427
|
+
// index page, before any Session exists, so it cannot filter by "the current
|
|
428
|
+
// Session" — but it can say which one each group belongs to, which is what
|
|
429
|
+
// lets a person tell two memberships in one group apart.
|
|
430
|
+
sessionId: record.sessionId ?? '',
|
|
431
|
+
// Which person this Session is in this group. The panel needs it to mark
|
|
432
|
+
// "me" in the roster: without it there is no way to tell which row is the
|
|
433
|
+
// reader, and the edit affordance has no target. The line above already
|
|
434
|
+
// reads `record.personId` to decide `role`, so the value is present in the
|
|
435
|
+
// record — it simply was not being passed on.
|
|
436
|
+
personId: record.personId ?? '',
|
|
437
|
+
local: hostedHere,
|
|
438
|
+
// The roster a Session cached earlier, via a2a_group_info / a2a_group_members.
|
|
439
|
+
// Nothing is fetched at render time — this page is built before any Session
|
|
440
|
+
// exists and holds no credential — so an absent roster means "not fetched yet"
|
|
441
|
+
// and `rosterCachedAt` says how old a present one is.
|
|
442
|
+
roster: Array.isArray(record.members) ? record.members : [],
|
|
443
|
+
rosterCachedAt: typeof record.rosterCachedAt === 'number' ? record.rosterCachedAt : 0,
|
|
444
|
+
// Kept for the card's hint: with a cached roster the panel draws people, without
|
|
445
|
+
// one it points at the Session rather than implying the group is empty.
|
|
446
|
+
rosterInSession: !Array.isArray(record.members) || record.members.length === 0,
|
|
447
|
+
// No `feed` array: the panel does not need one, and carrying it wrote message bodies into
|
|
448
|
+
// the page. See the note above the tail extraction.
|
|
449
|
+
lastFromName,
|
|
450
|
+
lastIsSystem,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// Groups hosted here that this instance is not a member of: these are exactly what
|
|
454
|
+
// "可加入" means, and they are readable without a network call because the store is
|
|
455
|
+
// local. A group hosted elsewhere is discovered through a2a_group_directory instead.
|
|
456
|
+
try {
|
|
457
|
+
const joinedIds = new Set(rows.map((row) => row.groupId));
|
|
458
|
+
for (const summary of groups.list() ?? []) {
|
|
459
|
+
if (joinedIds.has(summary.groupId)) continue;
|
|
460
|
+
if ((summary.settings?.visibility ?? 'public') === 'unlisted') continue;
|
|
461
|
+
rows.push({
|
|
462
|
+
id: summary.groupId,
|
|
463
|
+
groupId: summary.groupId,
|
|
464
|
+
joined: false,
|
|
465
|
+
discovered: true,
|
|
466
|
+
name: summary.name,
|
|
467
|
+
topic: summary.topic ?? '',
|
|
468
|
+
role: 'member',
|
|
469
|
+
memberCount: summary.memberCount ?? 0,
|
|
470
|
+
onlineCount: summary.onlineCount ?? 0,
|
|
471
|
+
unreadCount: 0,
|
|
472
|
+
lastSeq: summary.lastSeq ?? 0,
|
|
473
|
+
lastTime: summary.lastPostAt ?? 0,
|
|
474
|
+
sessionId: '',
|
|
475
|
+
local: true,
|
|
476
|
+
rosterInSession: true,
|
|
477
|
+
lastFromName: '',
|
|
478
|
+
lastIsSystem: false,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
// A discover list is a convenience; failing to build it must not blank the panel.
|
|
483
|
+
info(ctx, `a2a: could not list discoverable groups (${describe(error)})`);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
updatedAt: clock(),
|
|
487
|
+
groups: rows,
|
|
488
|
+
// Colleague cards travel with the group list because the panel renders both from one
|
|
489
|
+
// snapshot: a second channel would mean a second way for the two to disagree.
|
|
490
|
+
// The aggregator is a pure function in core/: rosters in, cards out. Keeping it here
|
|
491
|
+
// would make it testable only by mounting the whole plugin.
|
|
492
|
+
// One source: the host's own person table, which already merges stored profiles with
|
|
493
|
+
// membership rows so somebody who joined before the table existed still appears.
|
|
494
|
+
// Deriving cards from memberships instead was a workaround with the wrong shape — one
|
|
495
|
+
// card per group, stitched together from whichever rosters happened to be cached.
|
|
496
|
+
cards: (() => {
|
|
497
|
+
try {
|
|
498
|
+
const people = typeof groups.listPersons === 'function' ? groups.listPersons() : [];
|
|
499
|
+
return people.map((person) => ({
|
|
500
|
+
personId: person.personId,
|
|
501
|
+
name: person.displayName,
|
|
502
|
+
headline: person.headline,
|
|
503
|
+
tags: Array.isArray(person.tags) ? person.tags : [],
|
|
504
|
+
endpoint: typeof person.endpoint === 'string' ? person.endpoint : '',
|
|
505
|
+
// "Mine" is decided by the ledger: it knows which person ids this instance holds.
|
|
506
|
+
// Matching on a display name would mark a stranger's card as mine, and offer an
|
|
507
|
+
// edit that changes the wrong profile.
|
|
508
|
+
isSelf: identities.list().some((record) => record.personId === person.personId),
|
|
509
|
+
// Presence is the one fact a profile cannot carry: it changes by the minute. It is
|
|
510
|
+
// read from the cached rosters, and absence simply means "unknown", not "offline".
|
|
511
|
+
online: identities.list().some((record) => (record.members ?? [])
|
|
512
|
+
.some((member) => member.personId === person.personId && member.online === true)),
|
|
513
|
+
}));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// Cards are reference material; a failure must not blank the panel.
|
|
516
|
+
info(ctx, `a2a: could not build colleague cards (${describe(error)})`);
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
})(),
|
|
520
|
+
unreadTotal: rows.reduce((sum, row) => sum + row.unreadCount, 0),
|
|
521
|
+
};
|
|
522
|
+
} catch (error) {
|
|
523
|
+
// A panel with no data shows its empty state; it must never be a mount
|
|
524
|
+
// failure or a broken page.
|
|
525
|
+
info(ctx, `a2a: could not build the panel snapshot (${describe(error)})`);
|
|
526
|
+
return { updatedAt: clock(), groups: [], cards: [], unreadTotal: 0 };
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// The panel's write path. Client → Host over DSH's own connection layer: same origin, authenticated by
|
|
531
|
+
// the connection transport, and no credential ever placed in the page.
|
|
532
|
+
//
|
|
533
|
+
// Registered defensively because the service is optional: a host without the Connection plugin has no
|
|
534
|
+
// panel, and that must not stop the A2A side from working.
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
// The upstream bug (#8414): registration from a plugin fiber throws "cannot get property webServer
|
|
538
|
+
// without inject" because the registration effect runs on a fiber that lacks the declaration. The
|
|
539
|
+
// discussion notes the ROOT context registers fine, so try the plugin's root before giving up.
|
|
540
|
+
const rootCtx = ctx.root ?? (typeof ctx.get === "function" ? ctx.get("root") : undefined) ?? ctx;
|
|
541
|
+
const connection = (rootCtx.get ?? ctx.get)("connection");
|
|
542
|
+
if (connection === undefined || connection === null || typeof connection.rpc?.handle !== 'function') {
|
|
543
|
+
console.error("[a2a-panel] no connection.rpc available");
|
|
544
|
+
} else {
|
|
545
|
+
const disposePanelRpc = connection.rpc.handle(
|
|
546
|
+
PANEL_RPC_CHANNEL,
|
|
547
|
+
async (endpoint, payload) => {
|
|
548
|
+
// `endpoint` is channel-relative, per the Connection contract, so it arrives without the
|
|
549
|
+
// leading slash the channel itself has.
|
|
550
|
+
const name = String(endpoint ?? '').replace(/^\/+/, '');
|
|
551
|
+
try {
|
|
552
|
+
if (name === 'ping') {
|
|
553
|
+
return { ok: true, value: { pong: true, at: clock() } };
|
|
554
|
+
}
|
|
555
|
+
if (name === 'profile') {
|
|
556
|
+
return await setPanelProfile(payload);
|
|
557
|
+
}
|
|
558
|
+
if (name === 'invite') {
|
|
559
|
+
return await setPanelInvite(payload);
|
|
560
|
+
}
|
|
561
|
+
// `details` is mandatory in ConnectionRpcFailure; omitting it yields a malformed response
|
|
562
|
+
// rather than a clean refusal.
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
error: { code: 'not_found', message: 'unknown panel endpoint: ' + name, details: { endpoint: name } },
|
|
566
|
+
};
|
|
567
|
+
} catch (error) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error: { code: 'panel_failed', message: describe(error), details: {} },
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
if (typeof disposePanelRpc === "function") disposers.push(disposePanelRpc);
|
|
576
|
+
console.error("[a2a-panel] registered " + PANEL_RPC_CHANNEL);
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
// A probe that breaks the mount would be worse than no probe.
|
|
580
|
+
console.error("[a2a-panel] register failed: " + describe(error));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
const disposeInject = ctx.on('webserver/index-inject', (table) => {
|
|
585
|
+
table.push({ kind: 'global', name: '__DSH_A2A_GROUPS__', value: publishGroups() });
|
|
586
|
+
});
|
|
587
|
+
if (typeof disposeInject === 'function') disposers.push(disposeInject);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
info(ctx, `a2a: the browser panel data channel is unavailable (${describe(error)})`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- startup -------------------------------------------------------------
|
|
593
|
+
// The inbox is resumed for every group this instance has joined, so a restart
|
|
594
|
+
// does not silently stop the Session receiving group messages. Failures are
|
|
595
|
+
// logged rather than thrown: a group host that is down must not prevent the
|
|
596
|
+
// plugin from mounting.
|
|
597
|
+
if (settings.groups.enabled) {
|
|
598
|
+
void inbox.resume().catch((error) => {
|
|
599
|
+
info(ctx, `a2a: the group inbox could not resume (${describe(error)}); use a2a_group_inbox on to retry`);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Restore the task store lazily: `flush` on unload keeps a restart from losing
|
|
604
|
+
// the tasks a caller may still poll.
|
|
605
|
+
ctx.on('dispose', () => {
|
|
606
|
+
for (const dispose of disposers) {
|
|
607
|
+
try {
|
|
608
|
+
dispose();
|
|
609
|
+
} catch {
|
|
610
|
+
// A failing disposer must not stop the rest of the unload.
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
void Promise.allSettled([groups.flush(), registry.flush(), identities.flush(), tasks.flush()]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
info(
|
|
617
|
+
ctx,
|
|
618
|
+
`a2a ready (server=${settings.server.enabled}, client=${settings.client.enabled}, `
|
|
619
|
+
+ `groups=${settings.groups.enabled}, tools=${settings.tools}, `
|
|
620
|
+
+ `skills=${skills.ids().join(',') || 'none'}, home=${home})`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** The configuration an operator can set, exported for documentation and tests. */
|
|
625
|
+
export { DEFAULT_CONFIG };
|
|
626
|
+
|
|
627
|
+
/** The package version, read once from the manifest next to this file. */
|
|
628
|
+
function pluginVersion() {
|
|
629
|
+
try {
|
|
630
|
+
// A synchronous read at mount time, not per request: the version cannot
|
|
631
|
+
// change while the process runs, and `ping` is called in hot paths.
|
|
632
|
+
const url = new URL('../package.json', import.meta.url);
|
|
633
|
+
const manifest = JSON.parse(readFileSync(url, 'utf8'));
|
|
634
|
+
return typeof manifest.version === 'string' ? manifest.version : '0.0.0';
|
|
635
|
+
} catch {
|
|
636
|
+
return '0.0.0';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Log one informational line without depending on a particular logger shape. */
|
|
641
|
+
function info(ctx, message) {
|
|
642
|
+
if (typeof ctx.logger?.info === 'function') ctx.logger.info(message);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** One line about an error, without a stack. */
|
|
646
|
+
function describe(error) {
|
|
647
|
+
return error instanceof Error ? error.message : String(error);
|
|
648
|
+
}
|