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/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
+ }