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.
@@ -0,0 +1,1349 @@
1
+ /**
2
+ * Every tool's execution, host-agnostic.
3
+ *
4
+ * The DSH adapter (`tools.js`) and the MCP server (`mcp-server.js`) both call
5
+ * `call(name, args, caller)`. What differs between them is only how they reach this
6
+ * function:
7
+ *
8
+ * DSH → exec.agent.id → caller.sessionId
9
+ * MCP → the connection's identity → caller.sessionId
10
+ *
11
+ * That single mapping is why the logic lives here rather than in either adapter. Two
12
+ * copies of nineteen tool bodies would drift, and the drift would show up as a tool that
13
+ * behaves differently depending on which client called it.
14
+ *
15
+ * Dependencies arrive as parameters rather than as closures over a plugin context: the
16
+ * same reason. A host that is not DSH has no `ctx` to close over.
17
+ *
18
+ * @module dsh-a2a/tool-runtime
19
+ */
20
+
21
+ import { parseInviteLink } from '../dsh/tools.js';
22
+ import { probeReachability } from '../core/probe.js';
23
+ // The member's own address, composed from the MEMBER's config — this file runs on the member's
24
+ // machine when it joins, which is the only process that knows this.
25
+ import { ownEndpointFromConfig } from '../core/endpoint.js';
26
+
27
+ /**
28
+ * Build the runtime over one set of dependencies.
29
+ *
30
+ * @param {object} deps - `{ config, registry, bridge, store, identities, server, sessions, logger, inbox }`.
31
+ * @returns {{call: (name: string, args: object, caller?: object) => Promise<object>, has: (name: string) => boolean, names: () => string[]}} the runtime.
32
+ */
33
+ export function createToolRuntime(deps = {}) {
34
+ const { config, registry, bridge, store, identities, server, sessions, logger, inbox } = deps;
35
+
36
+ /** A non-empty string argument, or a coded error. */
37
+ function requireText(value, name) {
38
+ if (typeof value !== 'string' || value.trim() === '') {
39
+ throw coded('invalid_arguments', `"${name}" must be a non-empty string`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ /** Parse an optional JSON-object argument supplied as a string. */
45
+ function parseJsonObject(value) {
46
+ if (value === undefined) return undefined;
47
+ if (typeof value !== 'string') return value;
48
+ try {
49
+ const parsed = JSON.parse(value);
50
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
51
+ } catch {
52
+ throw coded('invalid_arguments', '"data" must be a JSON object');
53
+ }
54
+ }
55
+
56
+ /** An error carrying a stable code. */
57
+ function coded(code, message) {
58
+ const error = new Error(`[${code}] ${message}`);
59
+ error.code = code;
60
+ return error;
61
+ }
62
+
63
+ /** Shorten a string for a one-line summary. */
64
+ function truncate(text, limit) {
65
+ const single = String(text).replace(/\s+/g, ' ').trim();
66
+ return single.length <= limit ? single : `${single.slice(0, limit - 1)}…`;
67
+ }
68
+
69
+ /** Join a gateway base and an agent name. */
70
+ function joinUrl(base, agent) {
71
+ return `${String(base).replace(/\/+$/, '')}/${String(agent).replace(/^\/+/, '')}`;
72
+ }
73
+
74
+ /**
75
+ * Resolve a tool's `agent` argument to a bridge target.
76
+ *
77
+ * A raw URL and a registered name are both accepted because a user who pastes a
78
+ * link should not have to register it first.
79
+ */
80
+ function resolveTarget(registry, config, reference) {
81
+ const text = requireText(reference, 'agent');
82
+ if (/^https?:\/\//i.test(text)) {
83
+ return {
84
+ baseUrl: text,
85
+ apiKey: config.client.apiKey,
86
+ forwardProto: config.client.forwardProto,
87
+ protocolVersion: config.client.protocolVersion,
88
+ };
89
+ }
90
+ const entry = registry.get(text);
91
+ if (entry === undefined) {
92
+ throw coded('unknown_agent', `"${text}" is not a registered A2A agent; run a2a_agents to list them, or pass a full URL`);
93
+ }
94
+ return {
95
+ baseUrl: entry.baseUrl,
96
+ apiKey: entry.apiKey ?? config.client.apiKey,
97
+ forwardProto: config.client.forwardProto,
98
+ protocolVersion: entry.protocolVersion ?? config.client.protocolVersion,
99
+ };
100
+ }
101
+
102
+ /** The bridge target for a joined (remote) membership. */
103
+ /**
104
+ * The full address of a group host, from the two facts an invite link carries.
105
+ *
106
+ * `gw` is the gateway root and `agent` is the entry under it; the callable address is both. Keeping only
107
+ * `gw` produces a path the gateway answers 404 for, because routing happens on `/a2a/<agent>`.
108
+ *
109
+ * An absent agent is left alone rather than guessed: a link that names no agent may be pointing straight
110
+ * at a host (local development), where `gw` alone IS the address.
111
+ *
112
+ * @param {string} [base] the gateway base, e.g. `http://gw:3000/a2a`.
113
+ * @param {string} [agent] the agent name, e.g. `agent-group-host`.
114
+ * @returns {string|undefined} the address to call.
115
+ */
116
+ function composeMemberBase(base, agent) {
117
+ const trimmed = typeof base === 'string' ? base.replace(/\/+$/u, '') : '';
118
+ if (trimmed === '') return undefined;
119
+ const name = typeof agent === 'string' ? agent.trim() : '';
120
+ if (name === '') return trimmed;
121
+ // Already carries its agent (a hand-written `url` link, or a re-join of an older record).
122
+ if (trimmed.endsWith('/' + name)) return trimmed;
123
+ return trimmed + '/' + name;
124
+ }
125
+
126
+ function targetOf(membership, config) {
127
+ return {
128
+ baseUrl: membership.baseUrl ?? config.groups.gatewayBase,
129
+ apiKey: config.client.apiKey,
130
+ forwardProto: config.client.forwardProto,
131
+ protocolVersion: config.client.protocolVersion,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The host a group lives on, for the paths that have no membership record yet.
137
+ *
138
+ * `a2a_group_request` runs BEFORE the applicant is a member, so there is no
139
+ * `memberships.json` row to read a `baseUrl` from. The address therefore comes from
140
+ * the caller (a registered agent name or a URL) or, failing that, from the
141
+ * configured group host. This is the one place the plugin has to guess, and it
142
+ * guesses only where the config says the host is.
143
+ */
144
+ function hostTarget(registry, config, reference) {
145
+ if (typeof reference === 'string' && reference !== '') {
146
+ return resolveTarget(registry, config, reference);
147
+ }
148
+ if (config.groups.gatewayBase === '' || config.groups.agentName === '') {
149
+ throw coded(
150
+ 'missing_endpoint',
151
+ 'this tool needs to know which host to talk to: pass `agent`, or configure groups.gatewayBase + groups.agentName.',
152
+ );
153
+ }
154
+ return {
155
+ baseUrl: joinUrl(config.groups.gatewayBase, config.groups.agentName),
156
+ apiKey: config.client.apiKey,
157
+ forwardProto: config.client.forwardProto,
158
+ protocolVersion: config.client.protocolVersion,
159
+ };
160
+ }
161
+
162
+ /** The gateway entry this instance needs, as YAML an operator can paste. */
163
+ function gatewayEntryFor(info, config) {
164
+ return [
165
+ 'a2a_agents:',
166
+ ` - name: "${config.groups.agentName || 'my-dsh'}"`,
167
+ ` url: "${info.url}"`,
168
+ ` protocol_version: "${info.protocolVersion}"`,
169
+ ' auth_type: api_key',
170
+ ` secret: "${info.tokenConfigured ? '<server.token from this profile>' : '<set server.token first, or this only works from loopback>'}"`,
171
+ ' timeout_ms: 600000',
172
+ ' enabled: true',
173
+ ].join('\n');
174
+ }
175
+
176
+ /**
177
+ * A local membership record for THIS Session, or a coded error.
178
+ *
179
+ * The Session is required: two Sessions of one instance may both be members of the
180
+ * same group, and picking "the first record" would administer somebody else's
181
+ * membership.
182
+ */
183
+ function requireLocalMembership(identities, groupId, sessionId) {
184
+ const record = identities.get(groupId, sessionId);
185
+ if (record === undefined) {
186
+ throw coded('unknown_group', `this Session has not joined "${groupId}"; run a2a_groups to list the groups it belongs to`);
187
+ }
188
+ if (record.local !== true) {
189
+ throw coded('not_hosted_here', `"${groupId}" is hosted elsewhere; only this instance's own groups can be administered here`);
190
+ }
191
+ return record;
192
+ }
193
+
194
+ /** A membership record for any group, hosted here or joined elsewhere, for THIS Session. */
195
+ function requireMembership(store, identities, groupId, sessionId) {
196
+ const record = identities.get(groupId, sessionId);
197
+ if (record !== undefined) return record;
198
+ // A group hosted by this instance but not in the ledger should not happen (the
199
+ // ledger is written when the group is created), and guessing a credential here
200
+ // would be worse than saying so.
201
+ if (store.has?.(groupId) === true) {
202
+ throw coded('missing_identity', `"${groupId}" is hosted here but this Session has no stored credential for it; join it again`);
203
+ }
204
+ throw coded('unknown_group', `"${groupId}" is not one of this Session's groups; run a2a_groups to list them`);
205
+ }
206
+
207
+ /** Hosted-group summaries as a map, tolerant of a store that cannot list. */
208
+ function safeList(store, identities) {
209
+ const map = new Map();
210
+ try {
211
+ for (const group of store.list() ?? []) map.set(group.groupId, group);
212
+ } catch (error) {
213
+ loggerless(error);
214
+ }
215
+ void identities;
216
+ return map;
217
+ }
218
+
219
+ /** A one-line summary of the last message in a group summary. */
220
+ function lastMessageOf(summary, record) {
221
+ const entry = summary?.feedTail?.at?.(-1);
222
+ if (entry !== undefined && typeof entry.text === 'string') {
223
+ return `${entry.from?.displayName ?? '?'}: ${truncate(entry.text, 120)}`;
224
+ }
225
+ void record;
226
+ return '';
227
+ }
228
+
229
+ /** The registry as the flat list the tool returns. */
230
+ function listEntries(registry) {
231
+ return registry.list().map((entry) => ({
232
+ name: entry.name,
233
+ baseUrl: entry.baseUrl,
234
+ enabled: entry.enabled !== false,
235
+ note: entry.note ?? '',
236
+ }));
237
+ }
238
+
239
+ /** Swallow a diagnostic-only failure; the caller already has a fallback. */
240
+ function loggerless() {}
241
+
242
+ /** A one-line summary of the last message in a group summary. */
243
+ function lastMessageOf(summary, record) {
244
+ const entry = summary?.feedTail?.at?.(-1);
245
+ if (entry !== undefined && typeof entry.text === 'string') {
246
+ return `${entry.from?.displayName ?? '?'}: ${truncate(entry.text, 120)}`;
247
+ }
248
+ void record;
249
+ return '';
250
+ }
251
+
252
+
253
+ /**
254
+ * The display name to record for the caller when they did not supply one.
255
+ *
256
+ * A display name is shown to every member in the group feed, so it has to read as a person.
257
+ * A Session id does not: `session-1f3a9c` in a member list looks like a bug, and it also
258
+ * leaks an internal identifier to everybody in the group.
259
+ *
260
+ * Order: an explicit name the adapter knows, then the store default. Returning `undefined`
261
+ * rather than throwing is deliberate — creating a group must not fail because nobody has
262
+ * named themselves yet, and the owner can rename themselves afterwards.
263
+ *
264
+ * @param {object} exec - the tool execution context from the host adapter.
265
+ * @returns {string|undefined} a name, or undefined to let the store default apply.
266
+ */
267
+ function displayNameOf(exec) {
268
+ if (exec === null || typeof exec !== 'object') return undefined;
269
+ // Adapters differ in where they expose the signed-in person; take the first usable one.
270
+ const candidates = [
271
+ exec.displayName,
272
+ exec.agent?.displayName,
273
+ exec.agent?.name,
274
+ exec.user?.displayName,
275
+ exec.user?.name,
276
+ ];
277
+ for (const candidate of candidates) {
278
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
279
+ }
280
+ // Undefined means "use the store default", keeping that decision in one place.
281
+ return undefined;
282
+ }
283
+
284
+ /**
285
+ * The gateway agent name this host is registered under, if configured.
286
+ *
287
+ * Used only to word the "next steps" hint after creating a group, whose point is that a new
288
+ * group needs no new gateway entry. An unconfigured host gets `undefined`, so the caller words
289
+ * the hint without a name instead of printing "undefined".
290
+ *
291
+ * @param {object} config - normalized config.
292
+ * @returns {string|undefined} the agent name.
293
+ */
294
+ function settingsAgentName(config) {
295
+ const name = config?.groups?.agentName;
296
+ return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
297
+ }
298
+
299
+ /**
300
+ * The caller behind a tool invocation.
301
+ *
302
+ * Hosts differ in how they name the caller: DSH puts a Session id at `exec.agent.id`,
303
+ * while an MCP connection has an id of its own and no Session at all. Both are reduced to
304
+ * `{ id }` here, so every tool body can stay host-agnostic.
305
+ *
306
+ * A tool that genuinely needs a Session (all the group mutators do, because a membership
307
+ * belongs to one) calls `requireSession`, which explains itself when the host cannot
308
+ * supply one.
309
+ *
310
+ * @param {object} exec - `{ agent?: { id }, sessionId?: string }`.
311
+ * @returns {{id: string|undefined}} the caller.
312
+ */
313
+ function senderOf(exec) {
314
+ if (exec === null || typeof exec !== 'object') return { id: undefined };
315
+ if (typeof exec.sessionId === 'string' && exec.sessionId !== '') return { id: exec.sessionId };
316
+ const agent = exec.agent;
317
+ const id = agent !== null && typeof agent === 'object' && typeof agent.id === 'string' ? agent.id : undefined;
318
+ return { id };
319
+ }
320
+
321
+ /**
322
+ * A tool call must name its caller.
323
+ *
324
+ * Every group operation is scoped to one conversation, and a membership belongs to one —
325
+ * so a host that cannot say who is calling cannot be served correctly. Failing loudly
326
+ * beats guessing: a wrong guess would act on somebody else's membership.
327
+ *
328
+ * @param {string|undefined} sessionId - the caller's identity.
329
+ * @param {string} tool - tool name, for the message.
330
+ * @returns {string} the id.
331
+ */
332
+ function requireSession(sessionId, tool) {
333
+ if (typeof sessionId !== 'string' || sessionId === '') {
334
+ throw coded('no_session', `${tool} needs to know which conversation is calling, and the host did not provide one`);
335
+ }
336
+ return sessionId;
337
+ }
338
+
339
+ /**
340
+ * The caller acting through this tool.
341
+ *
342
+ * `sessionId` is what a DSH Session contributes; a host without Sessions passes a
343
+ * stable id of its own (an MCP connection's id, say), which is enough because the group
344
+ * tools only need something consistent to key a membership by.
345
+ */
346
+ const normaliseCaller = (caller) => {
347
+ if (caller === null || typeof caller !== 'object') return { sessionId: undefined };
348
+ return { sessionId: typeof caller.sessionId === 'string' ? caller.sessionId : undefined };
349
+ };
350
+
351
+ /** Every tool body, keyed by name. */
352
+ const HANDLERS = {
353
+ 'a2a_agents': async function a2a_agents(args, exec) {
354
+ const action = args.action ?? 'list';
355
+ if (action === 'add') {
356
+ const name = requireText(args.name, 'name');
357
+ const entry = args.gatewayBase !== undefined && args.agent !== undefined
358
+ ? { name, baseUrl: joinUrl(args.gatewayBase, args.agent), note: args.note, apiKey: args.apiKey }
359
+ : { name, baseUrl: requireText(args.baseUrl, 'baseUrl, or gatewayBase together with agent'), note: args.note, apiKey: args.apiKey };
360
+ registry.upsert(entry);
361
+ await registry.flush();
362
+ return { agents: listEntries(registry), changed: `added ${name}` };
363
+ }
364
+ if (action === 'remove') {
365
+ const name = requireText(args.name, 'name');
366
+ const removed = registry.remove(name);
367
+ await registry.flush();
368
+ return { agents: listEntries(registry), changed: removed ? `removed ${name}` : `${name} was not registered` };
369
+ }
370
+ return { agents: listEntries(registry) };
371
+ },
372
+ 'a2a_discover': async function a2a_discover(args, exec) {
373
+ const target = resolveTarget(registry, config, args.agent);
374
+ const card = await bridge.discover(target);
375
+ return {
376
+ name: card.name,
377
+ url: card.url,
378
+ protocolVersion: card.protocolVersion ?? config.client.protocolVersion,
379
+ streaming: card.capabilities?.streaming === true,
380
+ skills: (card.skills ?? []).map((skill) => ({
381
+ id: skill.id,
382
+ name: skill.name ?? skill.id,
383
+ description: skill.description ?? '',
384
+ })),
385
+ };
386
+ },
387
+ 'a2a_send': async function a2a_send(args, exec) {
388
+ const target = resolveTarget(registry, config, args.agent);
389
+ const result = await bridge.send(target, {
390
+ skill: args.skill ?? 'session/ask',
391
+ text: args.message,
392
+ data: parseJsonObject(args.data),
393
+ timeoutMs: args.timeoutMs ?? config.client.timeoutMs,
394
+ from: senderOf(exec).id,
395
+ });
396
+ return { state: result.state, text: result.text, taskId: result.taskId };
397
+ },
398
+ 'a2a_stream': async function a2a_stream(args, exec) {
399
+ const target = resolveTarget(registry, config, args.agent);
400
+ const events = [];
401
+ const result = await bridge.stream(target, {
402
+ skill: args.skill ?? 'session/ask',
403
+ text: args.message,
404
+ data: parseJsonObject(args.data),
405
+ from: senderOf(exec).id,
406
+ onEvent: (event) => {
407
+ if (event?.type === 'artifact' || event?.type === 'status') events.push(event);
408
+ },
409
+ });
410
+ const printed = events
411
+ .filter((event) => event.type === 'status' && typeof event.note === 'string' && event.note !== '')
412
+ .map((event) => `· ${event.note}`);
413
+ return {
414
+ state: result.state,
415
+ text: printed.length === 0 ? result.text : `${printed.join('\n')}\n\n${result.text}`,
416
+ events: events.length,
417
+ };
418
+ },
419
+ 'a2a_person_send': async function a2a_person_send(args, exec) {
420
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_person_send');
421
+ const to = requireText(args.to, 'to');
422
+ const kind = requireText(args.kind, 'kind');
423
+ // The sender is whoever this Session is in the group the message is about. For an invitation that
424
+ // group is required, because the credential comes from it and the link is scoped to it.
425
+ const groupId = typeof args.groupId === 'string' && args.groupId !== ''
426
+ ? args.groupId
427
+ : requireText(args.groupId, 'groupId for a group invitation');
428
+ const membership = requireMembership(store, identities, groupId, sessionId);
429
+ if (membership.local === true) {
430
+ const sent = store.sendPersonMessage({
431
+ fromPersonId: membership.personId,
432
+ toPersonId: to,
433
+ kind,
434
+ text: args.text ?? '',
435
+ payload: undefined,
436
+ });
437
+ await store.flush();
438
+ return { dmId: sent.dmId, online: store.isPersonOnline(to), kind };
439
+ }
440
+ const result = await bridge.send(targetOf(membership, config), {
441
+ skill: 'person/send',
442
+ text: `send ${kind} to ${to}`,
443
+ data: {
444
+ groupId,
445
+ personId: membership.personId,
446
+ personKey: membership.personKey,
447
+ fromPersonId: membership.personId,
448
+ toPersonId: to,
449
+ kind,
450
+ text: args.text,
451
+ },
452
+ timeoutMs: config.client.timeoutMs,
453
+ });
454
+ const meta = result.data ?? {};
455
+ return {
456
+ dmId: typeof meta.dmId === 'string' ? meta.dmId : '',
457
+ online: meta.online === true,
458
+ kind,
459
+ };
460
+ },
461
+
462
+ 'a2a_person_inbox': async function a2a_person_inbox(args, exec) {
463
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_person_inbox');
464
+ // Any membership works for reaching the host: it is the credential, and the inbox is keyed by
465
+ // person, not by group.
466
+ const membership = identities.list({ sessionId })[0];
467
+ if (membership === undefined) {
468
+ throw coded('not_member', '这个会话还没有加入任何协作群,因此没有收件箱。');
469
+ }
470
+ if (membership.local === true) {
471
+ const messages = store.listPersonMessages({ personId: membership.personId, since: args.since });
472
+ return { messages };
473
+ }
474
+ const result = await bridge.send(targetOf(membership, config), {
475
+ skill: 'person/inbox',
476
+ text: 'inbox',
477
+ data: { personId: membership.personId, since: args.since },
478
+ timeoutMs: config.client.timeoutMs,
479
+ });
480
+ const meta = result.data ?? {};
481
+ return { messages: Array.isArray(meta.messages) ? meta.messages : [] };
482
+ },
483
+
484
+ 'a2a_person_ack': async function a2a_person_ack(args, exec) {
485
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_person_ack');
486
+ const dmIds = Array.isArray(args.dmIds) ? args.dmIds.filter((id) => typeof id === 'string') : [];
487
+ if (dmIds.length === 0) throw coded('bad_argument', 'Nothing to confirm: dmIds is empty.');
488
+ const membership = identities.list({ sessionId })[0];
489
+ if (membership === undefined) {
490
+ throw coded('not_member', '这个会话还没有加入任何协作群,因此没有收件箱。');
491
+ }
492
+ if (membership.local === true) {
493
+ const acked = store.ackPersonMessages({ personId: membership.personId, dmIds });
494
+ await store.flush();
495
+ return acked;
496
+ }
497
+ const result = await bridge.send(targetOf(membership, config), {
498
+ skill: 'person/ack',
499
+ text: 'ack',
500
+ data: { personId: membership.personId, dmIds },
501
+ timeoutMs: config.client.timeoutMs,
502
+ });
503
+ const meta = result.data ?? {};
504
+ return {
505
+ acked: Number.isFinite(meta.acked) ? meta.acked : 0,
506
+ unknown: Array.isArray(meta.unknown) ? meta.unknown : [],
507
+ };
508
+ },
509
+
510
+ 'a2a_pending': async function a2a_pending(args, exec) {
511
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_pending');
512
+ const includeGroups = args.includeGroups !== false;
513
+
514
+ // Direct messages first: an invitation blocks progress, a group message does not.
515
+ const directMessages = [];
516
+ const memberships = identities.list({ sessionId });
517
+ if (memberships.length > 0) {
518
+ const first = memberships[0];
519
+ try {
520
+ if (first.local === true) {
521
+ for (const message of store.listPersonMessages({ personId: first.personId })) {
522
+ // Only unconfirmed ones count as pending: a message already acked has been delivered and read.
523
+ if (message.deliveredAt !== undefined) continue;
524
+ directMessages.push({ dmId: message.dmId, kind: message.kind, text: message.text });
525
+ }
526
+ } else {
527
+ const result = await bridge.send(targetOf(first, config), {
528
+ skill: 'person/inbox',
529
+ text: 'inbox',
530
+ data: { personId: first.personId },
531
+ timeoutMs: config.client.timeoutMs,
532
+ });
533
+ const list = Array.isArray(result?.data?.messages) ? result.data.messages : [];
534
+ for (const message of list) {
535
+ if (message.deliveredAt !== undefined) continue;
536
+ directMessages.push({ dmId: message.dmId, kind: message.kind, text: message.text });
537
+ }
538
+ }
539
+ } catch {
540
+ // An unreachable host must not make this probe throw: the whole point is that it is cheap and safe to
541
+ // call often. An empty result is honest — it says nothing about pending count, and the caller can
542
+ // still act on groups.
543
+ }
544
+ }
545
+
546
+ // Then groups with unread messages. Unread is "the host's head minus what this Session last read", which
547
+ // is already how the panel counts it — computed the same way here so the two agree.
548
+ const groups = [];
549
+ if (includeGroups === true) {
550
+ for (const record of memberships) {
551
+ if (record.local !== true) {
552
+ // A remote group's head is only known after a read; without one there is no honest unread count, and
553
+ // inventing zero would hide new messages. Reported as unknown rather than as zero.
554
+ continue;
555
+ }
556
+ let head = 0;
557
+ let name = record.groupName ?? record.groupId;
558
+ try {
559
+ const summary = store.snapshot({ groupId: record.groupId, member: { personId: record.personId, personKey: record.personKey } });
560
+ head = summary.lastSeq ?? 0;
561
+ name = summary.name ?? name;
562
+ } catch {
563
+ continue;
564
+ }
565
+ const seen = Number.isFinite(record.lastSeq) ? record.lastSeq : 0;
566
+ const unread = Math.max(0, head - seen);
567
+ if (unread > 0) groups.push({ groupId: record.groupId, name, unread });
568
+ }
569
+ }
570
+
571
+ return {
572
+ total: directMessages.length + groups.reduce((sum, entry) => sum + entry.unread, 0),
573
+ directMessages,
574
+ groups,
575
+ summary: directMessages.length === 0 && groups.length === 0
576
+ ? 'Nothing waiting.'
577
+ : `${directMessages.length} direct message(s), ${groups.length} group(s) with unread.`,
578
+ };
579
+ },
580
+
581
+ 'a2a_group_create': async function a2a_group_create(args, exec) {
582
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_create');
583
+ const name = requireText(args.name, 'name');
584
+ const topic = args.topic ?? '';
585
+ const visibility = args.visibility === 'unlisted' ? 'unlisted' : 'public';
586
+
587
+ // A local store with groups enabled means this instance is itself a host. That
588
+ // is the rarer setup now, but it stays supported: a single machine can host its
589
+ // own groups with no gateway at all.
590
+ const hostedHere = config.groups.embedded === true;
591
+ const target = hostedHere ? undefined : hostTarget(registry, config, args.agent);
592
+
593
+ let groupId;
594
+ let personId;
595
+ let ownerKey;
596
+ let groupName;
597
+ let remote;
598
+
599
+ if (hostedHere) {
600
+ const created = store.create({
601
+ name,
602
+ topic,
603
+ ownerDisplayName: displayNameOf(exec),
604
+ settings: { visibility },
605
+ });
606
+ groupId = created.group.groupId;
607
+ personId = created.group.owner.personId;
608
+ ownerKey = created.ownerKey;
609
+ groupName = created.group.name;
610
+ remote = false;
611
+ } else {
612
+ const result = await bridge.send(target, {
613
+ skill: 'group/create',
614
+ text: `create a group named ${name}`,
615
+ data: {
616
+ name,
617
+ topic,
618
+ ownerDisplayName: displayNameOf(exec),
619
+ // The skill reads this from the top level, not from a nested settings bag.
620
+ visibility,
621
+ },
622
+ timeoutMs: config.client.timeoutMs,
623
+ });
624
+ const meta = result.data ?? {};
625
+ groupId = meta.groupId;
626
+ personId = meta.personId;
627
+ ownerKey = meta.ownerKey;
628
+ groupName = meta.name ?? name;
629
+ remote = true;
630
+ if (typeof groupId !== 'string' || groupId === '') {
631
+ throw coded('bad_response', 'the group host did not return a groupId; check that the host runs a compatible agent-groups version');
632
+ }
633
+ }
634
+
635
+ // The membership record belongs to the Session that created the group: it is the
636
+ // owner, and it is where invite/approve calls will be issued from.
637
+ identities.upsert({
638
+ sessionId,
639
+ groupId,
640
+ personId,
641
+ personKey: ownerKey,
642
+ local: remote === false,
643
+ baseUrl: remote === true ? target.baseUrl : undefined,
644
+ groupName,
645
+ role: 'owner',
646
+ lastSeq: 0,
647
+ });
648
+ await identities.flush();
649
+ if (remote === false) await store.flush();
650
+
651
+ const gatewayName = settingsAgentName(config);
652
+ return {
653
+ groupId,
654
+ name: groupName,
655
+ personId,
656
+ ownerKey,
657
+ agentName: gatewayName,
658
+ remote,
659
+ nextSteps: remote === true
660
+ ? 'Next: a2a_group_invite to mint an invite link and a paste-ready message for a colleague.'
661
+ + `\nThe host serves every group under one gateway entry (${gatewayName ?? 'not configured'}); a new group needs no gateway change.`
662
+ : 'Next: a2a_group_invite to mint an invite link and a paste-ready message for a colleague.'
663
+ + '\nThis group lives on this instance, so colleagues reach it directly or through a gateway you configure.',
664
+ };
665
+ },
666
+ 'a2a_group_invite': async function a2a_group_invite(args, exec) {
667
+ const groupId = requireText(args.groupId, 'groupId');
668
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_invite');
669
+ const membership = identities.get(groupId, sessionId);
670
+ if (membership === undefined) {
671
+ throw coded('unknown_group', `this Session has not joined "${groupId}"; run a2a_groups to list the groups it belongs to`);
672
+ }
673
+ // A group hosted elsewhere: the host owns the token table, so it mints. The owner credential travels
674
+ // with the call and the host verifies it — this instance does not gain authority it does not have.
675
+ if (membership.local !== true) {
676
+ const target = targetOf(membership, config);
677
+ // invite via bridge: the host is the only process that can mint a link it will itself accept.
678
+ const result = await bridge.send(target, {
679
+ skill: 'group/invite',
680
+ text: 'invite for ' + groupId,
681
+ data: {
682
+ groupId,
683
+ personId: membership.personId,
684
+ personKey: membership.personKey,
685
+ label: args.label,
686
+ expiresInHours: args.expiresInHours,
687
+ },
688
+ timeoutMs: config.client.timeoutMs,
689
+ });
690
+ const meta = result.data ?? {};
691
+ const link = typeof meta.link === 'string' ? meta.link : '';
692
+ if (link === '') {
693
+ throw coded('bad_response', 'the host did not return an invite link');
694
+ }
695
+ return {
696
+ groupId,
697
+ link,
698
+ prompt: typeof meta.prompt === 'string' ? meta.prompt : link,
699
+ expiresAt: meta.expiresAt,
700
+ inviteId: meta.inviteId,
701
+ };
702
+ }
703
+
704
+ const issued = store.invite({
705
+ groupId,
706
+ label: args.label,
707
+ expiresInHours: args.expiresInHours ?? config.groups.inviteExpiresInHours,
708
+ // The owner credential, not just the role: the store verifies it, so a
709
+ // display-field role being wrong cannot mint an invite.
710
+ ownerKey: membership.personKey,
711
+ gatewayBase: config.groups.gatewayBase === '' ? undefined : config.groups.gatewayBase,
712
+ agent: config.groups.agentName === '' ? undefined : config.groups.agentName,
713
+ });
714
+ await store.flush();
715
+ return {
716
+ link: issued.link,
717
+ prompt: issued.prompt,
718
+ expiresAt: new Date(issued.expiresAt).toISOString(),
719
+ neverExpires: issued.expiresAt === null,
720
+ };
721
+ },
722
+ 'a2a_group_join': async function a2a_group_join(args, exec) {
723
+ // The Session that joins is the member: two Sessions of one instance may join
724
+ // the same group as two distinct members, each with its own credential.
725
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_join');
726
+ const displayName = args.displayName ?? senderOf(exec).id ?? 'member';
727
+ // A link carries the gateway target as well as the group, so a remote group
728
+ // needs no separate registration step — that is the point of the link.
729
+ const parsed = typeof args.link === 'string' && args.link !== ''
730
+ ? parseInviteLink(args.link)
731
+ : { groupId: requireText(args.groupId, 'groupId'), token: args.token, baseUrl: undefined, groupName: undefined };
732
+
733
+ const local = store.has?.(parsed.groupId) === true;
734
+ if (local) {
735
+ const joined = store.join({
736
+ groupId: parsed.groupId,
737
+ token: parsed.token,
738
+ displayName,
739
+ // Same reasoning as the remote path: this process knows its own address.
740
+ endpoint: ownEndpointFromConfig(config),
741
+ });
742
+ identities.upsert({
743
+ sessionId,
744
+ groupId: parsed.groupId,
745
+ personId: joined.member.personId,
746
+ personKey: joined.personKey,
747
+ local: true,
748
+ groupName: joined.group.name,
749
+ role: joined.member.role,
750
+ lastSeq: joined.group.lastSeq ?? 0,
751
+ });
752
+ await store.flush();
753
+ await identities.flush();
754
+ return {
755
+ groupId: parsed.groupId,
756
+ groupName: joined.group.name,
757
+ personId: joined.member.personId,
758
+ remote: false,
759
+ recentMessages: joined.feedTail.length,
760
+ };
761
+ }
762
+
763
+ // The link already says which gateway and which agent host the group, so a
764
+ // colleague needs nothing configured on their side: paste the link, say the
765
+ // password. The configured gatewayBase is only a fallback for a hand-written
766
+ // link that omitted it.
767
+ // The link splits the address in two (`gw` + `agent`) because they are two facts; the ledger needs
768
+ // one callable string, so they are joined here rather than at every use site.
769
+ const baseUrl = parsed.endpoint
770
+ ?? composeMemberBase(
771
+ parsed.gatewayBase ?? (config.groups.gatewayBase === '' ? undefined : config.groups.gatewayBase),
772
+ parsed.agent,
773
+ );
774
+ if (baseUrl === undefined || baseUrl === '') {
775
+ throw coded(
776
+ 'missing_endpoint',
777
+ 'this invite link carries no gateway address, and no groups.gatewayBase is configured to fall back on. Ask the group owner for a fresh link.',
778
+ );
779
+ }
780
+ const target = {
781
+ baseUrl,
782
+ apiKey: config.client.apiKey,
783
+ forwardProto: config.client.forwardProto,
784
+ protocolVersion: config.client.protocolVersion,
785
+ };
786
+ const result = await bridge.send(target, {
787
+ skill: 'group/join',
788
+ text: `join ${parsed.groupId}`,
789
+ data: {
790
+ groupId: parsed.groupId,
791
+ token: parsed.token,
792
+ displayName,
793
+ // Our own address, so the host can record where to reach us. Composed here rather than
794
+ // there: the host process only knows its own name, and composing from it recorded every
795
+ // member at the host's address.
796
+ //
797
+ // Omitted when unconfigured, which the host reads as "no address" — the honest answer,
798
+ // since nothing can dial a member that has no gateway name.
799
+ endpoint: ownEndpointFromConfig(config),
800
+ },
801
+ timeoutMs: config.client.timeoutMs,
802
+ });
803
+ const meta = result.data ?? {};
804
+ sessionId,
805
+ identities.upsert({
806
+ sessionId,
807
+ groupId: parsed.groupId,
808
+ personId: meta.personId,
809
+ personKey: meta.personKey,
810
+ local: false,
811
+ baseUrl,
812
+ groupName: meta.groupName ?? parsed.groupName,
813
+ role: 'member',
814
+ lastSeq: meta.lastSeq ?? 0,
815
+ });
816
+ await identities.flush();
817
+ if (inbox !== undefined) await inbox.enable(parsed.groupId);
818
+ return {
819
+ groupId: parsed.groupId,
820
+ groupName: meta.groupName ?? parsed.groupName ?? parsed.groupId,
821
+ personId: meta.personId,
822
+ remote: true,
823
+ recentMessages: Array.isArray(meta.feedTail) ? meta.feedTail.length : 0,
824
+ };
825
+ },
826
+ 'a2a_group_post': async function a2a_group_post(args, exec) {
827
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_post');
828
+ const groupId = requireText(args.groupId, 'groupId');
829
+ const message = requireText(args.message, 'message');
830
+ const membership = requireMembership(store, identities, groupId, sessionId);
831
+ if (membership.local === true) {
832
+ const posted = store.post({
833
+ groupId,
834
+ member: { personId: membership.personId, personKey: membership.personKey },
835
+ text: message,
836
+ });
837
+ await store.flush();
838
+ return { seq: posted.seq, groupId };
839
+ }
840
+ const result = await bridge.send(targetOf(membership, config), {
841
+ skill: 'group/post',
842
+ text: message,
843
+ data: {
844
+ groupId,
845
+ personId: membership.personId,
846
+ personKey: membership.personKey,
847
+ text: message,
848
+ mentions: args.mentions,
849
+ },
850
+ timeoutMs: config.client.timeoutMs,
851
+ });
852
+ return { seq: result.data?.seq ?? 0, groupId };
853
+ },
854
+ 'a2a_group_role': async function a2a_group_role(args, exec) {
855
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_read');
856
+ const groupId = requireText(args.groupId, 'groupId');
857
+ const membership = requireMembership(store, identities, groupId, sessionId);
858
+ // The member credential identifies the caller, so the store can only ever
859
+ // change this caller's own row. There is deliberately no `targetPersonId`:
860
+ // a role is what you say about yourself.
861
+ const data = {
862
+ groupId,
863
+ personId: membership.personId,
864
+ personKey: membership.personKey,
865
+ about: args.about,
866
+ tags: args.tags,
867
+ };
868
+ if (args.displayName !== undefined) data.displayName = args.displayName;
869
+
870
+ if (membership.local === true) {
871
+ const updated = store.setProfile({ groupId, member: data, about: data.about, tags: data.tags });
872
+ await store.flush();
873
+ return { groupId, about: updated.roleAbout ?? '', tags: updated.tags ?? [] };
874
+ }
875
+ const result = await bridge.send(targetOf(membership, config), {
876
+ skill: 'group/role',
877
+ text: `role in ${groupId}`,
878
+ data,
879
+ timeoutMs: config.client.timeoutMs,
880
+ });
881
+ const meta = result.data ?? {};
882
+ return { groupId, about: meta.about ?? '', tags: meta.tags ?? [] };
883
+ },
884
+ 'a2a_group_info': async function a2a_group_info(args, exec) {
885
+ const groupId = requireText(args.groupId, 'groupId');
886
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_info');
887
+ const membership = requireMembership(store, identities, groupId, sessionId);
888
+
889
+ if (membership.local === true) {
890
+ const snapshot = store.snapshot({
891
+ groupId,
892
+ member: { personId: membership.personId, personKey: membership.personKey },
893
+ });
894
+ return {
895
+ groupId,
896
+ name: snapshot.name,
897
+ topic: snapshot.topic,
898
+ memberCount: snapshot.memberCount,
899
+ onlineCount: snapshot.onlineCount,
900
+ lastSeq: snapshot.lastSeq,
901
+ isOwner: snapshot.owner?.personId === membership.personId,
902
+ };
903
+ }
904
+
905
+ const target = targetOf(membership, config);
906
+ const result = await bridge.send(target, {
907
+ skill: 'group/info',
908
+ text: `info for ${groupId}`,
909
+ data: { groupId, personId: membership.personId, personKey: membership.personKey },
910
+ timeoutMs: config.client.timeoutMs,
911
+ });
912
+ const meta = result.data ?? {};
913
+ // Cache everything the panel needs: it reads the ledger, not the network.
914
+ // Without this a remote group keeps showing its id and "0 人", which reads as an
915
+ // empty group rather than one that has simply not been fetched yet.
916
+ identities.upsert({
917
+ sessionId,
918
+ groupId,
919
+ groupName: typeof meta.name === 'string' && meta.name !== '' ? meta.name : undefined,
920
+ groupTopic: typeof meta.topic === 'string' ? meta.topic : undefined,
921
+ memberCount: Number.isFinite(meta.memberCount) ? meta.memberCount : undefined,
922
+ onlineCount: Number.isFinite(meta.onlineCount) ? meta.onlineCount : undefined,
923
+ lastSeq: Number.isFinite(meta.lastSeq) ? meta.lastSeq : undefined,
924
+ lastTime: Number.isFinite(meta.lastPostAt) ? meta.lastPostAt : undefined,
925
+ isOwner: meta.isOwner === true,
926
+ // The roster is what makes a colleague card useful: without roleAbout and tags a card
927
+ // can only say "in N groups", which is the one thing the reader already knows.
928
+ members: Array.isArray(meta.members) ? meta.members : undefined,
929
+ rosterCachedAt: Array.isArray(meta.members) ? Date.now() : undefined,
930
+ });
931
+ await identities.flush();
932
+ return {
933
+ groupId,
934
+ name: meta.name ?? groupId,
935
+ topic: meta.topic ?? '',
936
+ memberCount: Number.isFinite(meta.memberCount) ? meta.memberCount : 0,
937
+ onlineCount: Number.isFinite(meta.onlineCount) ? meta.onlineCount : 0,
938
+ lastSeq: Number.isFinite(meta.lastSeq) ? meta.lastSeq : 0,
939
+ isOwner: meta.isOwner === true,
940
+ rosterCount: Array.isArray(meta.members) ? meta.members.length : 0,
941
+ };
942
+ },
943
+ 'a2a_group_read': async function a2a_group_read(args, exec) {
944
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_role');
945
+ const groupId = requireText(args.groupId, 'groupId');
946
+ const sinceSeq = args.sinceSeq ?? 0;
947
+ const limit = args.limit ?? 50;
948
+ const membership = requireMembership(store, identities, groupId, sessionId);
949
+ if (membership.local === true) {
950
+ const page = store.read({
951
+ groupId,
952
+ member: { personId: membership.personId, personKey: membership.personKey },
953
+ sinceSeq,
954
+ limit,
955
+ });
956
+ return {
957
+ lastSeq: page.lastSeq,
958
+ entries: page.entries.map((entry) => ({
959
+ seq: entry.seq,
960
+ from: entry.from.displayName,
961
+ text: entry.text,
962
+ at: entry.atIso,
963
+ })),
964
+ };
965
+ }
966
+ const result = await bridge.send(targetOf(membership, config), {
967
+ skill: 'group/read',
968
+ text: `read ${groupId} after ${sinceSeq}`,
969
+ data: { groupId, personId: membership.personId, personKey: membership.personKey, sinceSeq, limit },
970
+ });
971
+ const meta = result.data ?? {};
972
+ return {
973
+ lastSeq: meta.lastSeq ?? sinceSeq,
974
+ entries: (meta.entries ?? []).map((entry) => ({
975
+ seq: entry.seq,
976
+ from: entry.from,
977
+ text: entry.text,
978
+ at: entry.atIso ?? entry.at,
979
+ })),
980
+ };
981
+ },
982
+ 'a2a_group_directory': async function a2a_group_directory(args, exec) {
983
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_directory');
984
+ // Default to whatever host this instance already points at: the common case
985
+ // is "what is on OUR host", and requiring an address for that would be noise.
986
+ const reference = args.agent
987
+ ?? (config.groups.agentName !== '' && config.groups.gatewayBase !== ''
988
+ ? joinUrl(config.groups.gatewayBase, config.groups.agentName)
989
+ : undefined);
990
+ const target = resolveTarget(registry, config, requireText(reference, 'agent or groups.gatewayBase+agentName'));
991
+ const result = await bridge.send(target, {
992
+ skill: 'group/directory',
993
+ text: 'list groups',
994
+ data: {
995
+ query: args.query,
996
+ limit: args.limit,
997
+ },
998
+ timeoutMs: config.client.timeoutMs,
999
+ });
1000
+ const meta = result.data ?? {};
1001
+ return {
1002
+ total: meta.total ?? 0,
1003
+ hasMore: meta.hasMore === true,
1004
+ groups: (meta.groups ?? []).map((group) => ({
1005
+ groupId: group.groupId,
1006
+ name: group.name,
1007
+ topic: group.topic ?? '',
1008
+ memberCount: group.memberCount ?? 0,
1009
+ onlineCount: group.onlineCount ?? 0,
1010
+ lastActivityAt: group.lastActivityAtIso ?? '',
1011
+ })),
1012
+ };
1013
+ },
1014
+ 'a2a_groups': async function a2a_groups(args, exec) {
1015
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_groups');
1016
+ // Hosted groups come from the group store; joined ones from the ledger.
1017
+ // Both are "my groups" to a person, so they are merged rather than split.
1018
+ const hosted = safeList(store, identities);
1019
+ const rows = [];
1020
+ for (const record of identities.list({ sessionId })) {
1021
+ if (args.groupId !== undefined && record.groupId !== args.groupId) continue;
1022
+ const summary = hosted.get(record.groupId);
1023
+ rows.push({
1024
+ groupId: record.groupId,
1025
+ name: summary?.name ?? record.groupName ?? record.groupId,
1026
+ role: summary?.owner?.personId === record.personId ? 'owner' : record.role,
1027
+ members: summary?.memberCount ?? 0,
1028
+ lastSeq: summary?.lastSeq ?? record.lastSeq ?? 0,
1029
+ lastMessage: lastMessageOf(summary, record),
1030
+ });
1031
+ }
1032
+ return { groups: rows };
1033
+ },
1034
+ 'a2a_group_inbox': async function a2a_group_inbox(args, exec) {
1035
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_requests');
1036
+ const action = args.action ?? 'status';
1037
+ if (action === 'on') await inbox.enable(args.groupId);
1038
+ if (action === 'off') await inbox.disable(args.groupId);
1039
+ return { enabled: inbox.enabled(), subscriptions: inbox.state() };
1040
+ },
1041
+ 'a2a_group_request': async function a2a_group_request(args, exec) {
1042
+ const groupId = requireText(args.groupId, 'groupId');
1043
+ const displayName = requireText(args.displayName, 'displayName');
1044
+ // Applying happens BEFORE this Session is a member, so there is no membership
1045
+ // record to read an address from: the target comes from the caller or config.
1046
+ if (store.has?.(groupId) === true) {
1047
+ const issued = store.requestJoin({ groupId, displayName, reason: args.reason });
1048
+ await store.flush();
1049
+ return {
1050
+ requestId: issued.requestId,
1051
+ claimKey: issued.claimKey ?? '',
1052
+ groupName: issued.groupName,
1053
+ expiresAt: new Date(issued.expiresAt).toISOString(),
1054
+ duplicate: issued.duplicate === true,
1055
+ };
1056
+ }
1057
+ const target = hostTarget(registry, config, args.agent);
1058
+ const result = await bridge.send(target, {
1059
+ skill: 'group/request',
1060
+ text: `apply to join ${groupId}`,
1061
+ data: { groupId, displayName, reason: args.reason },
1062
+ timeoutMs: config.client.timeoutMs,
1063
+ });
1064
+ const meta = result.data ?? {};
1065
+ return {
1066
+ requestId: meta.requestId ?? '',
1067
+ claimKey: meta.claimKey ?? '',
1068
+ groupName: meta.groupName ?? groupId,
1069
+ expiresAt: meta.expiresAt ?? '',
1070
+ duplicate: meta.duplicate === true,
1071
+ };
1072
+ },
1073
+ 'a2a_group_requests': async function a2a_group_requests(args, exec) {
1074
+ const groupId = requireText(args.groupId, 'groupId');
1075
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_requests');
1076
+ const membership = requireLocalMembership(identities, groupId, sessionId);
1077
+ const requests = store.listRequests({
1078
+ groupId,
1079
+ member: { personId: membership.personId, personKey: membership.personKey },
1080
+ status: args.status ?? 'pending',
1081
+ });
1082
+ return {
1083
+ count: requests.length,
1084
+ requests: requests.map((entry) => ({
1085
+ requestId: entry.requestId,
1086
+ displayName: entry.displayName,
1087
+ reason: entry.reason,
1088
+ status: entry.status,
1089
+ expiresAt: entry.expiresAtIso,
1090
+ })),
1091
+ };
1092
+ },
1093
+ 'a2a_group_decide': async function a2a_group_decide(args, exec) {
1094
+ const groupId = requireText(args.groupId, 'groupId');
1095
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_decide');
1096
+ const membership = requireLocalMembership(identities, groupId, sessionId);
1097
+ const decided = store.decideRequest({
1098
+ groupId,
1099
+ member: { personId: membership.personId, personKey: membership.personKey },
1100
+ requestId: requireText(args.requestId, 'requestId'),
1101
+ approve: args.approve !== false,
1102
+ });
1103
+ await store.flush();
1104
+ return {
1105
+ requestId: decided.requestId,
1106
+ status: decided.status,
1107
+ displayName: decided.displayName,
1108
+ };
1109
+ },
1110
+ 'a2a_group_remove': async function a2a_group_remove(args, exec) {
1111
+ const groupId = requireText(args.groupId, 'groupId');
1112
+ const targetPersonId = requireText(args.targetPersonId, 'targetPersonId');
1113
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_remove');
1114
+ const membership = requireMembership(store, identities, groupId, sessionId);
1115
+ if (membership.local === true) {
1116
+ const removed = store.removeMember({
1117
+ groupId,
1118
+ member: { personId: membership.personId, personKey: membership.personKey },
1119
+ targetPersonId,
1120
+ reason: args.reason,
1121
+ });
1122
+ await store.flush();
1123
+ return { groupId, personId: removed.personId, displayName: removed.displayName };
1124
+ }
1125
+ const result = await bridge.send(targetOf(membership, config), {
1126
+ skill: 'group/remove',
1127
+ text: `remove ${targetPersonId}`,
1128
+ data: { groupId, personId: membership.personId, personKey: membership.personKey, targetPersonId, reason: args.reason },
1129
+ timeoutMs: config.client.timeoutMs,
1130
+ });
1131
+ const meta = result.data ?? {};
1132
+ return { groupId, personId: meta.personId ?? targetPersonId, displayName: meta.displayName ?? targetPersonId };
1133
+ },
1134
+
1135
+ 'a2a_group_rename': async function a2a_group_rename(args, exec) {
1136
+ const groupId = requireText(args.groupId, 'groupId');
1137
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_rename');
1138
+ const membership = requireMembership(store, identities, groupId, sessionId);
1139
+ if (membership.local === true) {
1140
+ const renamed = store.renameGroup({
1141
+ groupId,
1142
+ member: { personId: membership.personId, personKey: membership.personKey },
1143
+ name: args.name,
1144
+ topic: args.topic,
1145
+ });
1146
+ await store.flush();
1147
+ return { groupId, name: renamed.name, topic: renamed.topic, changed: renamed.changed };
1148
+ }
1149
+ const result = await bridge.send(targetOf(membership, config), {
1150
+ skill: 'group/rename',
1151
+ text: 'rename',
1152
+ data: { groupId, personId: membership.personId, personKey: membership.personKey, name: args.name, topic: args.topic },
1153
+ timeoutMs: config.client.timeoutMs,
1154
+ });
1155
+ const meta = result.data ?? {};
1156
+ return { groupId, name: meta.name ?? '', topic: meta.topic ?? '', changed: Array.isArray(meta.changed) ? meta.changed : [] };
1157
+ },
1158
+
1159
+ 'a2a_group_archive': async function a2a_group_archive(args, exec) {
1160
+ const groupId = requireText(args.groupId, 'groupId');
1161
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_archive');
1162
+ const membership = requireMembership(store, identities, groupId, sessionId);
1163
+ if (membership.local === true) {
1164
+ const result = store.archiveGroup({
1165
+ groupId,
1166
+ member: { personId: membership.personId, personKey: membership.personKey },
1167
+ archived: args.archived !== false,
1168
+ });
1169
+ await store.flush();
1170
+ return { groupId, archived: result.archived };
1171
+ }
1172
+ const result = await bridge.send(targetOf(membership, config), {
1173
+ skill: 'group/archive',
1174
+ text: 'archive',
1175
+ data: { groupId, personId: membership.personId, personKey: membership.personKey, archived: args.archived !== false },
1176
+ timeoutMs: config.client.timeoutMs,
1177
+ });
1178
+ const meta = result.data ?? {};
1179
+ return { groupId, archived: meta.archived === true };
1180
+ },
1181
+
1182
+ 'a2a_group_claim': async function a2a_group_claim(args, exec) {
1183
+ const groupId = requireText(args.groupId, 'groupId');
1184
+ const requestId = requireText(args.requestId, 'requestId');
1185
+ const claimKey = requireText(args.claimKey, 'claimKey');
1186
+ const sessionId = requireSession(senderOf(exec).id, 'a2a_group_claim');
1187
+
1188
+ if (store.has?.(groupId) === true) {
1189
+ const claimed = store.claimJoin({ groupId, requestId, claimKey });
1190
+ identities.upsert({
1191
+ sessionId,
1192
+ groupId,
1193
+ personId: claimed.member.personId,
1194
+ personKey: claimed.personKey,
1195
+ local: true,
1196
+ groupName: claimed.group.name,
1197
+ role: claimed.member.role,
1198
+ lastSeq: claimed.group.lastSeq ?? 0,
1199
+ });
1200
+ await store.flush();
1201
+ await identities.flush();
1202
+ return {
1203
+ groupId,
1204
+ groupName: claimed.group.name,
1205
+ personId: claimed.member.personId,
1206
+ remote: false,
1207
+ };
1208
+ }
1209
+ const target = hostTarget(registry, config, args.agent);
1210
+ const result = await bridge.send(target, {
1211
+ skill: 'group/claim',
1212
+ text: `claim ${requestId}`,
1213
+ data: { groupId, requestId, claimKey, displayName: args.displayName },
1214
+ timeoutMs: config.client.timeoutMs,
1215
+ });
1216
+ const meta = result.data ?? {};
1217
+ identities.upsert({
1218
+ sessionId,
1219
+ groupId,
1220
+ personId: meta.personId,
1221
+ personKey: meta.personKey,
1222
+ local: false,
1223
+ baseUrl: target.baseUrl,
1224
+ groupName: meta.groupName ?? groupId,
1225
+ role: 'member',
1226
+ lastSeq: 0,
1227
+ });
1228
+ await identities.flush();
1229
+ if (inbox !== undefined) await inbox.enable(groupId);
1230
+ return {
1231
+ groupId,
1232
+ groupName: meta.groupName ?? groupId,
1233
+ personId: meta.personId,
1234
+ remote: true,
1235
+ };
1236
+ },
1237
+ 'a2a_serve': async function a2a_serve(args, exec) {
1238
+ // Asking how this instance is exposed is the declaration of intent that
1239
+ // binds it: the Session asking is the Session this agent should answer for.
1240
+ const sender = senderOf(exec);
1241
+ if (sender.id !== undefined) sessions.offerBinding(sender.id);
1242
+ const info = server.info();
1243
+ const base = {
1244
+ enabled: info.attached === true,
1245
+ endpoint: info.url,
1246
+ cardUrl: `${String(info.url).replace(/\/a2a$/, '')}${info.card}`,
1247
+ tokenRequired: info.tokenConfigured === true,
1248
+ boundSessionId: sessions.resolveBoundSessionId() ?? '',
1249
+ };
1250
+ if (args.check === false || !base.enabled) {
1251
+ return { ...base, gatewayEntry: gatewayEntryFor(info, config) };
1252
+ }
1253
+ // The probe walks the same path a colleague's invite would. Reporting its
1254
+ // steps — not just a yes/no — is what turns "it does not work" into "this is
1255
+ // the one thing left to do".
1256
+ const report = await probeReachability({ config, server });
1257
+ const missingRegistration = report.checks.some((check) => check.step === 'gateway-registered' && !check.ok);
1258
+ return {
1259
+ ...base,
1260
+ reachable: report.ok === true,
1261
+ steps: report.checks.map((check) => ({ step: check.step, ok: check.ok, detail: check.detail })),
1262
+ hint: report.hint,
1263
+ gatewayEntry: missingRegistration ? gatewayEntryFor(info, config) : '',
1264
+ };
1265
+ },
1266
+ };
1267
+
1268
+ /**
1269
+ * Drop the local membership when the host says the credential is dead.
1270
+ *
1271
+ * A removed member has no way to find out otherwise: nothing pushes to them, their panel
1272
+ * keeps listing the group, and every action fails with a code they never see. Deleting the
1273
+ * record is what turns an invisible revocation into a visible one — the group disappears
1274
+ * from their panel, which is exactly what happened.
1275
+ *
1276
+ * Scoped to one (groupId, sessionId) pair. A failure on one group says nothing about the
1277
+ * others, and a broader sweep would turn one stale credential into a wiped ledger.
1278
+ *
1279
+ * @param {Error} error - the failure from the handler.
1280
+ * @param {object} args - the tool arguments, for the group id.
1281
+ * @param {object} caller - `{ sessionId }`.
1282
+ * @returns {boolean} whether a record was removed.
1283
+ */
1284
+ function reapStaleMembership(error, args, caller) {
1285
+ const code = typeof error?.code === 'string' ? error.code : '';
1286
+ // `not_member` is the host saying "this credential is not one of mine".
1287
+ // `unknown_group` covers a group that was deleted, or a cache pointing at a host that
1288
+ // no longer has it.
1289
+ if (code !== 'not_member' && code !== 'unknown_group') return false;
1290
+ const groupId = typeof args?.groupId === 'string' ? args.groupId : '';
1291
+ const sessionId = typeof caller?.sessionId === 'string' ? caller.sessionId : '';
1292
+ if (groupId === '' || sessionId === '') return false;
1293
+ if (identities === undefined || identities === null || typeof identities.remove !== 'function') return false;
1294
+ try {
1295
+ const removed = identities.remove({ groupId, sessionId });
1296
+ if (removed > 0) {
1297
+ // flush() is fire-and-forget: the in-memory ledger is already correct, and making a
1298
+ // failed disk write turn a clear error into a different one would help nobody.
1299
+ if (typeof identities.flush === 'function') {
1300
+ Promise.resolve(identities.flush()).catch(() => {});
1301
+ }
1302
+ logger?.info?.(
1303
+ `a2a: dropped the local membership for ${groupId} after the host answered ${code}`,
1304
+ );
1305
+ }
1306
+ return removed > 0;
1307
+ } catch {
1308
+ return false;
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Run one tool.
1314
+ *
1315
+ * @param {string} name - tool name.
1316
+ * @param {object} args - arguments, already validated by the adapter.
1317
+ * @param {object} [caller] - `{ sessionId }`.
1318
+ * @returns {Promise<object>} the tool result.
1319
+ */
1320
+ async function call(name, args, caller) {
1321
+ const handler = HANDLERS[name];
1322
+ if (typeof handler !== 'function') throw new Error(`unknown tool: ${name}`);
1323
+ const normalised = normaliseCaller(caller);
1324
+ try {
1325
+ return await handler(args ?? {}, normalised);
1326
+ } catch (error) {
1327
+ // One funnel for "your membership is gone", so the twelve group tools do not each
1328
+ // need their own copy of the same eight lines.
1329
+ if (reapStaleMembership(error, args, normalised)) {
1330
+ // Annotate rather than replace: the caller still needs the original code, and the
1331
+ // new field is what a UI can use to say "you were removed" instead of "not a member".
1332
+ if (error !== null && typeof error === 'object') error.membershipDropped = true;
1333
+ }
1334
+ throw error;
1335
+ }
1336
+ }
1337
+
1338
+ /** Whether this runtime knows a tool. */
1339
+ function has(name) {
1340
+ return typeof HANDLERS[name] === 'function';
1341
+ }
1342
+
1343
+ /** Every tool name the runtime can run. */
1344
+ function names() {
1345
+ return Object.keys(HANDLERS);
1346
+ }
1347
+
1348
+ return { call, has, names };
1349
+ }