@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.32

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.
Files changed (88) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +126 -74
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +1 -1
  75. package/nexus-stage/src/federation.ts +1 -0
  76. package/nexus-stage/src/main.ts +41 -18
  77. package/nexus-stage/src/planning-member-handles.ts +34 -0
  78. package/nexus-stage/src/planning-mirror.ts +47 -3
  79. package/nexus-stage/src/planning-reachability.ts +28 -0
  80. package/nexus-stage/src/planning-types.ts +1 -1
  81. package/nexus-stage/src/planning-view.ts +29 -26
  82. package/nexus-stage/src/planning.css +4 -0
  83. package/nexus-stage/src/wb-directory.ts +13 -0
  84. package/nexus-stage/src/workbench.css +3 -0
  85. package/nexus-stage/src/workbench.ts +5 -1
  86. package/package.json +1 -1
  87. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  88. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
@@ -0,0 +1,684 @@
1
+ /**
2
+ * The information-plane model tools (gh#77 B3-R): whoami / teams / status /
3
+ * tasks / probe moved verbatim out of apply() in src/index.ts. Registration
4
+ * order is preserved by the caller invoking each registrar at the original
5
+ * tool position; the joined-team scope helpers and describeAge moved with the
6
+ * tools because only these tools use them. SWEEP_CONCURRENCY arrives through
7
+ * a getter so the deps object can be built before the const is declared.
8
+ * @module src/tools/info
9
+ */
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { peerDirectoryRows } from "../peer-directory.js";
12
+ import { peerContentMeta } from "../node-self.js";
13
+ /**
14
+ * Build the five registrars. Call createInfoTools after its deps exist, then
15
+ * invoke each registrar at the tool's original registration position — the
16
+ * registry order the model sees must not change.
17
+ */
18
+ export function createInfoTools(deps) {
19
+ const { ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger, joinedSessions, teamMemberships, canvasStore, exposureStore, idempotencyStore, cardCache, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, id8, sessionTeamOf, nodeMetadataOf, canvasTeamOf, canvasTeamsOfSession, localHandleResolver, memoizedCardFetch, mapBounded, SWEEP_CONCURRENCY, a2aJoinGateRefusal, observeAuthorization, settlePeerCard, client, listDirectoryTeams, } = deps;
20
+ // S5 (owner directive 2026-10-20): information-plane scoping. The host's
21
+ // joined-team scope is the union of every local node's roster
22
+ // declarations — a host-level set, not per-session: supervisor seats need
23
+ // the cross-team overview, and 「加入的团队」 reads as the teams this
24
+ // host's nodes joined. The information-plane tools (a2a_teams /
25
+ // a2a_status / a2a_probe) disclose remote facts only inside that scope;
26
+ // a2a_tasks — this host's own dispatch ledger and receipt-reconciliation
27
+ // surface, including initiator-exempt dispatches — stays unscoped by
28
+ // ruling (its rows name teams this host itself routed to, never a fleet
29
+ // census).
30
+ const joinedTeamScope = () => {
31
+ const joined = new Set();
32
+ for (const entry of teamMemberships.list()) {
33
+ for (const team of entry.teams)
34
+ joined.add(team);
35
+ }
36
+ // G4 (review artifact 688c0354d3 v2): canvas membership is join intent
37
+ // too. This host's canvas teams — and every member handle they name
38
+ // (remote zone-qualified handles as-is, local session ids as their
39
+ // session-team handle) — belong to the scope, so canvas teammates stay
40
+ // visible to each other under teamScopeInfo instead of the roster-only
41
+ // reading splitting them apart.
42
+ for (const name of canvasStore.list()) {
43
+ joined.add(canvasTeamOf(name));
44
+ for (const member of canvasStore.membersOf(name)) {
45
+ joined.add(member.includes('/') ? member : `${config.team}/${id8(member)}`);
46
+ }
47
+ }
48
+ return joined;
49
+ };
50
+ /**
51
+ * Match exactly the sanitized rows the directory would expose: process/session
52
+ * and roster teams use their joined names; canvas rows need an explicitly
53
+ * joined local member resolved by the same local-first handle rules.
54
+ */
55
+ const cardServesJoined = (card, joined, resolveLocal) => peerDirectoryRows(card, '', true).some(row => {
56
+ if (row.canvasMembers !== undefined) {
57
+ return row.canvasMembers.some(ref => {
58
+ const id = resolveLocal(ref);
59
+ return id !== undefined && joinedSessions.has(id);
60
+ });
61
+ }
62
+ return joined.has(row.team) || (row.teams ?? []).some(team => joined.has(team));
63
+ });
64
+ /** Human duration for a ledger row: how long a task has been owed or took. */
65
+ const describeAge = (ms) => {
66
+ const minutes = Math.floor(ms / 60_000);
67
+ if (minutes < 1)
68
+ return 'under a minute';
69
+ if (minutes < 60)
70
+ return `${String(minutes)}m`;
71
+ return `${String(Math.floor(minutes / 60))}h${minutes % 60 === 0 ? '' : `${String(minutes % 60)}m`}`;
72
+ };
73
+ // S5 ruling: a2a_tasks stays unscoped — it is this host's own dispatch
74
+ // ledger (pending / dead / archived), the receipt-reconciliation surface
75
+ // for work this host itself routed, initiator-exempt dispatches included.
76
+ // Its rows name teams this host already talked to; reading them back is
77
+ // bookkeeping, not fleet disclosure.
78
+ function registerWhoami() {
79
+ // K3 feedback P0 (2026-10-20): a session could appear in a2a_teams, route
80
+ // fine, and still be refused by a2a_team_join — with no way to ask "what am
81
+ // I, actually?". whoami is that one-call answer. Deliberately NOT gated by
82
+ // a2aJoinGateRefusal: the unjoined session is precisely the caller that
83
+ // needs it.
84
+ ctx.tools.register(defineTool({
85
+ name: 'a2a_whoami',
86
+ description: 'One-call self-introspection on this host\'s A2A network: the calling session\'s node id, joined/live '
87
+ + 'state, routable team handle, declared team memberships (roster + canvas), the owner\'s teamJoinAllowlist, '
88
+ + 'the teamScopeRouting switch, and this node\'s most recent refused join/leave attempts with reasons. '
89
+ + 'Note: outbound routing never requires join — the originating session is always exempt (initiator face); '
90
+ + 'join buys presence, discovery, and being routed to.',
91
+ parameters: {},
92
+ output: {
93
+ schema: {
94
+ type: 'object',
95
+ additionalProperties: false,
96
+ properties: {
97
+ ok: { type: 'boolean', required: true },
98
+ face: { type: 'string', required: true, description: 'session | host' },
99
+ node: {
100
+ type: 'object',
101
+ additionalProperties: true,
102
+ description: 'id, joined, live, and the routable team handle (zone/id8) once joined.',
103
+ },
104
+ teams: {
105
+ type: 'object',
106
+ additionalProperties: true,
107
+ description: 'roster: allowlisted teams declared via a2a_team_join; canvas: workbench-assigned teams.',
108
+ },
109
+ allowlist: {
110
+ type: 'object',
111
+ additionalProperties: true,
112
+ description: 'The owner-curated teamJoinAllowlist: exact entries (the only join authority, gh#11) plus pattern entries kept readable but carrying no mint rights (deny-all / curated; allow-all only via explicit exact entries).',
113
+ },
114
+ teamScopeRouting: { type: 'boolean' },
115
+ note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
116
+ lastRefusals: {
117
+ type: 'array',
118
+ description: 'Most recent refused join/leave attempts for this node (newest first, ≤3), with reason codes.',
119
+ items: { type: 'object', additionalProperties: true },
120
+ },
121
+ },
122
+ },
123
+ render: (_args, value) => [{
124
+ type: 'text',
125
+ text: value.face === 'host'
126
+ ? `Host face on team ${String(value.node?.team ?? '?')}: routing rides the bare node team (能发即能收)`
127
+ : `Node ${String(value.node?.id ?? '?')}: joined=${String(value.node?.joined)}, live=${String(value.node?.live)}; roster teams: ${(value.teams?.roster ?? []).join(', ') || 'none'}; canvas: ${(value.teams?.canvas ?? []).join(', ') || 'none'}; allowlist effect: ${String(value.allowlist?.effect ?? '?')}`,
128
+ }],
129
+ },
130
+ presentCall: () => ({ card: 'generic', title: 'A2A whoami', kind: 'other', rawInput: null }),
131
+ execute: async (_args, exec) => {
132
+ const agentId = exec?.agent !== undefined ? String(exec.agent.id) : '';
133
+ if (agentId === '') {
134
+ return {
135
+ ok: true,
136
+ face: 'host',
137
+ node: { team: config.team },
138
+ note: 'host face — routing rides the bare node team (能发即能收); joins are session-node concerns',
139
+ };
140
+ }
141
+ const joined = joinedSessions.has(agentId);
142
+ const live = sessionNodes.has(agentId);
143
+ const refusals = teamAuditRing
144
+ .filter(entry => entry.id === agentId && !entry.ok)
145
+ .slice(-3)
146
+ .reverse()
147
+ .map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
148
+ const patterns = [...deps.ownerAllowlist];
149
+ return {
150
+ ok: true,
151
+ face: 'session',
152
+ node: {
153
+ id: agentId,
154
+ ...(joined ? { team: `${config.team}/${id8(agentId)}` } : {}),
155
+ joined,
156
+ live,
157
+ },
158
+ teams: {
159
+ roster: [...teamMemberships.teamsOf(agentId)],
160
+ canvas: [...canvasTeamsOfSession(agentId)],
161
+ },
162
+ allowlist: {
163
+ patterns,
164
+ effect: patterns.length === 0 ? 'deny-all (empty allowlist refuses every join)' : patterns.includes('*') ? 'allow-all' : 'curated',
165
+ },
166
+ teamScopeRouting: config.teamScopeRouting === true,
167
+ ...(refusals.length > 0 ? { lastRefusals: refusals } : {}),
168
+ note: joined
169
+ ? 'join buys presence + being routed to; outbound routing needs no join (initiator face is exempt)'
170
+ : 'NOT joined: no presence/discovery and cannot be routed to; outbound routing still works (initiator face), and joining is a host-side human control (GUI sidebar A2A panel)',
171
+ };
172
+ },
173
+ }));
174
+ }
175
+ function registerTeams() {
176
+ ctx.tools.register(defineTool({
177
+ name: 'a2a_teams',
178
+ description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
179
+ + 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
180
+ + 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
181
+ + 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
182
+ + 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
183
+ + 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
184
+ + 'list as local rows and as remote rows when a peer publishes them (origin/via identify their owner). Local routing resolves '
185
+ + 'the first live member or wakes the first cold one; the dispatching session itself is skipped on the local tiers '
186
+ + '(issue #41: routing to its own group never loops home). Remote candidates still require the existing peer-link and '
187
+ + 'receiver admission checks; a served-fresh canvasOrg is not itself a signature over membership. '
188
+ + 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote canvas rows name a joined session of this host, remote roster rows serve a joined team, and [this host] session rows show only your own row, your canvas teams, and sessions sharing a team with you — an exposed node id invites mis-routing, so the directory does not advertise strangers. The owner\'s GUI panel keeps the full plane; teamScopeInfo: false opens it for tools too. '
189
+ + 'Call this before a2a_route to pick a target team.',
190
+ parameters: {
191
+ query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
192
+ },
193
+ output: {
194
+ // gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
195
+ presentationMeta: peerContentMeta('a2a_teams'),
196
+ schema: {
197
+ type: 'object',
198
+ additionalProperties: false,
199
+ properties: {
200
+ ok: { type: 'boolean', required: true },
201
+ query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
202
+ teams: {
203
+ type: 'array',
204
+ required: true,
205
+ items: {
206
+ type: 'object',
207
+ additionalProperties: false,
208
+ properties: {
209
+ team: { type: 'string', required: true },
210
+ session: { type: 'string', required: true },
211
+ name: { type: 'string', required: true },
212
+ description: { type: 'string', required: true },
213
+ local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
214
+ origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
215
+ via: { type: 'string', description: 'The peer URL this row was discovered through (host:port), when it came from a peer card.' },
216
+ workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
217
+ legacy: { type: 'boolean', description: 'true when the publishing node advertises no roster capability (pre-roster peer).' },
218
+ teams: { type: 'array', items: { type: 'string' }, description: 'Roster teams the node declared for this handle (card teamMemberships).' },
219
+ },
220
+ },
221
+ },
222
+ note: { type: 'string', description: 'Present while teamScopeInfo scoping is on: the joined-team scope plus how many rows outside YOUR collaboration circle were withheld (remote/local split; the process team, your own row, and your canvas teams always show).' },
223
+ },
224
+ },
225
+ render: (args, value) => [{
226
+ type: 'text',
227
+ text: (value.teams.length === 0
228
+ ? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
229
+ : 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'))
230
+ + (value.note === undefined ? '' : `\n${value.note}`),
231
+ }],
232
+ },
233
+ presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
234
+ execute: async (args, exec) => {
235
+ a2aJoinGateRefusal(exec);
236
+ const query = args.query?.trim().toLowerCase() ?? '';
237
+ let teams = await listDirectoryTeams(query !== '', true);
238
+ // S5: remote rows survive only when they serve a joined team (the
239
+ // row's own name or any roster declaration it carries); local rows
240
+ // are host-internal facts and always survive. The note keeps the
241
+ // model from reading a scoped listing as an empty fleet.
242
+ let note;
243
+ if (deps.ownerTeamScopeInfo) {
244
+ const joined = joinedTeamScope();
245
+ const resolveLocal = localHandleResolver();
246
+ const remoteBefore = teams.filter(row => row.local !== true).length;
247
+ teams = teams.filter(row => {
248
+ if (row.local === true)
249
+ return true;
250
+ // A same-zone canvas NAME can exist on two hosts: a local team
251
+ // with that name does not prove membership in the remote one.
252
+ if (row.canvasMembers !== undefined)
253
+ return row.canvasMembers.some(ref => {
254
+ const id = resolveLocal(ref);
255
+ return id !== undefined && joinedSessions.has(id);
256
+ });
257
+ return joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name));
258
+ });
259
+ const hiddenRemote = remoteBefore - teams.filter(row => row.local !== true).length;
260
+ // gh#39 (owner ruling: the listing ADVERTISES targets — an exposed
261
+ // node id invites a session with no collaboration relationship to
262
+ // route at it): [this host] session rows follow the CALLER's
263
+ // collaboration circle. The host's own GUI/panel keeps the full
264
+ // plane (the owner's eyes); the model-tool face is scoped.
265
+ let hiddenLocal = 0;
266
+ const callerAgent = exec.agent;
267
+ if (callerAgent !== undefined) {
268
+ const callerId = String(callerAgent.id);
269
+ const ownHandle = sessionTeamOf(callerAgent);
270
+ const myCanvas = new Set(canvasTeamsOfSession(callerId));
271
+ const myRoster = new Set(teamMemberships.teamsOf(callerId));
272
+ const before = teams.length;
273
+ teams = teams.filter(row => {
274
+ if (row.local !== true)
275
+ return true;
276
+ if (row.team === config.team || row.team === ownHandle)
277
+ return true;
278
+ if (row.team.startsWith(`${config.team}/canvas/`))
279
+ return myCanvas.has(row.name);
280
+ // Another session's team row: visible only when we share a
281
+ // canvas or a declared team with that session.
282
+ const targetId8 = row.team.slice(config.team.length + 1);
283
+ const target = [...sessionNodes.values()].find(agent => id8(String(agent.id)) === targetId8);
284
+ if (target === undefined)
285
+ return true; // unresolvable: keep (host fact)
286
+ const targetId = String(target.id);
287
+ const shares = [...canvasTeamsOfSession(targetId)].some(name => myCanvas.has(name))
288
+ || [...teamMemberships.teamsOf(targetId)].some(name => myRoster.has(name));
289
+ return shares;
290
+ });
291
+ hiddenLocal = before - teams.length;
292
+ }
293
+ const hidden = hiddenRemote + hiddenLocal;
294
+ note = `teamScopeInfo on: ${String(joined.size)} joined team${joined.size === 1 ? '' : 's'} on this host — ${String(hidden)} row${hidden === 1 ? '' : 's'} outside your collaboration circle withheld (${String(hiddenRemote)} remote, ${String(hiddenLocal)} local [this host] session rows; the process team, your own row, and your canvas teams always show). To widen the scope: a human composes the shared canvas team (the board drag), the owner adds the EXACT team name to teamJoinAllowlist (patterns carry no mint rights, gh#11), or the owner sets teamScopeInfo: false for the open plane`;
295
+ }
296
+ // Canvas members are a private scope hint, not part of the tool schema.
297
+ teams = teams.map(({ canvasMembers: _canvasMembers, ...row }) => row);
298
+ if (query === '')
299
+ return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
300
+ const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
301
+ || team.session.toLowerCase().includes(query)
302
+ || (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
303
+ return { ok: true, query, teams: teams.filter(matches), ...(note !== undefined ? { note } : {}) };
304
+ },
305
+ }));
306
+ }
307
+ function registerStatus() {
308
+ ctx.tools.register(defineTool({
309
+ name: 'a2a_status',
310
+ description: 'Read-only A2A network health: the tracked peer fleet with quality scores, routes currently in flight '
311
+ + '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
312
+ + 'Includes the idempotency window occupancy (claims, replays, conflicts) behind the UNIQUE-ization '
313
+ + 'traffic watch. '
314
+ + 'While teamScopeInfo is on (default), peers / in-flight / activity narrow to the joined-team scope — local traffic and idempotency aggregates stay. '
315
+ + 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
316
+ parameters: {},
317
+ output: {
318
+ // gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
319
+ presentationMeta: peerContentMeta('a2a_status'),
320
+ schema: {
321
+ type: 'object',
322
+ additionalProperties: false,
323
+ properties: {
324
+ ok: { type: 'boolean', required: true },
325
+ peers: {
326
+ type: 'array',
327
+ required: true,
328
+ items: {
329
+ type: 'object',
330
+ additionalProperties: false,
331
+ properties: {
332
+ url: { type: 'string', required: true },
333
+ score: { type: 'number' },
334
+ },
335
+ },
336
+ },
337
+ inFlight: {
338
+ type: 'array',
339
+ required: true,
340
+ items: {
341
+ type: 'object',
342
+ additionalProperties: false,
343
+ properties: {
344
+ team: { type: 'string', required: true },
345
+ peer: { type: 'string', required: true },
346
+ startedAt: { type: 'number', required: true },
347
+ },
348
+ },
349
+ },
350
+ activity: {
351
+ type: 'array',
352
+ required: true,
353
+ items: {
354
+ type: 'object',
355
+ additionalProperties: false,
356
+ properties: {
357
+ ts: { type: 'number', required: true },
358
+ dir: { type: 'string', required: true },
359
+ team: { type: 'string', required: true },
360
+ peer: { type: 'string', required: true },
361
+ ok: { type: 'boolean', required: true },
362
+ },
363
+ },
364
+ },
365
+ idempotency: {
366
+ type: 'object',
367
+ additionalProperties: false,
368
+ required: true,
369
+ properties: {
370
+ window: { type: 'number', required: true },
371
+ cap: { type: 'number', required: true },
372
+ pending: { type: 'number', required: true },
373
+ settled: { type: 'number', required: true },
374
+ claimsFresh: { type: 'number', required: true },
375
+ replays: { type: 'number', required: true },
376
+ conflicts: { type: 'number', required: true },
377
+ },
378
+ },
379
+ },
380
+ },
381
+ render: (_args, value) => [{
382
+ type: 'text',
383
+ text: [
384
+ ...(value.inFlight.length === 0 ? [] : ['In flight:']),
385
+ ...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
386
+ `Peers (${String(value.peers.length)}):`,
387
+ ...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
388
+ `Idempotency window: ${String(value.idempotency.window)}/${String(value.idempotency.cap)} (pending ${String(value.idempotency.pending)}, settled ${String(value.idempotency.settled)}) — claims ${String(value.idempotency.claimsFresh)}, replays ${String(value.idempotency.replays)}, conflicts ${String(value.idempotency.conflicts)}`,
389
+ ...(value.activity.length === 0 ? [] : ['Recent routes:']),
390
+ ...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
391
+ ].join('\n'),
392
+ }],
393
+ },
394
+ presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
395
+ execute: async (_args, exec) => {
396
+ a2aJoinGateRefusal(exec);
397
+ // S5 open plane: the unscoped snapshot, as before.
398
+ if (!config.teamScopeInfo) {
399
+ return {
400
+ ok: true,
401
+ peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
402
+ inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
403
+ activity: recentActivity.slice(),
404
+ idempotency: idempotencyStore.stats(),
405
+ };
406
+ }
407
+ // S5 scoped plane: the tracked fleet narrows to peers whose verified
408
+ // card serves a joined team (bounded-concurrency memoized fetch — a
409
+ // warm sweep costs no extra round trips); routing facts keep
410
+ // host-internal traffic (peer 'local') and drop remote rows whose
411
+ // team is outside the scope, so cross-fleet collaborations stay
412
+ // unread here. The idempotency aggregate is host machinery, not
413
+ // attribution — it stays unscoped like the ledger it serves.
414
+ const joined = joinedTeamScope();
415
+ const resolveLocal = localHandleResolver();
416
+ const visibleTeams = new Set(joined);
417
+ for (const agent of sessionNodes.values()) {
418
+ if (teamMemberships.teamsOf(String(agent.id)).some(team => joined.has(team)))
419
+ visibleTeams.add(sessionTeamOf(agent));
420
+ }
421
+ // The host's own process-team face is host-internal ALWAYS — a
422
+ // zero-roster host must still see inbound deliveries and outbound
423
+ // attempts addressed to itself (review M2: gating this on
424
+ // joined.size > 0 self-blinded exactly that traffic).
425
+ visibleTeams.add(config.team);
426
+ // G2 (review artifact 688c0354d3 v2): the host-synthetic face rows —
427
+ // the /a2a/log telemetry records under the synthetic team label
428
+ // 'log' (recordActivity('in', 'log', caller) on both admission
429
+ // outcomes) — are THIS host's own face facts, not fleet observation.
430
+ // Scoping filtered them like foreign traffic and hid the host's own
431
+ // inbound log-face telemetry from its own supervisor; they pass.
432
+ visibleTeams.add('log');
433
+ const fetch = memoizedCardFetch();
434
+ const peerRows = await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => {
435
+ const card = await fetch(url);
436
+ return card !== undefined && cardServesJoined(card, joined, resolveLocal) ? { url, score: peerStore.score(url) } : undefined;
437
+ });
438
+ const peers = peerRows.filter((row) => row !== undefined);
439
+ const inScope = (entry) => entry.peer === 'local' || visibleTeams.has(entry.team);
440
+ return {
441
+ ok: true,
442
+ peers,
443
+ inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })).filter(inScope),
444
+ activity: recentActivity.filter(inScope),
445
+ idempotency: idempotencyStore.stats(),
446
+ };
447
+ },
448
+ }));
449
+ }
450
+ function registerTasks() {
451
+ ctx.tools.register(defineTool({
452
+ name: 'a2a_tasks',
453
+ description: 'Read-only three-tier ledger of routed A2A tasks: pending rows still owed a receipt, rows '
454
+ + 'auto-dead-lettered past the stale TTL, and the bounded archive of correlated '
455
+ + '`[A2A receipt] task <task_id>` outcomes. Use it to reconcile dispatched async work instead of '
456
+ + 're-routing to ask for progress.',
457
+ parameters: {},
458
+ output: {
459
+ // gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
460
+ presentationMeta: peerContentMeta('a2a_tasks'),
461
+ schema: {
462
+ type: 'object',
463
+ additionalProperties: false,
464
+ properties: {
465
+ ok: { type: 'boolean', required: true },
466
+ tasks: {
467
+ type: 'array',
468
+ required: true,
469
+ items: {
470
+ type: 'object',
471
+ additionalProperties: false,
472
+ properties: {
473
+ taskId: { type: 'string', required: true },
474
+ team: { type: 'string', required: true },
475
+ peer: { type: 'string', required: true },
476
+ startedAt: { type: 'number', required: true },
477
+ contextId: { type: 'string' },
478
+ brief: { type: 'string' },
479
+ status: { type: 'string', required: true },
480
+ deadAt: { type: 'number' },
481
+ direction: { type: 'string', enum: ['inbound', 'outbound'] },
482
+ receiptExpected: { type: 'boolean' },
483
+ reason: { type: 'string', enum: ['fire-forget', 'unconsumed'] },
484
+ sender: {
485
+ type: 'object',
486
+ additionalProperties: false,
487
+ properties: {
488
+ kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
489
+ id: { type: 'string', required: true },
490
+ face: { type: 'string' },
491
+ },
492
+ },
493
+ },
494
+ },
495
+ },
496
+ archive: {
497
+ type: 'array',
498
+ required: true,
499
+ items: {
500
+ type: 'object',
501
+ additionalProperties: false,
502
+ properties: {
503
+ taskId: { type: 'string', required: true },
504
+ team: { type: 'string', required: true },
505
+ startedAt: { type: 'number', required: true },
506
+ resolvedAt: { type: 'number', required: true },
507
+ summary: { type: 'string' },
508
+ brief: { type: 'string' },
509
+ sender: {
510
+ type: 'object',
511
+ additionalProperties: false,
512
+ properties: {
513
+ kind: { type: 'string', required: true, enum: ['session', 'host', 'bridge'] },
514
+ id: { type: 'string', required: true },
515
+ face: { type: 'string' },
516
+ },
517
+ },
518
+ },
519
+ },
520
+ },
521
+ archivedTotal: { type: 'number', required: true },
522
+ },
523
+ },
524
+ render: (_args, value) => [{
525
+ type: 'text',
526
+ text: value.tasks.length === 0 && value.archivedTotal === 0
527
+ ? 'No routed tasks are owed a receipt.'
528
+ : [
529
+ ...(() => {
530
+ // C-ruling split (2026-09-01 decision seat): only young,
531
+ // receipt-expected pending rows are settleable debt — the
532
+ // stale tail is conversational noise waiting on the dead
533
+ // TTL; count it, don't list it.
534
+ const pending = value.tasks.filter((task) => task.status === 'pending');
535
+ const settleable = pending.filter((task) => task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000);
536
+ const staleCount = pending.length - settleable.length;
537
+ return [
538
+ ...(settleable.length > 0 ? ['Owed receipts:'] : []),
539
+ ...settleable.map((task) => {
540
+ const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
541
+ const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
542
+ return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), waiting ${describeAge(Date.now() - task.startedAt)}${context}`;
543
+ }),
544
+ ...(staleCount > 0 ? [`Delivered conversationally without a formal receipt (${String(staleCount)}) — not debts; they auto-dead-letter at the stale TTL.`] : []),
545
+ ];
546
+ })(),
547
+ ...(value.tasks.some((task) => task.status === 'dead') ? ['Dead-lettered (auto-flagged past the stale TTL; a revived target can still settle with a late receipt):'] : []),
548
+ ...value.tasks.filter((task) => task.status === 'dead').map((task) => {
549
+ const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
550
+ const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
551
+ return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), dispatched ${describeAge(Date.now() - task.startedAt)} ago${context}`;
552
+ }),
553
+ ...(value.archivedTotal > 0 ? [`Archived (${String(value.archivedTotal)}), most recent first:`] : []),
554
+ ...value.archive.map((entry) => {
555
+ const brief = entry.brief === undefined || entry.brief === '' ? '' : ` — "${entry.brief}"`;
556
+ return ` - ${entry.taskId}${brief} → ${entry.team}: after ${describeAge(entry.resolvedAt - entry.startedAt)}, ${entry.summary === undefined || entry.summary === '' ? 'resolved' : entry.summary}`;
557
+ }),
558
+ ...(value.archivedTotal > value.archive.length ? [` (+${String(value.archivedTotal - value.archive.length)} older not shown)`] : []),
559
+ ].join('\n'),
560
+ }],
561
+ },
562
+ presentCall: () => ({ card: 'generic', title: 'A2A task ledger', kind: 'other', rawInput: null }),
563
+ execute: async (_args, exec) => {
564
+ a2aJoinGateRefusal(exec);
565
+ observeAuthorization('lazy');
566
+ const archiveAll = taskLedger.archive();
567
+ return {
568
+ ok: true,
569
+ tasks: taskLedger.list().map(task => ({ ...task })),
570
+ archive: archiveAll.slice(0, 5).map(({ taskId, team, startedAt, resolvedAt, summary, brief, sender }) => ({ taskId, team, startedAt, resolvedAt, ...(summary !== undefined && summary !== '' ? { summary } : {}), ...(brief !== undefined ? { brief } : {}), ...(sender !== undefined ? { sender } : {}) })),
571
+ archivedTotal: archiveAll.length,
572
+ };
573
+ },
574
+ }));
575
+ }
576
+ function registerProbe() {
577
+ ctx.tools.register(defineTool({
578
+ name: 'a2a_probe',
579
+ description: 'Probe the tracked peer fleet for reachability and round-trip latency: one verified-card fetch per peer '
580
+ + '(the same fetch discovery uses), reporting reachable peers with their team and latency and unreachable '
581
+ + 'ones with the failure reason. Pass url to probe one target instead of the whole fleet. '
582
+ + 'While teamScopeInfo is on (default), the sweep covers only peers serving a joined team, and a single URL is refused unless its verified card proves service. '
583
+ + 'Use it before dispatching verification work or when routes fail over, instead of guessing which node is down.',
584
+ parameters: {
585
+ url: { type: 'string', description: 'Optional single peer base URL to probe; empty probes every tracked peer.' },
586
+ },
587
+ output: {
588
+ // gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
589
+ presentationMeta: peerContentMeta('a2a_probe'),
590
+ schema: {
591
+ type: 'object',
592
+ additionalProperties: false,
593
+ properties: {
594
+ ok: { type: 'boolean', required: true },
595
+ results: {
596
+ type: 'array',
597
+ required: true,
598
+ items: {
599
+ type: 'object',
600
+ additionalProperties: false,
601
+ properties: {
602
+ url: { type: 'string', required: true },
603
+ reachable: { type: 'boolean', required: true },
604
+ ms: { type: 'number', description: 'Round-trip of the card fetch, reachable or not.' },
605
+ team: { type: 'string', description: 'The team the peer publishes, when reachable.' },
606
+ error: { type: 'string', description: 'Why the peer is unreachable.' },
607
+ },
608
+ },
609
+ },
610
+ },
611
+ },
612
+ render: (_args, value) => [{
613
+ type: 'text',
614
+ text: value.results.length === 0
615
+ ? 'No peers are tracked; add seeds or let referrals arrive.'
616
+ : [
617
+ `Fleet probe (${String(value.results.length)}): ${String(value.results.filter((row) => row.reachable).length)} reachable, ${String(value.results.filter((row) => !row.reachable).length)} down`,
618
+ ...value.results.map((row) => ` ${row.reachable ? '✓' : '✗'} ${row.url}${row.reachable ? ` (team ${String(row.team)}, ${String(row.ms)}ms)` : ` (${String(row.error)})`}`),
619
+ ].join('\n'),
620
+ }],
621
+ },
622
+ presentCall: args => ({ card: 'generic', title: args.url === undefined || args.url === '' ? 'Probe the A2A fleet' : `Probe A2A peer: ${args.url}`, kind: 'other', rawInput: args }),
623
+ execute: async (args, exec) => {
624
+ a2aJoinGateRefusal(exec);
625
+ const probeOne = async (url) => {
626
+ const startedAt = Date.now();
627
+ const outcome = await client.fetchCardDetail(url);
628
+ const ms = Date.now() - startedAt;
629
+ // The fetch is the probe: settling it moves the peer's quality score
630
+ // and learns its referrals, exactly like a discovery sweep would,
631
+ // while the outcome's stage names the failure for the report.
632
+ const card = settlePeerCard(url, outcome);
633
+ if (card !== undefined) {
634
+ // gh#71 fix ①: a successful full-face fetch persists into the warm
635
+ // cache — the probe is often the FIRST contact (no directory sweep
636
+ // has run), and the state face's peerOrg reads exactly this cache.
637
+ cardCache.set(url, { at: Date.now(), card });
638
+ return { url, reachable: true, ms, team: card.team };
639
+ }
640
+ if (outcome.ok)
641
+ return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
642
+ return outcome.stage === 'unreachable'
643
+ ? { url, reachable: false, ms, error: `unreachable: ${outcome.detail}` }
644
+ : { url, reachable: false, ms, error: `rejected: ${outcome.reason} card` };
645
+ };
646
+ // S5: a reachability report is fleet disclosure too. The sweep probes
647
+ // only peers whose verified card serves a joined team (memoized —
648
+ // warm cards cost nothing); a single-URL probe needs proof of service
649
+ // — a FRESH card, or a stale cached one: a peer that once served a
650
+ // joined team and is now down gets the normal down report (that
651
+ // reachability fact belongs to the team), while a URL that never
652
+ // proved service is refused without a probe (its reachability is
653
+ // exactly the fact scoping withholds).
654
+ const joined = joinedTeamScope();
655
+ const resolveLocal = localHandleResolver();
656
+ const fetch = memoizedCardFetch();
657
+ const serves = async (url) => {
658
+ const card = await fetch(url);
659
+ return card !== undefined && cardServesJoined(card, joined, resolveLocal);
660
+ };
661
+ if (args.url !== undefined && args.url !== '') {
662
+ if (config.teamScopeInfo && !(await serves(args.url))) {
663
+ const stale = cardCache.get(args.url)?.card;
664
+ if (stale !== undefined && cardServesJoined(stale, joined, resolveLocal)) {
665
+ // Was serving, now unverifiable fresh — probe and report the
666
+ // down state through the normal path.
667
+ return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
668
+ }
669
+ return { ok: true, results: [{ url: args.url, reachable: false, ms: 0, error: stale === undefined
670
+ ? 'refused (not probed): teamScopeInfo on — no verified card for this URL proves service to a joined team'
671
+ : 'refused (not probed): teamScopeInfo on — this URL\'s verified card shows no team joined by this host\'s nodes' }] };
672
+ }
673
+ return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
674
+ }
675
+ const targets = config.teamScopeInfo
676
+ ? await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => await serves(url) ? url : undefined).then(rows => rows.filter((url) => url !== undefined))
677
+ : peerStore.list();
678
+ // Promise.all keeps the report in the store's preference order.
679
+ return { ok: true, results: await Promise.all(targets.map(probeOne)) };
680
+ },
681
+ }));
682
+ }
683
+ return { registerWhoami, registerTeams, registerStatus, registerTasks, registerProbe };
684
+ }