@nelsonlongxiang/dsh-open-a2a-net 0.5.13

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,1485 @@
1
+ /**
2
+ * A2A node extension (decentralized topology): registers the `a2a_teams` and
3
+ * `a2a_route` model tools over verified peer agent cards and direct peer
4
+ * routes, publishes this node's card and joined session teams, and dispatches
5
+ * inbound direct routes into live agent sessions.
6
+ * @module @nelsonlongxiang/dsh-open-a2a-net
7
+ */
8
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { networkInterfaces } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
13
+ // The package own version, read from package.json at load time so the
14
+ // panel never drifts from the manifest the installer actually resolved.
15
+ const PLUGIN_VERSION = String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
16
+ import { signCard } from "./card.js";
17
+ import { GroupStore } from "./group-store.js";
18
+ import { JoinedSessions } from "./joined-store.js";
19
+ import { PeerStore } from "./peer-store.js";
20
+ import { resolveZone } from "./zone.js";
21
+ import s from '@deepseek-ai/schemastery';
22
+ import { defineTool } from '@deepseek-ai/dsh-tools';
23
+ import { MessageId } from '@deepseek-ai/dsh-llm';
24
+ import { SessionId } from '@deepseek-ai/dsh-session';
25
+ import { A2aClient } from "./a2a-client.js";
26
+ export { A2aClient } from "./a2a-client.js";
27
+ export const name = 'a2a';
28
+ export const inject = ['tools', 'timer'];
29
+ /** Schemastery configuration for the A2A client plugin row. */
30
+ export const Config = s.object({
31
+ apiKey: s.string().default(''),
32
+ session: s.string().default(''),
33
+ team: s.string().default('dsh'),
34
+ announce: s.boolean().default(false),
35
+ agentName: s.string().default('DeepSeek Harness A2A node'),
36
+ peers: s.array(s.string()).default([]),
37
+ delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
38
+ dshHome: s.string().default(''),
39
+ sessionNodes: s.boolean().default(true),
40
+ wakeJoinedOnBoot: s.boolean().default(false),
41
+ cardTtlMs: s.number().default(172_800_000),
42
+ flushTimeoutMs: s.number().default(300_000),
43
+ routeTimeoutMs: s.number().default(1_800_000),
44
+ });
45
+ /** Model-facing text for one route result. */
46
+ function renderRoute(_args, value) {
47
+ const result = value;
48
+ const text = result.ok
49
+ ? [
50
+ `Team ${result.team} replied (task ${result.task_id}, status ${result.task_status}, context ${result.context_id}):`,
51
+ result.reply,
52
+ ].join('\n')
53
+ : `A2A route failed${result.code === undefined ? '' : ` (code ${String(result.code)})`}: ${result.error}`;
54
+ return [{ type: 'text', text }];
55
+ }
56
+ /**
57
+ * Register the outbound peer tools, the direct-route endpoint, and (when
58
+ * composed) the announced card and session-node control surface.
59
+ * @param ctx - registrant context carrying the tool registry and timer.
60
+ * @param config - deployment's A2A node facts.
61
+ */
62
+ export function apply(ctx, config) {
63
+ const logger = ctx.logger('a2a');
64
+ // The production seams: Node's globals. Tests inject their own A2aClient
65
+ // seams through src/a2a-client.ts directly. During fiber teardown the timer
66
+ // service is already gone; a timer armed then belongs to nobody, so the
67
+ // arming fails closed (callback never fires).
68
+ const schedule = (callback, delayMs) => {
69
+ try {
70
+ return ctx.timer.timeout(callback, delayMs);
71
+ }
72
+ catch {
73
+ /* v8 ignore next 2 -- reachable only when the timer service is disposed
74
+ mid-flight; the client owns no teardown-time arming site anymore, so
75
+ no test can drive this without re-adding a teardown unregister. */
76
+ return () => { };
77
+ }
78
+ };
79
+ const fetchImpl = (url, init) => globalThis.fetch(url, init);
80
+ /** Constant-time secret comparison: hash both sides to equal-length digests. */
81
+ function secretEqual(a, b) {
82
+ const digestA = createHash('sha256').update(a).digest();
83
+ const digestB = createHash('sha256').update(b).digest();
84
+ return timingSafeEqual(digestA, digestB);
85
+ }
86
+ /**
87
+ * Same-origin / loopback trust for the control routes when no API key is
88
+ * configured (the sidebar's browser fetches carry no secret): a
89
+ * same-origin or user-initiated navigational request passes, an Origin
90
+ * matching the Host passes, and any loopback caller passes. A cross-site
91
+ * page is rejected even when it reaches a loopback bind (the web-app
92
+ * restart-control trust rule).
93
+ * @param req - the inbound control request.
94
+ * @returns whether the caller is trusted without a key.
95
+ */
96
+ function controlTrustedWithoutKey(req) {
97
+ const site = req.headers['sec-fetch-site'];
98
+ if (site === 'same-origin' || site === 'none')
99
+ return true;
100
+ const origin = req.headers.origin;
101
+ const host = req.headers.host;
102
+ if (typeof origin === 'string' && typeof host === 'string' && origin === `http://${host}`)
103
+ return true;
104
+ const remote = req.socket.remoteAddress;
105
+ return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
106
+ }
107
+ /**
108
+ * Control-route authorization. With `apiKey` set, the `X-API-Key` header
109
+ * (or `api_key` query, for non-browser clients) must match in constant
110
+ * time. With no key, the same-origin/loopback trust applies — the explicit
111
+ * exposure assumption: an empty-key deployment accepts control from its
112
+ * own page and from loopback, and from nothing else.
113
+ * @param req - the inbound control request.
114
+ * @returns whether the request may drive the control routes.
115
+ */
116
+ function controlAuthorized(req) {
117
+ if (config.apiKey !== '') {
118
+ const presented = String(req.headers['x-api-key'] ?? new URL(req.url ?? '/', 'http://x').searchParams.get('api_key') ?? '');
119
+ return secretEqual(presented, config.apiKey);
120
+ }
121
+ return controlTrustedWithoutKey(req);
122
+ }
123
+ /**
124
+ * Answer an unauthorized control request: 401 names the missing key,
125
+ * 403 names the untrusted origin.
126
+ * @param req - the inbound request.
127
+ * @param res - the response.
128
+ */
129
+ function rejectControl(req, res) {
130
+ void req;
131
+ const code = config.apiKey !== '' ? 401 : 403;
132
+ const payload = JSON.stringify({ error: config.apiKey !== '' ? 'unauthorized: X-API-Key required' : 'forbidden: untrusted origin' });
133
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
134
+ res.end(payload);
135
+ }
136
+ /**
137
+ * Wrap one control handler with the authorization guard.
138
+ * @param handler - the route's inner handler.
139
+ * @returns the guarded handler.
140
+ */
141
+ function controlRoute(handler) {
142
+ return (req, res) => {
143
+ if (!controlAuthorized(req)) {
144
+ rejectControl(req, res);
145
+ return;
146
+ }
147
+ handler(req, res);
148
+ };
149
+ }
150
+ /**
151
+ * Read one JSON object body and hand it to the handler; malformed input
152
+ * answers 400. Bounded like the direct endpoint's reader.
153
+ * @param req - the inbound request.
154
+ * @param res - the response for the malformed-body rejection.
155
+ * @param use - receives the parsed body.
156
+ */
157
+ function readJsonBody(req, res, use) {
158
+ const chunks = [];
159
+ let size = 0;
160
+ req.on('data', (chunk) => {
161
+ size += chunk.length;
162
+ if (size > 10_000) {
163
+ req.destroy();
164
+ return;
165
+ }
166
+ chunks.push(chunk);
167
+ });
168
+ req.on('end', () => {
169
+ let body;
170
+ try {
171
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
172
+ }
173
+ catch {
174
+ const payload = JSON.stringify({ error: 'malformed body' });
175
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
176
+ res.end(payload);
177
+ return;
178
+ }
179
+ use(body);
180
+ });
181
+ }
182
+ /**
183
+ * The web server is a sibling row, not a declared injection, so in a Loader
184
+ * tree it can activate after this row: the optional read waits for the tree
185
+ * to settle (immediate in a hand-built context with no Loader). The
186
+ * callback runs at most once and never after this fiber's teardown.
187
+ * @param use - receives the webServer service when the settled tree has one.
188
+ * @param onAbsent - runs when the settled tree has no webServer.
189
+ */
190
+ function whenWebServerSettled(use, onAbsent) {
191
+ const read = () => {
192
+ const webServer = ctx.get('webServer');
193
+ if (webServer !== undefined)
194
+ use(webServer);
195
+ else
196
+ onAbsent?.();
197
+ };
198
+ const settled = ctx.get('loader')?.await();
199
+ if (settled === undefined)
200
+ read();
201
+ else
202
+ void settled.then(() => {
203
+ if (ctx.fiber.uid !== null)
204
+ read();
205
+ }, () => {
206
+ // A failed boot never mounts the web server row; the absent path stays
207
+ // silent so a dying tree logs its own failure once.
208
+ });
209
+ }
210
+ // Slice 2: the configured peer list is a seed set; referral URLs learned
211
+ // from fetched cards join a bounded, quality-scored store persisted under
212
+ // the same home as the node key.
213
+ const home = config.dshHome === '' ? resolveDshHome() : resolveDshHome(config.dshHome);
214
+ const peerStore = new PeerStore(config.peers, join(home, 'a2a', 'peers.json'));
215
+ // Slice 3: an unset session derives from a per-home node id, and the
216
+ // configured delegations become signed zone records on every card.
217
+ const session = config.session === '' ? deriveSession(home) : config.session;
218
+ const records = config.delegates.map(delegate => ({
219
+ type: 'delegate',
220
+ name: delegate.name,
221
+ url: delegate.url,
222
+ ...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
223
+ }));
224
+ const client = new A2aClient({
225
+ apiKey: config.apiKey,
226
+ sessionId: session,
227
+ schedule,
228
+ fetch: fetchImpl,
229
+ });
230
+ // Persisted join intent: a join is a user gesture over a durable session,
231
+ // but the node it mounts dies with the session's Agent — page reloads and
232
+ // host restarts silently dropped every join. The store remounts the node
233
+ // whenever the Agent comes back, until the user leaves.
234
+ const joinedSessions = new JoinedSessions(join(home, 'a2a', 'joined.json'));
235
+ const ACTIVITY_CAP = 20;
236
+ const recentActivity = [];
237
+ const inFlightRoutes = new Map();
238
+ let inFlightSeq = 0;
239
+ const beginRoute = (team, peer) => {
240
+ const id = ++inFlightSeq;
241
+ inFlightRoutes.set(id, { id, team, peer, startedAt: Date.now() });
242
+ return id;
243
+ };
244
+ const endRoute = (id) => { inFlightRoutes.delete(id); };
245
+ const recordActivity = (dir, team, peer, ok) => {
246
+ recentActivity.push({ ts: Date.now(), dir, team, peer, ok });
247
+ if (recentActivity.length > ACTIVITY_CAP)
248
+ recentActivity.splice(0, recentActivity.length - ACTIVITY_CAP);
249
+ };
250
+ /**
251
+ * This host's primary LAN IPv4 (first non-internal address), computed once:
252
+ * a node fact the card and state route publish so peers and operators can
253
+ * tell machines apart in a same-team fleet. Empty when no external address
254
+ * exists (loopback-only deployments).
255
+ */
256
+ const lanIp = (() => {
257
+ for (const entries of Object.values(networkInterfaces())) {
258
+ for (const entry of entries ?? []) {
259
+ if (entry.family === 'IPv4' && !entry.internal)
260
+ return entry.address;
261
+ }
262
+ }
263
+ return '';
264
+ })();
265
+ // Session groups: user-named buckets for the panel's session listing,
266
+ // persisted beside the join intents so they survive restarts.
267
+ const groupStore = new GroupStore(join(home, 'a2a', 'groups.json'));
268
+ // Session nodes: every joined top-level session is its own addressable
269
+ // team (label `<session>-<agentId8>`, team `<team>/<agentId8>`). Joining
270
+ // is a local fact: the entry dispatches `/a2a/direct` routes and rides
271
+ // the announced card's unsigned sessionTeams listing.
272
+ const sessionNodes = new Map();
273
+ // Live top-level agents: the join surface's candidates and the cold-row
274
+ // complement (apply scope — the direct-route wake below reads it too).
275
+ const liveRoots = new Map();
276
+ /**
277
+ * The api gateway's wake face, when composed: materializing a persisted
278
+ * session's agent (log replay plus composed preset world) is web-app
279
+ * knowledge, so both wake paths ride the one service that owns it.
280
+ */
281
+ const materialize = (id) => {
282
+ const apiProxy = ctx.get('apiProxy');
283
+ if (apiProxy === undefined)
284
+ return undefined;
285
+ return apiProxy.materializeSession(SessionId(id));
286
+ };
287
+ /**
288
+ * Wake one cold joined session on demand — the route-triggered half:
289
+ * the join consented to network reachability, so a route addressed to
290
+ * its team pays the wake rather than failing.
291
+ * @param team - the routed team name.
292
+ * @returns a promise resolving with the woken agent, or undefined when
293
+ * the team names no cold joined session or no wake face is composed.
294
+ */
295
+ const wakeColdTeam = (team) => {
296
+ const id = joinedSessions.list().find(entry => !liveRoots.has(entry) && `${config.team}/${id8(entry)}` === team);
297
+ if (id === undefined)
298
+ return undefined;
299
+ // An archived session never wakes: archive is closure, not sleep.
300
+ if (archivedSessionFilter()?.(id) === true)
301
+ return undefined;
302
+ return materialize(id);
303
+ };
304
+ /**
305
+ * The workspace registry's archived set, when composed. Archiving is a
306
+ * registry state — the session store stays — so this list is the one
307
+ * authoritative archive signal. Read structurally: the registry is an
308
+ * optional neighbor, not a dependency.
309
+ */
310
+ function archivedSessionFilter() {
311
+ const registry = ctx.get('workspaceRegistry');
312
+ if (registry === undefined || !Array.isArray(registry.archivedSessionIds))
313
+ return undefined;
314
+ const archived = new Set(registry.archivedSessionIds.map(String));
315
+ return (id) => archived.has(id);
316
+ }
317
+ /**
318
+ * Archived sessions leave the node network: archive is closure, so the
319
+ * persisted join intent goes, a mounted live node unmounts, and the next
320
+ * listing stops advertising the team. Runs at boot settlement and on every
321
+ * state read — the panel's poll makes a mid-session archive disappear
322
+ * within one poll interval.
323
+ */
324
+ const pruneArchivedJoins = () => {
325
+ const isArchived = archivedSessionFilter();
326
+ if (isArchived === undefined)
327
+ return;
328
+ for (const id of joinedSessions.list()) {
329
+ if (!isArchived(id))
330
+ continue;
331
+ sessionNodes.delete(id);
332
+ joinedSessions.remove(id);
333
+ logger.info(`a2a: archived session ${id8(id)} left the node network`);
334
+ }
335
+ };
336
+ /**
337
+ * The short id suffix session-node labels and teams key on. Web agents
338
+ * carry `SessionId`-branded ids whose first 8 characters are the literal
339
+ * `session-` prefix — every node would share one team — so the brand
340
+ * strips before the slice. Imported sessions (`import-<uuid>`) keep the
341
+ * `import-` prefix plus the uuid's first 8 hex chars: a bare 8-char slice
342
+ * would collapse the whole import family into 16 possible ids (the uuid's
343
+ * 8th char), which collides as soon as several sessions are imported.
344
+ */
345
+ function id8(id) {
346
+ const bare = id.replace(/^session-/, '');
347
+ const imported = bare.match(/^import-([0-9a-fA-F]+)/);
348
+ if (imported !== null)
349
+ return `import-${imported[1].slice(0, 8)}`;
350
+ return bare.slice(0, 8);
351
+ }
352
+ /** The 8-char suffix of one agent's id. */
353
+ function agentId8(agent) {
354
+ return id8(String(agent.id));
355
+ }
356
+ /** The session node's wire label. */
357
+ function sessionLabelOf(agent) {
358
+ return `${session}-${agentId8(agent)}`;
359
+ }
360
+ /** The session node's team name. */
361
+ function sessionTeamOf(agent) {
362
+ return `${config.team}/${agentId8(agent)}`;
363
+ }
364
+ /**
365
+ * Resolve the live agent a session-node team names, when that node exists.
366
+ * @param team - the routed team name.
367
+ * @returns the node's agent, or undefined for any other team.
368
+ */
369
+ function resolveAgentForTeam(team) {
370
+ const prefix = `${config.team}/`;
371
+ if (!team.startsWith(prefix))
372
+ return undefined;
373
+ const suffix = team.slice(prefix.length);
374
+ return [...sessionNodes.values()].find(agent => agentId8(agent) === suffix);
375
+ }
376
+ /**
377
+ * Human-facing node facts for one session: the session title (fallback:
378
+ * the node label), a one-line recent-activity excerpt, and the session's
379
+ * working directory (from the durable session header — a storage fact
380
+ * read fresh, never baked into any prompt).
381
+ * @param agent - the session the facts describe.
382
+ * @returns the name/description/workspace triple the card and state route serve.
383
+ */
384
+ function nodeMetadataOf(agent) {
385
+ const title = ctx.get('sessionTitle')?.get(agent.session)?.title;
386
+ const cwd = agent.session.header?.cwd;
387
+ return {
388
+ name: title !== undefined && title !== '' ? title : sessionLabelOf(agent),
389
+ description: recentActivityOf(agent),
390
+ ...(cwd !== undefined && cwd !== '' ? { workspace: cwd } : {}),
391
+ };
392
+ }
393
+ /**
394
+ * One line of recent activity: the newest user or assistant text,
395
+ * whitespace-collapsed and truncated.
396
+ * @param agent - the session whose event log is scanned.
397
+ * @returns the excerpt, or a placeholder for a fresh session.
398
+ */
399
+ function recentActivityOf(agent) {
400
+ const events = agent.session.events;
401
+ for (let index = events.length - 1; index >= 0; index -= 1) {
402
+ const event = events[index];
403
+ if (event === undefined)
404
+ continue;
405
+ const blocks = event.type === 'user/message'
406
+ ? event.data.content
407
+ : event.type === 'assistant/message'
408
+ ? event.data.message.content
409
+ : undefined;
410
+ const text = blocks
411
+ ?.filter((block) => block.type === 'text')
412
+ .map(block => block.text)
413
+ .join(' ')
414
+ .replace(/\s+/g, ' ')
415
+ .trim();
416
+ if (text !== undefined && text !== '')
417
+ return text.slice(0, 160);
418
+ }
419
+ return 'no activity yet';
420
+ }
421
+ /**
422
+ * Join one live top-level session: its team dispatches direct routes and
423
+ * rides the announced card from this moment.
424
+ * @param agent - the session to join.
425
+ */
426
+ function mountSessionNode(agent) {
427
+ sessionNodes.set(String(agent.id), agent);
428
+ }
429
+ if (config.sessionNodes) {
430
+ // Track live top-level sessions for the join surface; joining is
431
+ // explicit (the sidebar control), never automatic. A throwing
432
+ // agent/created listener vetoes publication, so tracking is strictly
433
+ // side-effect-only.
434
+ ctx.on('agent/created', ({ agent }) => {
435
+ const agents = ctx.get('agents');
436
+ if (agents === undefined || !agents.roots().some(root => root.id === agent.id))
437
+ return;
438
+ liveRoots.set(String(agent.id), agent);
439
+ if (joinedSessions.has(String(agent.id)))
440
+ mountSessionNode(agent);
441
+ });
442
+ ctx.on('agent/disposed', ({ agent }) => {
443
+ liveRoots.delete(String(agent.id));
444
+ // Disposal unmounts the runtime node; the persisted intent stays for
445
+ // the remount above (leave is the only intent remover).
446
+ sessionNodes.delete(String(agent.id));
447
+ });
448
+ for (const agent of ctx.get('agents')?.roots() ?? []) {
449
+ liveRoots.set(String(agent.id), agent);
450
+ if (joinedSessions.has(String(agent.id)))
451
+ mountSessionNode(agent);
452
+ }
453
+ {
454
+ // Archive pruning and boot wake both ride the loader tree's
455
+ // settlement: the workspace registry and the api gateway may activate
456
+ // after this row in the same tree, and an apply-time snapshot would
457
+ // see neither. Archived intents prune first — an archived session
458
+ // must never wake — and pruning runs even with the wake off.
459
+ const pruneThenWake = () => {
460
+ pruneArchivedJoins();
461
+ if (!config.wakeJoinedOnBoot)
462
+ return;
463
+ if (ctx.get('apiProxy') === undefined) {
464
+ logger.warn('wakeJoinedOnBoot is on but no api gateway is composed; cold joined sessions stay asleep');
465
+ return;
466
+ }
467
+ for (const id of joinedSessions.list()) {
468
+ if (liveRoots.has(id))
469
+ continue;
470
+ // The remount listener joins the node the moment the woken agent
471
+ // publishes; a failed wake keeps the cold row and its intent.
472
+ materialize(id)?.catch((error) => {
473
+ logger.warn(`boot wake of ${id} failed: ${String(error)}`);
474
+ });
475
+ }
476
+ };
477
+ const settled = ctx.get('loader')?.await();
478
+ if (settled === undefined)
479
+ pruneThenWake();
480
+ else
481
+ void settled.then(() => {
482
+ if (ctx.fiber.uid !== null)
483
+ pruneThenWake();
484
+ }, () => {
485
+ // A failed boot never wakes anything; the dying tree logs its own
486
+ // failure once.
487
+ });
488
+ }
489
+ // Peer-side rows for the panel, grouped there by origin: the sweep is
490
+ // real network work, so a shared 5s cache serves the 2s panel poll
491
+ // without hammering peers. The state read answers synchronously from the
492
+ // cache (never awaiting inside the handler) and kicks a background
493
+ // refresh when stale — the next poll picks the fresh rows up.
494
+ let remoteRowsCache;
495
+ const refreshRemoteRows = () => {
496
+ const now = Date.now();
497
+ if (remoteRowsCache !== undefined && now - remoteRowsCache.at < 5_000)
498
+ return;
499
+ void listDirectoryTeams(false)
500
+ .then(all => {
501
+ remoteRowsCache = {
502
+ at: now,
503
+ rows: all
504
+ .filter(row => row.local !== true)
505
+ .map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}) })),
506
+ };
507
+ })
508
+ .catch(() => { });
509
+ };
510
+ whenWebServerSettled((webServer) => {
511
+ ctx.effect(() => webServer.register({
512
+ kind: 'exact',
513
+ path: '/__dsh_a2a/state',
514
+ handler: controlRoute((_req, res) => {
515
+ void (async () => {
516
+ // The panel polls this route: pruning here makes a mid-session
517
+ // archive leave the network within one poll interval, no restart
518
+ // required.
519
+ pruneArchivedJoins();
520
+ const assignments = groupStore.all();
521
+ const groupOf = (id) => {
522
+ const name = assignments[id];
523
+ return name === undefined ? undefined : name;
524
+ };
525
+ // Archived sessions are closed: the GUI hides them everywhere, so
526
+ // the panel must not re-list them as join surface rows — joined
527
+ // or not. (The prune above already took their join intents; this
528
+ // covers never-joined live roots and any intent a restart left.)
529
+ const isArchived = archivedSessionFilter();
530
+ const sessions = [...liveRoots.values()]
531
+ .filter(agent => isArchived?.(String(agent.id)) !== true)
532
+ .map(agent => ({
533
+ id: String(agent.id),
534
+ label: sessionLabelOf(agent),
535
+ team: sessionTeamOf(agent),
536
+ ...nodeMetadataOf(agent),
537
+ joined: sessionNodes.has(String(agent.id)),
538
+ live: true,
539
+ ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
540
+ }));
541
+ // Cold joined sessions: a remembered intent whose Agent is not
542
+ // back yet (host restarted, session not opened). The row carries
543
+ // no facts — the Web client merges the session title from its
544
+ // own list — and wakes through the normal session-open path,
545
+ // after which the remount above joins it automatically.
546
+ const persistence = ctx.get('sessionPersistence');
547
+ if (persistence !== undefined) {
548
+ const persisted = new Set((await persistence.list()).map(header => String(header.id)));
549
+ for (const id of joinedSessions.list()) {
550
+ if (liveRoots.has(id) || !persisted.has(id))
551
+ continue;
552
+ sessions.push({
553
+ id,
554
+ label: `${session}-${id8(id)}`,
555
+ team: `${config.team}/${id8(id)}`,
556
+ joined: true,
557
+ live: false,
558
+ ...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
559
+ });
560
+ }
561
+ }
562
+ const body = JSON.stringify({
563
+ nodes: true,
564
+ version: PLUGIN_VERSION,
565
+ sessions,
566
+ groups: groupStore.list(),
567
+ host: lanIp === '' ? {} : { lanIp },
568
+ peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
569
+ remote: (refreshRemoteRows(), remoteRowsCache?.rows ?? []),
570
+ activity: recentActivity.slice(),
571
+ inFlight: [...inFlightRoutes.values()].map(route => ({
572
+ team: route.team,
573
+ peer: route.peer,
574
+ startedAt: route.startedAt,
575
+ })),
576
+ });
577
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
578
+ res.end(body);
579
+ })();
580
+ }),
581
+ }), 'a2a: session-node state route');
582
+ ctx.effect(() => webServer.register({
583
+ kind: 'exact',
584
+ path: '/__dsh_a2a/join',
585
+ handler: controlRoute((req, res) => {
586
+ readJsonBody(req, res, (body) => {
587
+ const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
588
+ if (agent === undefined) {
589
+ const payload = JSON.stringify({ error: 'no live session with that id' });
590
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
591
+ res.end(payload);
592
+ return;
593
+ }
594
+ mountSessionNode(agent);
595
+ joinedSessions.add(String(agent.id));
596
+ const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
597
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
598
+ res.end(payload);
599
+ });
600
+ }),
601
+ }), 'a2a: session-node join route');
602
+ ctx.effect(() => webServer.register({
603
+ kind: 'exact',
604
+ path: '/__dsh_a2a/leave',
605
+ handler: controlRoute((req, res) => {
606
+ readJsonBody(req, res, (body) => {
607
+ const id = typeof body.id === 'string' ? body.id : '';
608
+ if (id !== '')
609
+ sessionNodes.delete(id);
610
+ joinedSessions.remove(id);
611
+ const payload = JSON.stringify({ id });
612
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
613
+ res.end(payload);
614
+ });
615
+ }),
616
+ }), 'a2a: session-node leave route');
617
+ // Groups control: create/remove named groups and assign sessions to
618
+ // them (an empty name unassigns). Same guard as join/leave — the
619
+ // group taxonomy enumerates session ids, so it stays behind the key.
620
+ ctx.effect(() => webServer.register({
621
+ kind: 'exact',
622
+ path: '/__dsh_a2a/groups',
623
+ handler: controlRoute((req, res) => {
624
+ readJsonBody(req, res, (body) => {
625
+ const action = body.action;
626
+ let payload;
627
+ if (action === 'create') {
628
+ const name = typeof body.name === 'string' ? body.name : '';
629
+ const stored = groupStore.create(name);
630
+ payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
631
+ }
632
+ else if (action === 'remove') {
633
+ const name = typeof body.name === 'string' ? body.name : '';
634
+ payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
635
+ }
636
+ else if (action === 'assign') {
637
+ const id = typeof body.id === 'string' ? body.id : '';
638
+ const name = typeof body.name === 'string' ? body.name : undefined;
639
+ if (id === '' || name === undefined) {
640
+ payload = JSON.stringify({ ok: false, error: 'id and name are required' });
641
+ }
642
+ else {
643
+ payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
644
+ }
645
+ }
646
+ else {
647
+ payload = JSON.stringify({ ok: false, error: 'unknown action' });
648
+ }
649
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
650
+ res.end(payload);
651
+ });
652
+ }),
653
+ }), 'a2a: session-group route');
654
+ });
655
+ }
656
+ if (config.announce) {
657
+ whenWebServerSettled((webServer) => {
658
+ const privateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
659
+ const cardCore = () => ({
660
+ name: config.agentName,
661
+ session,
662
+ team: config.team,
663
+ // async: wait:false is honored (steer + delivered, no final hold);
664
+ // the signed capability turns async dialing into a deterministic
665
+ // check instead of a timeout race against pre-0.5.2 peers.
666
+ capabilities: { route: true, async: true },
667
+ expiresAt: Date.now() + config.cardTtlMs,
668
+ ...(records.length > 0 ? { records: [...records] } : {}),
669
+ });
670
+ let currentCard = signCard(cardCore(), privateKey);
671
+ // Re-sign at TTL/4: four publication opportunities per validity window
672
+ // tolerate three lost refreshes (GNUnet zonemaster cadence).
673
+ const refresh = ctx.timer.interval(() => {
674
+ currentCard = signCard(cardCore(), privateKey);
675
+ }, Math.max(1, Math.floor(config.cardTtlMs / 4)));
676
+ ctx.effect(() => webServer.register({
677
+ kind: 'exact',
678
+ path: '/.well-known/agent-card.json',
679
+ handler: (_req, res) => {
680
+ // The unsigned peers and sessionTeams fields carry this node's
681
+ // current peer set and joined sessions at serve time (between
682
+ // re-signs), so each card read spreads the publisher's latest
683
+ // view of the network.
684
+ const sessionTeams = [...sessionNodes.values()].map(agent => ({ team: sessionTeamOf(agent), ...nodeMetadataOf(agent) }));
685
+ const body = JSON.stringify({ ...currentCard, peers: peerStore.list(), ...(sessionTeams.length > 0 ? { sessionTeams } : {}), ...(lanIp !== '' ? { lanIp } : {}), description: `A2A node exposing team ${config.team}` });
686
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
687
+ res.end(body);
688
+ },
689
+ }), 'a2a: agent card route');
690
+ ctx.effect(() => refresh, 'a2a: card refresh timer');
691
+ }, () => {
692
+ logger.warn('announce is on but no web server is composed; the agent card is not published');
693
+ });
694
+ }
695
+ /**
696
+ * Resolve the live agent for inbound steering: the initiator when
697
+ * recoverable, else the first root.
698
+ * @returns the agent, or undefined when no agent is live.
699
+ */
700
+ function liveAgent() {
701
+ const agents = ctx.get('agents');
702
+ if (agents === undefined)
703
+ return undefined;
704
+ try {
705
+ return agents.requireInitiator();
706
+ }
707
+ catch {
708
+ const roots = agents.roots();
709
+ return roots.length > 0 ? roots[0] : undefined;
710
+ }
711
+ }
712
+ /**
713
+ * Steer one relay-form user message into the agent; the caller owns
714
+ * rejection handling.
715
+ * @param agent - the steering target.
716
+ * @param text - the fully formatted relay message.
717
+ */
718
+ function steerRelay(agent, text) {
719
+ agent.steer({
720
+ id: MessageId(`a2a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`),
721
+ role: 'user',
722
+ content: [{ type: 'text', text }],
723
+ source: { kind: 'plugin', plugin: 'a2a', form: 'relay' },
724
+ });
725
+ }
726
+ /**
727
+ * Register one final-reply waiter for the agent and arm its flush timeout.
728
+ * @param agent - the agent whose next assistant message answers the waiter.
729
+ * @param answer - how the waiter's reply is delivered.
730
+ * @returns the registered waiter (for callers that may retract it).
731
+ */
732
+ function registerFinalWaiter(agent, answer) {
733
+ const key = String(agent.id);
734
+ const waiter = { answer, sinceEvents: agent.session.events.length };
735
+ waiter.timeoutDisposer = armFlushTimeout(key, waiter);
736
+ pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
737
+ return waiter;
738
+ }
739
+ /**
740
+ * Steer one direct route request into a live agent and resolve with its
741
+ * final reply (final semantics always: the HTTP caller has no other
742
+ * channel to learn the reply).
743
+ * @param taskId - the correlation key the steered receipt header carries.
744
+ */
745
+ function routeIntoAgent(team, message, caller, taskId) {
746
+ const agent = resolveAgentForTeam(team);
747
+ if (agent !== undefined) {
748
+ return routeIntoAgentFor(agent, team, message, caller, taskId);
749
+ }
750
+ // Wake-on-route: a cold joined team materializes on demand, then steers.
751
+ const woken = wakeColdTeam(team);
752
+ if (woken !== undefined) {
753
+ return woken.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
754
+ }
755
+ // Only the bare process team falls back to the live (initiator) agent.
756
+ // A session-node-shaped team that misses is an error, never a silent
757
+ // redirect: a mistyped or stale short id must not reach another session
758
+ // through the initiator fallback.
759
+ if (team === config.team) {
760
+ const live = liveAgent();
761
+ if (live !== undefined)
762
+ return routeIntoAgentFor(live, team, message, caller, taskId);
763
+ return Promise.resolve({ ok: false, error: 'No live DSH agent is available to accept this message.' });
764
+ }
765
+ return Promise.resolve({ ok: false, error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.` });
766
+ }
767
+ /**
768
+ * Steer one direct route request into one named agent and resolve with its
769
+ * final reply.
770
+ * @param agent - the session-node agent the request addresses.
771
+ * @param team - team name the caller routed to (diagnostics).
772
+ * @param message - request text.
773
+ * @param caller - caller label for the steered prefix.
774
+ * @returns the agent's final reply, or the explicit failure.
775
+ */
776
+ function routeIntoAgentFor(agent, team, message, caller, taskId) {
777
+ return new Promise((resolve) => {
778
+ const waiter = registerFinalWaiter(agent, (text) => {
779
+ resolve({ ok: true, reply: text });
780
+ });
781
+ // The header names the sender first: `caller` is the routing node's
782
+ // own label (the session that issued the route), while `team` is this
783
+ // request's target — showing the target as "remote team" hid the
784
+ // actual origin behind the receiver's own team name. The task id, when
785
+ // the caller supplied one, rides the header so an "[A2A receipt] task
786
+ // <id>" answer correlates with the caller's own route result.
787
+ const from = caller === '' ? 'an unknown node' : caller;
788
+ const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
789
+ const text = `[A2A direct] ${taskPrefix}from "${from}" (routed to ${team}) sent:
790
+
791
+ ${message}`;
792
+ try {
793
+ steerRelay(agent, text);
794
+ }
795
+ catch (error) {
796
+ // The waiter was registered above, so the key always holds it; the
797
+ // map-of-arrays stays a plain read.
798
+ const key = String(agent.id);
799
+ const entries = pendingFinals.get(key);
800
+ const kept = entries.filter(entry => entry !== waiter);
801
+ if (kept.length === 0)
802
+ pendingFinals.delete(key);
803
+ else
804
+ pendingFinals.set(key, kept);
805
+ waiter.timeoutDisposer?.();
806
+ resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
807
+ }
808
+ });
809
+ }
810
+ whenWebServerSettled((webServer) => {
811
+ ctx.effect(() => webServer.register({
812
+ kind: 'exact',
813
+ path: '/a2a/direct',
814
+ handler: (req, res) => {
815
+ const chunks = [];
816
+ let size = 0;
817
+ req.on('data', (chunk) => {
818
+ size += chunk.length;
819
+ if (size > 1_000_000) {
820
+ req.destroy();
821
+ return;
822
+ }
823
+ chunks.push(chunk);
824
+ });
825
+ /* v8 ignore next 6 -- client aborts surface as 'close' via the shared
826
+ webServer's per-request guard; a raw req 'error' needs a malformed
827
+ HTTP stream the webServer rejects before this handler exists */
828
+ req.on('error', () => {
829
+ if (!res.headersSent) {
830
+ res.writeHead(400);
831
+ res.end();
832
+ }
833
+ });
834
+ req.on('end', () => {
835
+ let body;
836
+ try {
837
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
838
+ }
839
+ catch {
840
+ const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
841
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
842
+ res.end(payload);
843
+ return;
844
+ }
845
+ const team = typeof body.team === 'string' ? body.team : '';
846
+ const message = typeof body.message === 'string' ? body.message : '';
847
+ const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
848
+ const noWait = body.wait === false;
849
+ const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
850
+ // The caller-born task id (idempotency key): echo it when present
851
+ // so the receipt header correlates with the caller's own result;
852
+ // pre-0.5.3 callers carry none and this node mints one.
853
+ const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
854
+ if (team === '' || message === '') {
855
+ const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
856
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
857
+ res.end(payload);
858
+ return;
859
+ }
860
+ // wait:false delivers without the final-reply hold: steer resolves
861
+ // synchronously inside routeIntoAgentFor, so by the time the agent
862
+ // lookup settles the message is already in — answer delivered and
863
+ // let the receipt contract carry the reply.
864
+ if (noWait) {
865
+ const agent = resolveAgentForTeam(team);
866
+ const woken = agent !== undefined ? undefined : wakeColdTeam(team);
867
+ const deliver = (target) => {
868
+ const from = caller === '' ? 'an unknown node' : caller;
869
+ try {
870
+ // The receipt header carries the task id: the target echoes
871
+ // it verbatim in "[A2A receipt] task <id> ...", closing the
872
+ // correlation loop with the caller's own route result.
873
+ steerRelay(target, `[A2A direct] (task ${taskId}) from "${from}" (routed to ${team}) sent:\n\n${message}`);
874
+ recordActivity('in', team, caller, true);
875
+ const payload = JSON.stringify({
876
+ routed: true,
877
+ delivered: true,
878
+ team,
879
+ session,
880
+ task_id: taskId,
881
+ context_id: contextId,
882
+ task_status: 'TASK_STATE_DELIVERED',
883
+ artifacts: [],
884
+ });
885
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
886
+ res.end(payload);
887
+ }
888
+ catch (error) {
889
+ recordActivity('in', team, caller, false);
890
+ const payload = JSON.stringify({ error: `The DSH session rejected the message: ${String(error)}`, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
891
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
892
+ res.end(payload);
893
+ }
894
+ };
895
+ if (agent !== undefined) {
896
+ deliver(agent);
897
+ return;
898
+ }
899
+ if (woken !== undefined) {
900
+ void woken.then(deliver, (error) => {
901
+ recordActivity('in', team, caller, false);
902
+ const payload = JSON.stringify({ error: `waking the session for team "${team}" failed: ${String(error)}`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
903
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
904
+ res.end(payload);
905
+ });
906
+ return;
907
+ }
908
+ if (team === config.team) {
909
+ const live = liveAgent();
910
+ if (live !== undefined) {
911
+ deliver(live);
912
+ return;
913
+ }
914
+ }
915
+ const payload = JSON.stringify({ error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
916
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
917
+ res.end(payload);
918
+ return;
919
+ }
920
+ void routeIntoAgent(team, message, caller, taskId).then((outcome) => {
921
+ recordActivity('in', team, caller, outcome.ok);
922
+ const payload = outcome.ok
923
+ ? JSON.stringify({
924
+ routed: true,
925
+ team,
926
+ session,
927
+ result: { text: outcome.reply },
928
+ task_id: taskId,
929
+ context_id: contextId,
930
+ task_status: 'TASK_STATE_COMPLETED',
931
+ artifacts: [],
932
+ })
933
+ : JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
934
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
935
+ res.end(payload);
936
+ });
937
+ });
938
+ },
939
+ }), 'a2a: direct route endpoint');
940
+ });
941
+ /**
942
+ * Fetch one peer's card and move its store entry: the fetch is itself the
943
+ * reachability check — a miss degrades the peer, a verified card raises it
944
+ * and offers its referral list to the store. A peer offered mid-iteration
945
+ * is fetched on a later tool call, so gossip converges across calls.
946
+ * @param peer - base URL of the peer to visit.
947
+ * @returns the verified card, or undefined when the peer is unreachable or serves an invalid card.
948
+ */
949
+ async function fetchPeerCard(peer) {
950
+ const card = await client.fetchCard(peer);
951
+ if (card === undefined) {
952
+ peerStore.noteFailure(peer);
953
+ return undefined;
954
+ }
955
+ // A self-referral learned from a peer's card (the peer lists this node's
956
+ // URL back at it) must not track the node as its own peer: the node would
957
+ // list its own teams as remote rows and offer its own URL onward. The
958
+ // signed card session is the identity check — no URL guessing.
959
+ if (card.session === session) {
960
+ peerStore.drop(peer);
961
+ return undefined;
962
+ }
963
+ peerStore.noteSuccess(peer);
964
+ for (const referral of card.peers ?? [])
965
+ peerStore.offer(referral);
966
+ return card;
967
+ }
968
+ /**
969
+ * One verified-card fetch per zone URL per tool call: the direct pass and
970
+ * every delegated hop share the memo, so each zone is fetched (and scored,
971
+ * and offered referrals) exactly once per call.
972
+ * @returns the memoized card fetch.
973
+ */
974
+ function memoizedCardFetch() {
975
+ const cache = new Map();
976
+ return async (url) => {
977
+ if (!cache.has(url))
978
+ cache.set(url, await fetchPeerCard(url));
979
+ return cache.get(url);
980
+ };
981
+ }
982
+ ctx.tools.register(defineTool({
983
+ name: 'a2a_teams',
984
+ description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
985
+ + 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
986
+ + 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
987
+ + 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
988
+ + 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
989
+ + 'discovers one extra referral hop within the call. Call this before a2a_route to pick a target team.',
990
+ parameters: {
991
+ query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
992
+ },
993
+ output: {
994
+ schema: {
995
+ type: 'object',
996
+ additionalProperties: false,
997
+ properties: {
998
+ ok: { type: 'boolean', required: true },
999
+ query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
1000
+ teams: {
1001
+ type: 'array',
1002
+ required: true,
1003
+ items: {
1004
+ type: 'object',
1005
+ additionalProperties: false,
1006
+ properties: {
1007
+ team: { type: 'string', required: true },
1008
+ session: { type: 'string', required: true },
1009
+ name: { type: 'string', required: true },
1010
+ description: { type: 'string', required: true },
1011
+ local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
1012
+ origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
1013
+ workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
1014
+ },
1015
+ },
1016
+ },
1017
+ },
1018
+ },
1019
+ render: (args, value) => [{
1020
+ type: 'text',
1021
+ text: value.teams.length === 0
1022
+ ? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
1023
+ : value.teams.map(team => `- ${team.team}${team.local === true ? ' [this host]' : ''}${team.name === '' ? '' : ` (${team.name})`}${team.description === '' ? '' : ` — ${team.description}`}${typeof team.origin === 'string' && team.origin !== '' && team.local !== true ? ` @ ${team.origin}` : ''}${typeof team.workspace === 'string' && team.workspace !== '' ? ` [${team.workspace}]` : ''}`).join('\n'),
1024
+ }],
1025
+ },
1026
+ presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
1027
+ execute: async (args) => {
1028
+ const query = args.query?.trim().toLowerCase() ?? '';
1029
+ const teams = await listDirectoryTeams(query !== '');
1030
+ if (query === '')
1031
+ return { ok: true, query: '', teams };
1032
+ const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
1033
+ || (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query);
1034
+ return { ok: true, query, teams: teams.filter(matches) };
1035
+ },
1036
+ }));
1037
+ async function listDirectoryTeams(expand) {
1038
+ const localOrigin = lanIp === '' ? `${session} [this host]` : `${session} [this host, ${lanIp}]`;
1039
+ const teams = [
1040
+ { team: config.team, session, name: config.agentName, description: '', local: true, origin: localOrigin },
1041
+ ...[...sessionNodes.values()].map(agent => ({
1042
+ team: sessionTeamOf(agent),
1043
+ session,
1044
+ ...nodeMetadataOf(agent),
1045
+ local: true,
1046
+ origin: localOrigin,
1047
+ })),
1048
+ ];
1049
+ const fetch = memoizedCardFetch();
1050
+ // Collect per peer in store order (concurrent fetches, ordered merge):
1051
+ // the listing's row order follows the peer store's preference order,
1052
+ // independent of fetch completion timing.
1053
+ const collectPeer = async (peer) => {
1054
+ const card = await fetch(peer);
1055
+ if (card === undefined)
1056
+ return [];
1057
+ // The origin fields (publisher session label + LAN IP) are natural
1058
+ // grouping dimensions: a fleet's rows cluster by the host that owns
1059
+ // them, no manual grouping required.
1060
+ const origin = card.lanIp !== undefined ? `${card.session} (${card.lanIp})` : card.session;
1061
+ const rows = [
1062
+ { team: card.team, session: card.session, name: card.name, description: '', origin },
1063
+ ];
1064
+ for (const entry of card.sessionTeams ?? []) {
1065
+ rows.push({ team: entry.team, session: card.session, name: entry.name, description: entry.description, origin, ...(entry.workspace !== undefined ? { workspace: entry.workspace } : {}) });
1066
+ }
1067
+ return rows;
1068
+ };
1069
+ const sweep = async (peers) => {
1070
+ const collected = await Promise.all(peers.map(collectPeer));
1071
+ for (const rows of collected)
1072
+ teams.push(...rows);
1073
+ };
1074
+ const before = peerStore.list();
1075
+ await sweep(before);
1076
+ if (expand) {
1077
+ // One extra hop: only peers the first sweep's referrals newly offered,
1078
+ // collected once each — the memo keeps already-fetched URLs free.
1079
+ const fresh = peerStore.list().filter(peer => !before.includes(peer));
1080
+ if (fresh.length > 0)
1081
+ await sweep(fresh);
1082
+ }
1083
+ return teams;
1084
+ }
1085
+ ctx.tools.register(defineTool({
1086
+ name: 'a2a_route',
1087
+ description: 'Route a message to a remote A2A team on the peer network and await its reply. '
1088
+ + 'Pick the team with a2a_teams first. Team names also resolve through peers\' signed zone '
1089
+ + 'delegation records (zone-relative, at most 5 hops). Reuse context_id from a previous reply '
1090
+ + 'to continue that conversation. The call blocks until the remote team responds, which may take minutes — '
1091
+ + 'pass async for long-running tasks: delivery returns immediately and the target routes a receipt '
1092
+ + '(message starting "[A2A receipt] task <task_id> <outcome summary>") back to your team.',
1093
+ parameters: {
1094
+ team: { type: 'string', required: true, description: 'Target team name, from a2a_teams.' },
1095
+ message: { type: 'string', required: true, description: 'The request text to send.' },
1096
+ context_id: { type: 'string', description: 'Context id from a previous a2a_route reply, to continue that conversation.' },
1097
+ async: { type: 'boolean', description: 'Fire-and-forget: deliver and return immediately (delivered:true + task_id) without waiting for the target\'s reply. The target answers by routing a receipt — a message starting "[A2A receipt] task <task_id> <outcome summary>" — back to your team (visible in a2a_status activity), or follow up with the context_id. Prefer async for long-running tasks (minutes to hours).' },
1098
+ },
1099
+ output: {
1100
+ schema: {
1101
+ type: 'object',
1102
+ additionalProperties: true,
1103
+ },
1104
+ render: renderRoute,
1105
+ },
1106
+ timeoutMs: config.routeTimeoutMs,
1107
+ presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
1108
+ execute: async (args, exec) => {
1109
+ // The caller identity travels as a routable team (the calling session's
1110
+ // node team when it has joined, else the node label), so the receiver
1111
+ // can answer with one a2a_route instead of an unroutable display label.
1112
+ const callerSession = exec.agent === undefined ? undefined
1113
+ : sessionNodes.has(String(exec.agent.id)) ? sessionTeamOf(exec.agent) : sessionLabelOf(exec.agent);
1114
+ const fetch = memoizedCardFetch();
1115
+ // The task id is born at the caller (idempotency key semantics): both
1116
+ // dispatchers carry the one id, the peer request echoes it, and the
1117
+ // steered receipt header names it — so "[A2A receipt] task <id>" from
1118
+ // the target correlates with the route's own result verbatim.
1119
+ const taskId = `direct-${Math.random().toString(16).slice(2, 10)}`;
1120
+ // Candidate order: this host's own teams first (an in-process steer —
1121
+ // cheapest possible and immune to nested-signal aborts), then direct
1122
+ // publishers, then zone delegations, deduplicated by URL.
1123
+ const failures = [];
1124
+ const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, exec.signal, args.async === true, taskId);
1125
+ if (outcome !== undefined) {
1126
+ if (outcome.ok)
1127
+ return outcome;
1128
+ failures.push(`local: ${outcome.error}`);
1129
+ }
1130
+ const candidates = await directoryPeerCandidates(fetch, args.team, failures);
1131
+ // Failover: try every candidate in order — an offline path falls back
1132
+ // to the next alternate without a caller-visible error; only
1133
+ // exhaustion surfaces, with each candidate's reason.
1134
+ for (const candidate of candidates) {
1135
+ // Capability gate: wait:false only dials peers whose signed card
1136
+ // declares async — against a pre-0.5.2 peer the flag would silently
1137
+ // degrade into a minutes-long blocking hold (an async intent must
1138
+ // never turn into a surprise wait). The memo keeps the card fetch
1139
+ // free after the directory walk.
1140
+ let peerAsync = false;
1141
+ if (args.async === true) {
1142
+ const card = await fetch(candidate);
1143
+ const caps = card?.capabilities;
1144
+ peerAsync = card !== undefined && caps?.async === true;
1145
+ }
1146
+ const result = await dispatchPeerCandidate(candidate, args.team, args.message, args.context_id, exec.signal, callerSession, peerAsync, taskId);
1147
+ if (result.ok || exec.signal.aborted) {
1148
+ if (args.async === true && !peerAsync && result.ok) {
1149
+ return { ...result, reply: `${result.reply}\n(Note: the peer does not advertise async; the call waited synchronously.)` };
1150
+ }
1151
+ return result;
1152
+ }
1153
+ failures.push(`${candidate}: ${result.error}`);
1154
+ }
1155
+ if (failures.length > 0)
1156
+ logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
1157
+ return {
1158
+ ok: false,
1159
+ error: candidates.length === 0
1160
+ ? `team "${args.team}" is not published by any configured peer${failures.length === 0 ? '' : ` (unresolved delegations: ${failures.join('; ')})`}`
1161
+ : `team "${args.team}" failed on every candidate: ${failures.join('; ')}`,
1162
+ code: -32004,
1163
+ };
1164
+ },
1165
+ }));
1166
+ /**
1167
+ * The route dispatcher's local half: same-host candidates dial in-process,
1168
+ * not over loopback HTTP — a loopback fetch nests the final wait inside the
1169
+ * caller's own tool signal, and when that turn budget aborts, the abort
1170
+ * surfaces as the peer's failure even though delivery succeeded. The turn
1171
+ * signal still races the wait; on abort the honest result reports delivery
1172
+ * without a reply. Results use the canonical A2aRouteOk shape — the wire
1173
+ * route shape renders as "failed: undefined" through the tool renderer.
1174
+ * @param asyncMode - deliver without waiting: the steer fires, the
1175
+ * registered waiter still flushes (harmlessly) into the settled promise,
1176
+ * and the caller gets the delivered shape with the receipt contract.
1177
+ * @returns the canonical result, or undefined when the team is not local.
1178
+ */
1179
+ async function dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode = false, taskIdFromCaller) {
1180
+ const webServer = ctx.get('webServer');
1181
+ const teamIsLocal = team === config.team
1182
+ || resolveAgentForTeam(team) !== undefined
1183
+ || joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team);
1184
+ if (!teamIsLocal || webServer === undefined)
1185
+ return undefined;
1186
+ const taskId = taskIdFromCaller ?? `direct-${Math.random().toString(16).slice(2, 10)}`;
1187
+ const flight = beginRoute(team, 'local');
1188
+ if (asyncMode) {
1189
+ // Delivery means the message is IN, not merely enqueued: a cold team
1190
+ // materializes first (the wake settles, or fails honestly), then the
1191
+ // steer fires, then delivered answers. The receipt header carries the
1192
+ // task id so the target can echo it back verbatim.
1193
+ const agent = resolveAgentForTeam(team) ?? (team === config.team ? liveAgent() : undefined);
1194
+ const woken = agent !== undefined ? Promise.resolve(agent) : wakeColdTeam(team);
1195
+ if (woken === undefined) {
1196
+ endRoute(flight);
1197
+ return { ok: false, error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.`, code: -32000 };
1198
+ }
1199
+ try {
1200
+ const target = await woken;
1201
+ steerRelay(target, `[A2A direct] (task ${taskId}) from "${callerSession ?? session}" (routed to ${team}) sent:\n\n${message}`);
1202
+ endRoute(flight);
1203
+ recordActivity('out', team, 'local', true);
1204
+ return {
1205
+ ok: true,
1206
+ team,
1207
+ reply: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
1208
+ task_id: taskId,
1209
+ context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
1210
+ task_status: 'TASK_STATE_DELIVERED',
1211
+ };
1212
+ }
1213
+ catch (error) {
1214
+ endRoute(flight);
1215
+ recordActivity('out', team, 'local', false);
1216
+ return { ok: false, error: `waking or steering the session for team "${team}" failed: ${String(error)}`, code: -32000 };
1217
+ }
1218
+ }
1219
+ const wait = routeIntoAgent(team, message, callerSession ?? session, taskId);
1220
+ const aborted = new Promise((_, reject) => {
1221
+ signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
1222
+ });
1223
+ // A reply wait without a deadline parks the caller's whole turn forever
1224
+ // when the target session answers on its own (slow or never) cadence. The
1225
+ // deadline turns that into the same honest delivered shape the abort path
1226
+ // returns: the receipt contract carries the follow-up, the caller keeps
1227
+ // moving.
1228
+ const REPLY_WAIT_MS = 180_000;
1229
+ const expired = new Promise((_, reject) => {
1230
+ const timer = setTimeout(() => { reject(new Error('reply-wait-timeout')); }, REPLY_WAIT_MS);
1231
+ timer.unref?.();
1232
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); };
1233
+ const onAbort = () => done();
1234
+ signal.addEventListener('abort', onAbort, { once: true });
1235
+ wait.then(done, done);
1236
+ });
1237
+ let outcome;
1238
+ try {
1239
+ outcome = await Promise.race([wait, aborted, expired]);
1240
+ }
1241
+ catch (error) {
1242
+ if (String(error).includes('reply-wait-timeout')) {
1243
+ endRoute(flight);
1244
+ recordActivity('out', team, 'local', true);
1245
+ return {
1246
+ ok: true,
1247
+ team,
1248
+ reply: `The message was delivered to ${team}, but no reply arrived within ${Math.round(REPLY_WAIT_MS / 1000)}s (the target session answers on its own cadence). It routes a receipt, a message starting "[A2A receipt] task ${taskId}", back to your team when done; check a2a_status activity or follow up with the context id.`,
1249
+ task_id: taskId,
1250
+ context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
1251
+ task_status: 'TASK_STATE_DELIVERED',
1252
+ };
1253
+ }
1254
+ endRoute(flight);
1255
+ recordActivity('out', team, 'local', true);
1256
+ return {
1257
+ ok: true,
1258
+ team,
1259
+ reply: `The message was delivered to ${team}, but the wait for its reply was aborted (the target session answers on its own cadence; route again with the context id).`,
1260
+ task_id: taskId,
1261
+ context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
1262
+ task_status: 'TASK_STATE_ABORTED_WAIT',
1263
+ };
1264
+ }
1265
+ endRoute(flight);
1266
+ recordActivity('out', team, 'local', outcome.ok);
1267
+ if (outcome.ok) {
1268
+ return {
1269
+ ok: true,
1270
+ team,
1271
+ reply: outcome.reply,
1272
+ task_id: taskId,
1273
+ context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
1274
+ task_status: 'TASK_STATE_COMPLETED',
1275
+ };
1276
+ }
1277
+ return { ok: false, error: outcome.error, code: -32000 };
1278
+ }
1279
+ /**
1280
+ * The route dispatcher's peer half: one candidate dial with the in-flight
1281
+ * registration and activity-ring record the panel reads. Async mode sends
1282
+ * wait:false — the peer steers and answers delivered immediately (its
1283
+ * 0.5.2+ handler), so cross-host long tasks ride the receipt contract
1284
+ * exactly like same-host ones; older peers that ignore the flag simply
1285
+ * keep the blocking wait (graceful degradation).
1286
+ */
1287
+ async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller) {
1288
+ const flight = beginRoute(team, url);
1289
+ const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller);
1290
+ endRoute(flight);
1291
+ recordActivity('out', team, url, result.ok);
1292
+ return result;
1293
+ }
1294
+ /**
1295
+ * The directory's candidate walk for one team: direct publishers first,
1296
+ * then every tracked zone's delegation resolutions (cycle/depth/key-binding
1297
+ * failures are configuration bugs: closed, logged, and surfaced into
1298
+ * {@link failures}), deduplicated by URL.
1299
+ */
1300
+ async function directoryPeerCandidates(fetch, team, failures) {
1301
+ const candidates = [];
1302
+ for (const peer of peerStore.list()) {
1303
+ const card = await fetch(peer);
1304
+ if (card === undefined)
1305
+ continue;
1306
+ if (card.team === team || (card.sessionTeams ?? []).some(entry => entry.team === team))
1307
+ candidates.push(peer);
1308
+ }
1309
+ for (const peer of peerStore.list()) {
1310
+ const outcome = await resolveZone(fetch, peer, team);
1311
+ if (outcome.ok) {
1312
+ if (!candidates.includes(outcome.url))
1313
+ candidates.push(outcome.url);
1314
+ continue;
1315
+ }
1316
+ if (outcome.reason === 'not-found' || outcome.reason === 'unreachable')
1317
+ continue;
1318
+ failures.push(`${peer} ${outcome.reason}: ${outcome.detail}`);
1319
+ }
1320
+ return candidates;
1321
+ }
1322
+ ctx.tools.register(defineTool({
1323
+ name: 'a2a_status',
1324
+ description: 'Read-only A2A network health: the tracked peer fleet with quality scores, routes currently in flight '
1325
+ + '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
1326
+ + 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
1327
+ parameters: {},
1328
+ output: {
1329
+ schema: {
1330
+ type: 'object',
1331
+ additionalProperties: false,
1332
+ properties: {
1333
+ ok: { type: 'boolean', required: true },
1334
+ peers: {
1335
+ type: 'array',
1336
+ required: true,
1337
+ items: {
1338
+ type: 'object',
1339
+ additionalProperties: false,
1340
+ properties: {
1341
+ url: { type: 'string', required: true },
1342
+ score: { type: 'number' },
1343
+ },
1344
+ },
1345
+ },
1346
+ inFlight: {
1347
+ type: 'array',
1348
+ required: true,
1349
+ items: {
1350
+ type: 'object',
1351
+ additionalProperties: false,
1352
+ properties: {
1353
+ team: { type: 'string', required: true },
1354
+ peer: { type: 'string', required: true },
1355
+ startedAt: { type: 'number', required: true },
1356
+ },
1357
+ },
1358
+ },
1359
+ activity: {
1360
+ type: 'array',
1361
+ required: true,
1362
+ items: {
1363
+ type: 'object',
1364
+ additionalProperties: false,
1365
+ properties: {
1366
+ ts: { type: 'number', required: true },
1367
+ dir: { type: 'string', required: true },
1368
+ team: { type: 'string', required: true },
1369
+ peer: { type: 'string', required: true },
1370
+ ok: { type: 'boolean', required: true },
1371
+ },
1372
+ },
1373
+ },
1374
+ },
1375
+ },
1376
+ render: (_args, value) => [{
1377
+ type: 'text',
1378
+ text: [
1379
+ ...(value.inFlight.length === 0 ? [] : ['In flight:']),
1380
+ ...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
1381
+ `Peers (${String(value.peers.length)}):`,
1382
+ ...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
1383
+ ...(value.activity.length === 0 ? [] : ['Recent routes:']),
1384
+ ...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
1385
+ ].join('\n'),
1386
+ }],
1387
+ },
1388
+ presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
1389
+ execute: async () => {
1390
+ return {
1391
+ ok: true,
1392
+ peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
1393
+ inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
1394
+ activity: recentActivity.slice(),
1395
+ };
1396
+ },
1397
+ }));
1398
+ const pendingFinals = new Map();
1399
+ const armFlushTimeout = (agentId, waiter) => {
1400
+ return ctx.timer.timeout(() => {
1401
+ const entries = pendingFinals.get(agentId);
1402
+ if (entries === undefined)
1403
+ return;
1404
+ const kept = entries.filter(entry => entry !== waiter);
1405
+ if (kept.length === 0)
1406
+ pendingFinals.delete(agentId);
1407
+ else
1408
+ pendingFinals.set(agentId, kept);
1409
+ waiter.answer('The DSH session produced no final reply within the configured window.');
1410
+ }, config.flushTimeoutMs);
1411
+ };
1412
+ function flushFinals(agentId) {
1413
+ const entries = pendingFinals.get(agentId);
1414
+ if (entries === undefined || entries.length === 0)
1415
+ return;
1416
+ const agent = ctx.get('agents')?.get(SessionId(agentId));
1417
+ if (agent === undefined) {
1418
+ for (const entry of entries) {
1419
+ entry.timeoutDisposer?.();
1420
+ entry.answer('The target DSH session is no longer live.');
1421
+ }
1422
+ pendingFinals.delete(agentId);
1423
+ return;
1424
+ }
1425
+ const events = agent.session.events;
1426
+ const floor = Math.min(...entries.map(entry => entry.sinceEvents));
1427
+ let reply = '';
1428
+ for (let index = events.length - 1; index >= floor; index--) {
1429
+ const event = events[index];
1430
+ if (event === undefined || event.type !== 'assistant/message')
1431
+ continue;
1432
+ reply = event.data.message.content
1433
+ .filter((block) => block.type === 'text')
1434
+ .map(block => block.text)
1435
+ .join('\n');
1436
+ break;
1437
+ }
1438
+ if (reply !== '') {
1439
+ for (const entry of entries)
1440
+ entry.answer(reply);
1441
+ pendingFinals.delete(agentId);
1442
+ }
1443
+ }
1444
+ // Every inbound route is a direct route (final semantics), so the idle
1445
+ // flush listens unconditionally — an empty pendingFinals map makes it a
1446
+ // no-op. Node facts need no refresh here: the card serves them fresh at
1447
+ // read time.
1448
+ ctx.on('agent/status', (payload) => {
1449
+ if (payload.status === 'idle')
1450
+ flushFinals(String(payload.agent.id));
1451
+ });
1452
+ }
1453
+ /**
1454
+ * Load the node's Ed25519 private key, generating and persisting it on first
1455
+ * use. The key IS the node's card identity: a persisted key keeps signatures
1456
+ * verifiable across restarts, which is why storage lands with the announce
1457
+ * config surface rather than after it.
1458
+ * @param path - absolute PEM path (`<dsh-home>/a2a/node-key.pem`).
1459
+ * @returns the private key used to sign every published card.
1460
+ */
1461
+ function loadOrCreateNodeKey(path) {
1462
+ if (existsSync(path)) {
1463
+ return createPrivateKey(readFileSync(path, 'utf8'));
1464
+ }
1465
+ const { privateKey } = generateKeyPairSync('ed25519');
1466
+ mkdirSync(dirname(path), { recursive: true });
1467
+ writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 });
1468
+ return privateKey;
1469
+ }
1470
+ /**
1471
+ * Derive the default wire session label from a per-home node id: eight hex
1472
+ * characters generated once, so two deployments mounting defaults do not
1473
+ * collide while a persisted home keeps the label stable across restarts.
1474
+ * @param home - resolved harness home.
1475
+ * @returns the derived session label (`dsh-host-<8 hex>`).
1476
+ */
1477
+ function deriveSession(home) {
1478
+ const path = join(home, 'a2a', 'node-id');
1479
+ if (existsSync(path))
1480
+ return `dsh-host-${readFileSync(path, 'utf8').trim()}`;
1481
+ const id = randomBytes(4).toString('hex');
1482
+ mkdirSync(dirname(path), { recursive: true });
1483
+ writeFileSync(path, `${id}\n`, { mode: 0o600 });
1484
+ return `dsh-host-${id}`;
1485
+ }