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

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 (98) hide show
  1. package/README.md +11 -2
  2. package/README_zh.md +10 -1
  3. package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
  4. package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +450 -59
  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/canvas-store.js +6 -0
  11. package/lib/card.js +4 -2
  12. package/lib/cli-deposit.js +122 -7
  13. package/lib/client.js +64 -64
  14. package/lib/client.js.map +1 -1
  15. package/lib/faces/admin.js +176 -47
  16. package/lib/faces/canvas-notify.js +12 -3
  17. package/lib/faces/files.js +155 -0
  18. package/lib/faces/nodes.js +171 -0
  19. package/lib/faces/session-log.js +162 -0
  20. package/lib/faces/wire.js +212 -43
  21. package/lib/index.js +819 -1725
  22. package/lib/joined-store.js +23 -0
  23. package/lib/layout-store.js +3 -2
  24. package/lib/ledger-conservation.js +97 -0
  25. package/lib/legacy/teams-bridge-inbound.js +156 -0
  26. package/lib/node-bind.js +534 -0
  27. package/lib/node-github.js +72 -0
  28. package/lib/node-self.js +453 -0
  29. package/lib/node-store.js +484 -0
  30. package/lib/node-tasks.js +342 -0
  31. package/lib/origin-claim.js +246 -0
  32. package/lib/peer-directory.js +134 -0
  33. package/lib/receipt.js +26 -1
  34. package/lib/roster-op.js +104 -0
  35. package/lib/skill/SKILL.md +3 -1
  36. package/lib/skill/references/cli.md +83 -4
  37. package/lib/skill/references/collaboration.md +126 -74
  38. package/lib/skill/references/discovery.md +29 -0
  39. package/lib/skill/references/intake.md +107 -0
  40. package/lib/skill/references/router.md +96 -0
  41. package/lib/skill/references/setup.md +2 -1
  42. package/lib/task-auth.js +483 -0
  43. package/lib/task-ledger.js +324 -12
  44. package/lib/team-store.js +73 -2
  45. package/lib/tools/act.js +383 -0
  46. package/lib/tools/files.js +201 -0
  47. package/lib/tools/info.js +684 -0
  48. package/lib/types/a2a-client.d.ts +10 -4
  49. package/lib/types/auth-observer.d.ts +110 -0
  50. package/lib/types/blob-store.d.ts +83 -0
  51. package/lib/types/canvas-store.d.ts +4 -0
  52. package/lib/types/cli-deposit.d.ts +38 -0
  53. package/lib/types/faces/admin.d.ts +15 -2
  54. package/lib/types/faces/canvas-notify.d.ts +4 -1
  55. package/lib/types/faces/files.d.ts +53 -0
  56. package/lib/types/faces/nodes.d.ts +69 -0
  57. package/lib/types/faces/session-log.d.ts +44 -0
  58. package/lib/types/faces/wire.d.ts +51 -5
  59. package/lib/types/index.d.ts +27 -0
  60. package/lib/types/joined-store.d.ts +9 -0
  61. package/lib/types/ledger-conservation.d.ts +77 -0
  62. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  63. package/lib/types/node-bind.d.ts +220 -0
  64. package/lib/types/node-github.d.ts +35 -0
  65. package/lib/types/node-self.d.ts +178 -0
  66. package/lib/types/node-store.d.ts +180 -0
  67. package/lib/types/node-tasks.d.ts +121 -0
  68. package/lib/types/origin-claim.d.ts +119 -0
  69. package/lib/types/peer-directory.d.ts +39 -0
  70. package/lib/types/receipt.d.ts +12 -0
  71. package/lib/types/roster-op.d.ts +21 -0
  72. package/lib/types/task-auth.d.ts +271 -0
  73. package/lib/types/task-ledger.d.ts +143 -6
  74. package/lib/types/team-store.d.ts +16 -0
  75. package/lib/types/tools/act.d.ts +52 -0
  76. package/lib/types/tools/files.d.ts +38 -0
  77. package/lib/types/tools/info.d.ts +102 -0
  78. package/lib/types/types.d.ts +6 -0
  79. package/nexus-stage/src/canvas-ops.ts +3 -2
  80. package/nexus-stage/src/federation.ts +1 -0
  81. package/nexus-stage/src/layout-doc.ts +6 -4
  82. package/nexus-stage/src/main.ts +68 -20
  83. package/nexus-stage/src/planning-frame-hue.ts +13 -0
  84. package/nexus-stage/src/planning-legend.ts +1 -0
  85. package/nexus-stage/src/planning-member-handles.ts +34 -0
  86. package/nexus-stage/src/planning-menus.ts +28 -12
  87. package/nexus-stage/src/planning-mirror.ts +47 -3
  88. package/nexus-stage/src/planning-reachability.ts +28 -0
  89. package/nexus-stage/src/planning-roster.ts +15 -0
  90. package/nexus-stage/src/planning-types.ts +2 -2
  91. package/nexus-stage/src/planning-view.ts +57 -52
  92. package/nexus-stage/src/planning.css +6 -0
  93. package/nexus-stage/src/wb-directory.ts +13 -0
  94. package/nexus-stage/src/workbench.css +3 -0
  95. package/nexus-stage/src/workbench.ts +7 -1
  96. package/package.json +3 -2
  97. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  98. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * inbound direct routes into live agent sessions.
6
6
  * @module @nelsonlongxiang/dsh-open-a2a-net
7
7
  */
8
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, timingSafeEqual, verify as edVerify } from 'node:crypto';
8
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
10
  import { networkInterfaces } from 'node:os';
11
11
  import { dirname, join } from 'node:path';
@@ -28,30 +28,46 @@ export function resolveCallerLabel(input) {
28
28
  }
29
29
  import { signCard, verifyCard } from "./card.js";
30
30
  import { GroupStore } from "./group-store.js";
31
- import { CanvasStore, isRemoteMemberRef } from "./canvas-store.js";
32
- import { decodeSessionLog, extractTranscript, TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_FILE_BYTES, TRANSCRIPT_MAX_LIMIT } from "./session-transcript.js";
31
+ import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
32
+ import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
33
33
  import { LayoutStore } from "./layout-store.js";
34
34
  import { JoinedSessions } from "./joined-store.js";
35
35
  import { ExposureStore } from "./exposure-store.js";
36
- import { CliDepositStore } from "./cli-deposit.js";
36
+ import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
37
37
  import { TeamMembershipStore } from "./team-store.js";
38
38
  import { PeerStore } from "./peer-store.js";
39
39
  import { SelfReferralFilter } from "./self-suppress.js";
40
40
  import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
41
- import { formatReceipt, parseReceipt } from "./receipt.js";
41
+ import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
42
42
  import { runReceiptLadder } from "./receipt-ladder.js";
43
43
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
44
44
  import { createFloodGuard } from "./flood-guard.js";
45
45
  import { resolveZone } from "./zone.js";
46
46
  import { registerA2aNetworkSkill } from "./skill-registration.js";
47
+ import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
48
+ import { AuthObserver } from "./auth-observer.js";
49
+ import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission } from "./task-auth.js";
47
50
  import s from '@deepseek-ai/schemastery';
48
- import { defineTool } from '@deepseek-ai/dsh-tools';
49
51
  import { MessageId } from '@deepseek-ai/dsh-llm';
50
52
  import { SessionId } from '@deepseek-ai/dsh-session';
51
53
  import { A2aClient } from "./a2a-client.js";
52
54
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
53
55
  import { registerAdminFaces } from "./faces/admin.js";
56
+ import { registerNodeFaces } from "./faces/nodes.js";
57
+ import { NodeStore } from "./node-store.js";
58
+ import { createNodeBinder, NodeBindings } from "./node-bind.js";
59
+ import { NodeTasks, settledReminder } from "./node-tasks.js";
60
+ import { githubProbe } from "./node-github.js";
61
+ import { NodeReviewQueue, SessionTaint, localRoute, toolTaint } from "./node-self.js";
54
62
  import { registerWireFaces } from "./faces/wire.js";
63
+ import { createInfoTools } from "./tools/info.js";
64
+ import { registerSessionLogFaces } from "./faces/session-log.js";
65
+ import { registerFileFaces } from "./faces/files.js";
66
+ import { createFileTools } from "./tools/files.js";
67
+ import { BlobStore } from "./blob-store.js";
68
+ import { canvasCandidateMatches, canvasTargetAmbiguous, peerDirectoryRows, routableCallerLabel, sessionCandidateMatches, unroutableCallerNote } from "./peer-directory.js";
69
+ import { createTeamsBridgeInbound } from "./legacy/teams-bridge-inbound.js";
70
+ import { createActTools } from "./tools/act.js";
55
71
  import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
56
72
  export { A2aClient } from "./a2a-client.js";
57
73
  export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
@@ -64,7 +80,9 @@ export const Config = s.object({
64
80
  team: s.string().default('dsh'),
65
81
  /** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
66
82
  gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
83
+ settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
67
84
  announce: s.boolean().default(false),
85
+ isolated: s.boolean().default(false),
68
86
  agentName: s.string().default('DeepSeek Harness A2A node'),
69
87
  peers: s.array(s.string()).default([]),
70
88
  delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
@@ -145,6 +163,14 @@ export const Config = s.object({
145
163
  * failed signatures. See docs/protocol/delivery-origin-auth.md §4.
146
164
  */
147
165
  originClaimWindow: s.boolean().default(true),
166
+ /**
167
+ * OriginClaim unbound window: also sign/accept the legacy envelope that
168
+ * names no audience (counted as unboundInbound); closing it leaves the
169
+ * audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
170
+ */
171
+ originClaimUnboundWindow: s.boolean().default(true),
172
+ /** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
173
+ originClaimAudiences: s.array(s.string()).default([]),
148
174
  /** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
149
175
  originClaimUrl: s.string().default(''),
150
176
  /**
@@ -164,17 +190,6 @@ export const Config = s.object({
164
190
  nativeTeamsInbound: s.boolean().default(false),
165
191
  nativeRoundWaitMs: s.number().default(180_000),
166
192
  });
167
- /** Model-facing text for one route result. */
168
- function renderRoute(_args, value) {
169
- const result = value;
170
- const text = result.ok
171
- ? [
172
- `Team ${result.team} replied (task ${result.task_id}, status ${result.task_status}, context ${result.context_id}):`,
173
- result.reply,
174
- ].join('\n')
175
- : `A2A route failed${result.code === undefined ? '' : ` (code ${String(result.code)})`}: ${result.error}`;
176
- return [{ type: 'text', text }];
177
- }
178
193
  /**
179
194
  * Register the outbound peer tools, the direct-route endpoint, and (when
180
195
  * composed) the announced card and session-node control surface.
@@ -288,7 +303,9 @@ export function apply(ctx, config) {
288
303
  return () => { };
289
304
  }
290
305
  };
291
- const fetchImpl = (url, init) => globalThis.fetch(url, init);
306
+ // gh#193: the one door to peers — while isolated nothing leaves this host.
307
+ const peerFetch = (url, init) => config.isolated ? Promise.reject(new Error('isolated: outbound peer traffic is off on this host')) : globalThis.fetch(url, init);
308
+ const fetchImpl = (url, init) => peerFetch(url, init);
292
309
  /** Constant-time secret comparison: hash both sides to equal-length digests. */
293
310
  function secretEqual(a, b) {
294
311
  const digestA = createHash('sha256').update(a).digest();
@@ -426,6 +443,8 @@ export function apply(ctx, config) {
426
443
  let body;
427
444
  try {
428
445
  body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
446
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
447
+ throw new TypeError('control body must be a JSON object'); // `null` crashed every route reading body.id
429
448
  }
430
449
  catch {
431
450
  const payload = JSON.stringify({ error: 'malformed body' });
@@ -446,7 +465,18 @@ export function apply(ctx, config) {
446
465
  res.end(payload);
447
466
  return;
448
467
  }
449
- use(body);
468
+ try {
469
+ use(body);
470
+ }
471
+ catch (error) {
472
+ logger.warn('control route failed: %s', String(error));
473
+ if (!res.headersSent) {
474
+ res.writeHead(500, { 'Content-Type': 'application/json' });
475
+ res.end('{"error":"internal error"}');
476
+ }
477
+ else if (!res.writableEnded)
478
+ res.end();
479
+ } // runs in the reader's 'end' callback, outside the web server's guard: a throw here would exit the host
450
480
  });
451
481
  }
452
482
  /**
@@ -482,6 +512,8 @@ export function apply(ctx, config) {
482
512
  // the same home as the node key.
483
513
  const home = config.dshHome === '' ? resolveDshHome() : resolveDshHome(config.dshHome);
484
514
  const peerStore = new PeerStore(config.peers, join(home, 'a2a', 'peers.json'));
515
+ // Node identity (docs/design/node-identity.md, N1): human-named nodes and their prompt versions.
516
+ const nodeStore = new NodeStore(join(home, 'a2a'));
485
517
  // Peer boundary model (docs/design/peer-boundary-model.md) — connection
486
518
  // layer. referralLearning off freezes the seed set: referrals are neither
487
519
  // learned nor offered. A non-empty peerAllowlist admits a referral only
@@ -524,32 +556,23 @@ export function apply(ctx, config) {
524
556
  /** Our own card URL once the web server settles — the OriginClaim anchor. */
525
557
  let ownCardUrl = '';
526
558
  /**
527
- * The OriginClaim envelope headers (gh#9+gh#10): canonical form
528
- * JSON.stringify([caller, callerUrl, ts]) signed with the node card key.
529
- * Shared by the a2a client (every peer request) and verifyOriginClaim's
530
- * own verification fetch (REVIEW M2: the cold-cache fetch must carry the
531
- * envelope so a zone/id8 caller derives from the FULL card — a bare-zone
532
- * caller derives from the minimal card's team either way).
559
+ * The OriginClaim envelope headers (gh#9+gh#10, src/origin-claim.ts): the
560
+ * bound form names the target's origin, method and path; the unbound
561
+ * legacy form rides along only while originClaimUnboundWindow is open.
562
+ * Signed with the node card key; shared by the a2a client (every peer
563
+ * request) and the relay/notify faces.
533
564
  */
534
- function originClaimHeaders() {
535
- const ts = Date.now();
536
- const signature = edSign(undefined, Buffer.from(JSON.stringify([config.team, ownCardUrl, ts]), 'utf8'), nodePrivateKey).toString('base64');
537
- return {
538
- 'X-A2A-Caller': config.team,
539
- 'X-A2A-Caller-Url': ownCardUrl,
540
- 'X-A2A-Caller-Ts': String(ts),
541
- 'X-A2A-Caller-Sig': signature,
542
- };
565
+ function originClaimHeaders(target) {
566
+ return signOriginClaim(target, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
543
567
  }
544
568
  const client = new A2aClient({
545
569
  apiKey: config.apiKey,
546
570
  sessionId: session,
547
571
  cardCaller: config.team,
548
572
  // gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md): every
549
- // peer request carries a verifiable origin — canonical form is the
550
- // ruled JSON.stringify([caller, callerUrl, ts]), signed with the node
551
- // card key (single identity anchor). Until the server settles the URL
552
- // is unknown; the signer returns plain naming for that brief window.
573
+ // peer request carries a verifiable origin bound to its target, signed
574
+ // with the node card key (single identity anchor). Until the server
575
+ // settles the URL is unknown; receivers read that as an absent claim.
553
576
  originClaim: originClaimHeaders,
554
577
  schedule,
555
578
  fetch: fetchImpl,
@@ -637,6 +660,8 @@ export function apply(ctx, config) {
637
660
  // may sit in many teams; routing to <team>/canvas/<name> resolves the
638
661
  // first live member or wakes the first cold one (member order = priority).
639
662
  const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'));
663
+ // #91 file channel: the sender host's blob store (docs/design/file-channel.md).
664
+ const blobStore = new BlobStore(join(home, 'a2a'));
640
665
  // Spatial presentation state for the full-page stage (contract v2).
641
666
  const layoutStore = new LayoutStore(join(home, 'a2a', 'canvas-layout.json'));
642
667
  // The async-task ledger: every route that leaves a task owed a receipt is
@@ -650,13 +675,34 @@ export function apply(ctx, config) {
650
675
  // here. It grants no presence: reports land in a bounded ring the state
651
676
  // route serves, never in any routable surface.
652
677
  const reportRing = [];
653
- const reportLastAt = new Map();
654
678
  // Quick-dispatch face (0.6.9): the workbench POSTs /__dsh_a2a/dispatch as a
655
679
  // human gesture; the window keeps a runaway page (or a stuck autorefresh)
656
680
  // from flooding the mesh. Keyed by client address, mirror of reportLastAt.
657
681
  const dispatchLastAt = new Map();
658
- /** Fix ④ loop circuit-breaker: session+text-hash → accepted-report timestamps (10min window). */
659
- const reportLoopRing = new Map();
682
+ /**
683
+ * #114 S4: the judge of one settler against each correlated row's
684
+ * authorization chain (design §4.2). `observe` records what enforce would
685
+ * refuse and settles as before; `enforce` keeps a refused row owed. No
686
+ * settler (a path that knows none), or settlementAuth off, judges nothing.
687
+ */
688
+ function settlementJudgeFor(settler, parsedForJudge, alwaysEnforce = false) {
689
+ return settler === undefined || (config.settlementAuth === 'off' && !alwaysEnforce) ? undefined : {
690
+ enforce: alwaysEnforce || config.settlementAuth === 'enforce',
691
+ settler: { caller: settler.caller, proof: settler.proof, ingress: settler.ingress },
692
+ check: (row, echo) => judgeSettlement(row, settler, {
693
+ hostLabel: session,
694
+ membersOf: (team) => {
695
+ const name = parseCanvasTeamName(team);
696
+ if (name === undefined)
697
+ return undefined;
698
+ // Canvas members by session id and by handle, as the faces store them.
699
+ return canvasStore.membersOf(name).flatMap(m => (m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`]));
700
+ },
701
+ ...(parsedForJudge?.envelope?.onBehalfOf !== undefined ? { onBehalfOf: parsedForJudge.envelope.onBehalfOf } : {}),
702
+ echo,
703
+ }),
704
+ };
705
+ }
660
706
  /**
661
707
  * Settle one message through the ledger and announce what it correlated
662
708
  * on `a2a/receipt-resolved` — the seam peer plugins (native-teams)
@@ -667,7 +713,9 @@ export function apply(ctx, config) {
667
713
  * the consumer's own discipline — keep seam listeners synchronous.
668
714
  * @param message - the inbound or relayed message text.
669
715
  */
670
- function settleAndAnnounce(message) {
716
+ function settleAndAnnounce(message, settler) {
717
+ const parsedForJudge = parseReceipt(message);
718
+ const judge = settlementJudgeFor(settler, parsedForJudge);
671
719
  // W7 slice-2 hook 3: a receipt that correlates an id THIS node's
672
720
  // idempotency ledger claimed records the receipt's human line as that
673
721
  // key's outcome. Parsed DIRECTLY off the receipt codec — a claimed key
@@ -677,24 +725,41 @@ export function apply(ctx, config) {
677
725
  // the envelope's controlled-vocabulary outcome is never a product and
678
726
  // must not be recorded as one. First-write wins inside the store, so a
679
727
  // late receipt never overwrites a precise sync-hook record.
680
- const parsed = parseReceipt(message);
728
+ const parsed = parsedForJudge;
681
729
  if (parsed !== null && parsed.taskId !== '') {
682
730
  const line = parsed.summary.trim();
683
- if (line !== '') {
731
+ // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
732
+ // outcome a /a2a/query answers only for a row THIS ledger owes and
733
+ // whose settler is on the chain — anyone sending a receipt line could
734
+ // otherwise rewrite another caller's answer.
735
+ const row = judge?.enforce === true ? taskLedger.find(parsed.taskId) : undefined;
736
+ const allowed = judge?.enforce !== true || (row !== undefined && judge.check(row, false).ok);
737
+ if (line !== '' && allowed) {
684
738
  idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
685
739
  }
686
740
  }
687
- const resolved = taskLedger.resolveFromMessage(message);
741
+ // A header that names an owed row and comes back unresolved was refused
742
+ // (enforce): that is the verdict — the echo fallback must not judge the
743
+ // same message a second time.
744
+ const owedRow = parsed !== null && parsed.taskId !== '' ? taskLedger.find(parsed.taskId) : undefined;
745
+ const owedByHeader = owedRow !== undefined;
746
+ // #139 (design §4.4): an enforce refusal of the row the header names is
747
+ // told to the sender on the wire response, not only in /state.
748
+ const headerVerdict = judge?.enforce === true && owedRow !== undefined ? judge.check(owedRow, false) : undefined;
749
+ const resolved = taskLedger.resolveFromMessage(message, judge);
750
+ if (resolved === undefined && owedByHeader) {
751
+ return headerVerdict !== undefined && !headerVerdict.ok ? { status: 'refused', reason: headerVerdict.reason, task_id: parsed.taskId } : undefined;
752
+ }
688
753
  if (resolved === undefined) {
689
754
  // F5 (settlement integrity): the formal envelope missed — an inbound
690
755
  // message that still echoes a pending task id means the peer
691
756
  // demonstrably responded to it, which settles the debt even when the
692
757
  // receipt formality never rode the reply.
693
- const echoed = taskLedger.resolveEcho(message);
758
+ const echoed = taskLedger.resolveEcho(message, judge);
694
759
  if (echoed.length > 0) {
695
760
  logger.info(`a2a: ${String(echoed.length)} pending task(s) settled by content echo (${echoed.join(', ')})`);
696
761
  }
697
- return;
762
+ return undefined;
698
763
  }
699
764
  try {
700
765
  ctx.emit('a2a/receipt-resolved', resolved);
@@ -702,6 +767,32 @@ export function apply(ctx, config) {
702
767
  catch (error) {
703
768
  logger.warn(`a2a: receipt-resolved listener rejected: ${String(error)}`);
704
769
  }
770
+ return undefined;
771
+ }
772
+ /**
773
+ * gh#141: a receipt this node sent across hosts pays its own debt — the
774
+ * inbound row the answered delivery was born with (F2'). The same-host
775
+ * path settles in settleAndAnnounce, as the receipt lands in-process; a
776
+ * receipt that leaves over the wire never passed this ledger, so the row
777
+ * stayed owed (and kept the async nudge armed) until its dead TTL. Only
778
+ * an inbound row whose receipt target is the team the receipt reached
779
+ * settles, and only for an on-chain payer, whatever settlementAuth says:
780
+ * this path is new, so "observe settles as before" has no before to keep
781
+ * (review of PR #149 P2-1) — another session on this host never pays a
782
+ * row it was not delivered. A settled row records the receipt's line as
783
+ * its claimed key's outcome, as the in-process path does (hook 3).
784
+ * @param message - the receipt text as delivered.
785
+ * @param creditor - the team the receipt was delivered to.
786
+ * @param payer - who sent it on this host.
787
+ */
788
+ function settleOwnInbound(message, creditor, payer) {
789
+ const parsed = parseReceipt(message);
790
+ if (parsed === null || parsed.taskId === '')
791
+ return;
792
+ const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
793
+ const line = parsed.summary.trim();
794
+ if (settled !== undefined && line !== '')
795
+ idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
705
796
  }
706
797
  // Server-side idempotency keys (work-order P3): a caller-born task id
707
798
  // executes at most once inside the 24h window — same-key replays answer
@@ -721,6 +812,37 @@ export function apply(ctx, config) {
721
812
  // Live top-level agents: the join surface's candidates and the cold-row
722
813
  // complement (apply scope — the direct-route wake below reads it too).
723
814
  const liveRoots = new Map();
815
+ // Node identity N2+N3: a joined session that carries a node gets that node's layers on its own scope.
816
+ const nodeBindings = new NodeBindings(join(home, 'a2a'));
817
+ const nodeReviews = new NodeReviewQueue(join(home, 'a2a'));
818
+ const nodeTaint = new SessionTaint(join(home, 'a2a'));
819
+ // N4: a SUBAGENT's mark climbs to every ancestor it reports to (session.header: origin 'subagent' + parentSession). A
820
+ // fork is not climbed: its source never sees the fork's text, and the fork's inherited log carries its own evidence.
821
+ const markLineage = (agent, via) => {
822
+ const seen = new Set();
823
+ for (let a = agent; a !== undefined && !seen.has(String(a.id));) {
824
+ seen.add(String(a.id));
825
+ nodeTaint.mark(String(a.id), via);
826
+ const header = a.session?.header;
827
+ if (header?.origin !== 'subagent' || typeof header.parentSession !== 'string' || header.parentSession === '')
828
+ return;
829
+ const next = ctx.get('agents')?.get(header.parentSession);
830
+ if (next === undefined) {
831
+ nodeTaint.mark(header.parentSession, via);
832
+ return;
833
+ }
834
+ a = next;
835
+ }
836
+ };
837
+ ctx.on('tools/post-execute', (exec, result, next) => { const via = result.isError ? (/^a2a_(route|tasks|teams|status|probe|file_send|file_fetch)$/.test(exec.name) ? `${exec.name}:error` : '') : toolTaint(exec.name, result.value); if (via !== '')
838
+ markLineage(exec.agent, via); return next(); });
839
+ const nodeTasks = new NodeTasks(join(home, 'a2a'), { probe: githubProbe(), logger, onSettled: (node, p) => nodeBinder.remind(node, settledReminder(p)) });
840
+ nodeTasks.schedule(ctx);
841
+ const nodeBinder = createNodeBinder({ store: nodeStore, bindings: nodeBindings, logger, joined: id => joinedSessions.has(id), joinedList: () => joinedSessions.list(), archived: id => archivedSessionFilter()?.(id) === true, live: id => liveRoots.get(id), tasksText: node => nodeTasks.render(node), pendingReminders: node => nodeTasks.takePending(node).map(settledReminder), selfUpdate: { store: nodeStore, queue: nodeReviews, taint: nodeTaint, events: agent => sessionEventsOf(agent), localSession: id => liveRoots.has(id) || joinedSessions.has(id) } });
842
+ // No boot reconcile: a joined.json that failed to parse reads as empty, and ending every binding on that
843
+ // reading would destroy them (the 3081 lesson). A stale row is harmless — mount checks joined itself.
844
+ ctx.effect(() => joinedSessions.onChange(() => { nodeBinder.reconcile(); }), 'a2a: node bindings follow join');
845
+ ctx.effect(() => () => { nodeBinder.disposeAll(); }, 'a2a: node mounts');
724
846
  // Last foreground wake/route demand (epoch ms): boot prewarm steps yield
725
847
  // while demand keeps arriving inside the quiet window. Panel polls never
726
848
  // touch this — they are constant and would starve warming forever.
@@ -841,7 +963,10 @@ export function apply(ctx, config) {
841
963
  for (const id of joinedSessions.list()) {
842
964
  if (!isArchived(id))
843
965
  continue;
966
+ const ownHandle = ownCanvasHandleOf(id, true);
844
967
  sessionNodes.delete(id);
968
+ // Sections come back at the next mount (re-publish) if this archived reading was transient; the row stays.
969
+ nodeBinder.suspend(id);
845
970
  // The archived session's JOIN INTENT is retained: deleting it was the
846
971
  // 3081 wake-defect root cause (a transient archived-window reading
847
972
  // wiped intents at boot, and cold sessions then never woke — "no cold
@@ -849,6 +974,8 @@ export function apply(ctx, config) {
849
974
  // archived intents (:804), card assembly and the state cold rows filter
850
975
  // them, the reconciler skips them, and JOIN_CAP bounds the file.
851
976
  canvasStore.dropMember(id);
977
+ if (ownHandle !== undefined)
978
+ canvasStore.dropMember(ownHandle);
852
979
  // S2 consistency: an archived node's roster declarations die with it
853
980
  // — a left node keeps no team membership.
854
981
  teamMemberships.dropSession(id);
@@ -900,6 +1027,87 @@ export function apply(ctx, config) {
900
1027
  return `import-${imported[1].slice(0, 8)}`;
901
1028
  return bare.slice(0, 8);
902
1029
  }
1030
+ /**
1031
+ * gh#131: the local session one of this host's own handles names — the
1032
+ * `<zone>/<id8>` of a joined or live, unarchived session here — when that
1033
+ * is the only reading. An id8 shared by two local sessions names neither,
1034
+ * and a handle a configured peer's card also publishes (an id8 collision
1035
+ * across hosts: the operator may mean the peer's session) stays remote.
1036
+ * Only the peers the directory resolves through count (seeded and
1037
+ * delegates, review of PR #151 P3-1): a host learned by referral cannot
1038
+ * veto the local reading with an unsigned sessionTeams row.
1039
+ * @param ref - a remote-shaped member ref.
1040
+ * @returns the local session id, or undefined.
1041
+ */
1042
+ function localSessionOfHandle(ref) {
1043
+ return localHandleResolver()(ref);
1044
+ }
1045
+ /**
1046
+ * One snapshot of {@link localSessionOfHandle} for many lookups: the
1047
+ * archived filter, the local id8 index and the seeded peers' cards are
1048
+ * read once (review of PR #151 P3-e).
1049
+ * @param includeArchived - count archived sessions too (the archive prune
1050
+ * drops an archived session's own handle).
1051
+ */
1052
+ function localHandleResolver(includeArchived = false) {
1053
+ const isArchived = includeArchived ? undefined : archivedSessionFilter();
1054
+ const byHandle = new Map();
1055
+ for (const id of new Set([...joinedSessions.list(), ...sessionNodes.keys()])) {
1056
+ if (isArchived?.(id) === true)
1057
+ continue;
1058
+ const handle = `${config.team}/${id8(id)}`;
1059
+ byHandle.set(handle, [...(byHandle.get(handle) ?? []), id]);
1060
+ }
1061
+ const published = new Set();
1062
+ for (const url of new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])) {
1063
+ const card = cardCache.get(url)?.card;
1064
+ if (card !== undefined && card.session !== session)
1065
+ for (const row of card.sessionTeams ?? [])
1066
+ published.add(row.team);
1067
+ }
1068
+ return (ref) => {
1069
+ const hits = byHandle.get(ref);
1070
+ return hits?.length === 1 && !published.has(ref) ? hits[0] : undefined;
1071
+ };
1072
+ }
1073
+ /**
1074
+ * gh#131: the handle a canvas may hold for one local session — its own
1075
+ * `<zone>/<id8>` when that handle reads as this session — so leave,
1076
+ * archive and removal drop it with the id (review of PR #151 P2: a
1077
+ * handle-stored membership must not outlive the session's consent).
1078
+ * @param id - the local session id (still joined when this is read).
1079
+ */
1080
+ function ownCanvasHandleOf(id, includeArchived = false) {
1081
+ const handle = `${config.team}/${id8(id)}`;
1082
+ return localHandleResolver(includeArchived)(handle) === id ? handle : undefined;
1083
+ }
1084
+ /**
1085
+ * gh#152: a local session's canvas teams as every READ face sees them — the
1086
+ * stored member id, or one of this host's own handles that reads as this
1087
+ * session (a fleet edit stored it by handle; routing has read that shape
1088
+ * through canvasLocalMembersOf since gh#131). Visibility now matches
1089
+ * routability: whoami, the scoped teams listing and the state face show the
1090
+ * canvas a handle-stored membership puts the session in.
1091
+ */
1092
+ function canvasTeamsOfSession(id) {
1093
+ const teams = new Set(canvasStore.teamsOf(id));
1094
+ const own = ownCanvasHandleOf(id);
1095
+ if (own !== undefined)
1096
+ for (const name of canvasStore.teamsOf(own))
1097
+ teams.add(name);
1098
+ return [...teams];
1099
+ }
1100
+ /**
1101
+ * gh#131: a canvas team's members as the local routing tiers read them —
1102
+ * one of this host's own handles stands for the session it names (a fleet
1103
+ * edit stored it by handle). Read at route time, never written back: a
1104
+ * reading that depends on which peer cards are cached must not rewrite
1105
+ * the store (review of PR #151 P2-2).
1106
+ */
1107
+ function canvasLocalMembersOf(name) {
1108
+ const resolve = localHandleResolver();
1109
+ return canvasStore.membersOf(name).map(id => (isRemoteMemberRef(id) ? resolve(id) ?? id : id));
1110
+ }
903
1111
  /** The 8-char suffix of one agent's id. */
904
1112
  function agentId8(agent) {
905
1113
  return id8(String(agent.id));
@@ -948,7 +1156,7 @@ export function apply(ctx, config) {
948
1156
  function canvasLiveAgent(name, skipId) {
949
1157
  if (name === undefined)
950
1158
  return undefined;
951
- for (const id of canvasStore.membersOf(name)) {
1159
+ for (const id of canvasLocalMembersOf(name)) {
952
1160
  if (id === skipId)
953
1161
  continue;
954
1162
  const agent = sessionNodes.get(id);
@@ -968,7 +1176,7 @@ export function apply(ctx, config) {
968
1176
  if (name === undefined)
969
1177
  return undefined;
970
1178
  const isArchived = archivedSessionFilter();
971
- return canvasStore.membersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
1179
+ return canvasLocalMembersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
972
1180
  }
973
1181
  /**
974
1182
  * Human-facing node facts for one session: the session title (fallback:
@@ -1124,10 +1332,12 @@ export function apply(ctx, config) {
1124
1332
  liveRoots.set(String(agent.id), agent);
1125
1333
  if (joinedSessions.has(String(agent.id)))
1126
1334
  mountSessionNode(agent);
1335
+ nodeBinder.mount(agent);
1127
1336
  return undefined;
1128
1337
  });
1129
1338
  ctx.on('agent/disposed', ({ agent }) => {
1130
1339
  liveRoots.delete(String(agent.id));
1340
+ nodeBinder.forget(String(agent.id));
1131
1341
  // Disposal unmounts the runtime node; the persisted intent stays for
1132
1342
  // the remount above (leave is the only intent remover).
1133
1343
  sessionNodes.delete(String(agent.id));
@@ -1136,6 +1346,7 @@ export function apply(ctx, config) {
1136
1346
  liveRoots.set(String(agent.id), agent);
1137
1347
  if (joinedSessions.has(String(agent.id)))
1138
1348
  mountSessionNode(agent);
1349
+ nodeBinder.mount(agent, { atFrozen: true });
1139
1350
  }
1140
1351
  /** F-observability: the boot prewarm's lifecycle, surfaced on the state route (F: prewarm not running was invisible). */
1141
1352
  const prewarmStatus = { state: 'off', attempted: 0, woken: 0, failed: [] };
@@ -1417,7 +1628,7 @@ export function apply(ctx, config) {
1417
1628
  at: now,
1418
1629
  rows: all
1419
1630
  .filter(row => row.local !== true)
1420
- .map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1631
+ .map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1421
1632
  };
1422
1633
  })
1423
1634
  .catch(() => { })
@@ -1428,11 +1639,13 @@ export function apply(ctx, config) {
1428
1639
  whenWebServerSettled((webServer) => {
1429
1640
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1430
1641
  registerAdminFaces(webServer, {
1431
- ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch,
1642
+ ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1432
1643
  indexUrl: import.meta.url,
1433
1644
  get cardCache() { return cardCache; },
1434
1645
  get remoteRowsCache() { return remoteRowsCache; },
1435
1646
  get legacyInboundCount() { return legacyInboundCount; },
1647
+ get unboundInboundCount() { return unboundInboundCount; },
1648
+ get boundMissInboundCount() { return boundMissInboundCount; },
1436
1649
  get ownCardUrl() { return ownCardUrl; },
1437
1650
  get ownerAllowlist() { return ownerAllowlist; },
1438
1651
  set ownerAllowlist(v) { ownerAllowlist = v; },
@@ -1441,6 +1654,7 @@ export function apply(ctx, config) {
1441
1654
  get ownerAnnounceRestartPending() { return ownerAnnounceRestartPending; },
1442
1655
  set ownerAnnounceRestartPending(v) { ownerAnnounceRestartPending = v; },
1443
1656
  });
1657
+ registerNodeFaces(webServer, { ctx, logger, nodeStore, bindings: nodeBindings, binder: nodeBinder, tasks: nodeTasks, reviews: nodeReviews, apiKey: () => config.apiKey, secretEqual, controlRoute, readJsonBody });
1444
1658
  });
1445
1659
  }
1446
1660
  if (config.announce) {
@@ -1573,7 +1787,10 @@ export function apply(ctx, config) {
1573
1787
  // zone/id8 handles (local session ids translate to the node's
1574
1788
  // session-team handle) — one form on the wire, so readers never
1575
1789
  // mix id/handle shapes (owner: 数据对不上).
1576
- ...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name).map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id) })) } : {}),
1790
+ // gh#152: members are read through the local tiers' lens — one of
1791
+ // this host's own handles resolves to the session it names, and
1792
+ // the two stored shapes of one session publish as ONE handle.
1793
+ ...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: [...new Set(canvasLocalMembersOf(name))].map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id) })) } : {}),
1577
1794
  ...(named && lanIp !== '' ? { lanIp } : {}),
1578
1795
  ...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
1579
1796
  description: `A2A node exposing team ${config.team}${named ? '' : ' (minimal card — OriginClaim envelope required for the full card: X-A2A-Caller/-Url/-Ts/-Sig signed with your card key)'}`,
@@ -1625,12 +1842,15 @@ export function apply(ctx, config) {
1625
1842
  * so ui-chat renders the relay body ("来自 X" + full text) instead of an
1626
1843
  * opaque row; omitted when the sender is not meaningfully addressable.
1627
1844
  */
1628
- function steerRelay(agent, text, sender) {
1845
+ function steerRelay(agent, text, sender, localFrom) {
1846
+ // N4/gh#198: a relay steer carries content from outside this host — unless it is an in-process delivery from a clean local session (localFrom), whose taint the target then takes.
1847
+ if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
1848
+ markLineage(agent, 'relay');
1629
1849
  agent.steer({
1630
1850
  id: MessageId(`a2a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`),
1631
1851
  role: 'user',
1632
1852
  content: [{ type: 'text', text }],
1633
- source: { kind: 'a2a-relay', form: 'relay', ...(sender === undefined ? {} : { senderSessionId: sender }) },
1853
+ source: { kind: 'a2a-relay', form: 'relay', ...(sender === undefined ? {} : { senderSessionId: sender }), ...(localFrom === undefined ? {} : { localSender: localFrom }) },
1634
1854
  });
1635
1855
  }
1636
1856
  /** The cooperative stop-notice text, shared by the tasks/cancel control
@@ -1697,19 +1917,20 @@ export function apply(ctx, config) {
1697
1917
  * visible for /a2a/query follow-ups.
1698
1918
  */
1699
1919
  const nudgeBudget = new Map();
1920
+ const nudgeRef = (taskId) => createHash('sha256').update(taskId, 'utf8').digest('hex').slice(0, 16);
1700
1921
  const escalateQueuePrompt = (agent, team, taskId) => {
1701
- // Queue-mode prompt seam: the session controller owns prompt admission
1702
- // (successor of the removed api gateway's prompt face).
1922
+ // Queue-mode prompt seam (the session controller owns prompt admission); #183: it stamps kind:'user', so nudgeRef stands in for the caller's task id.
1703
1923
  const controller = ctx.get('sessionController');
1704
1924
  if (typeof controller?.prompt !== 'function')
1705
1925
  return;
1706
- logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (task ${taskId})`);
1926
+ markLineage(agent, 'nudge');
1927
+ logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (task ${taskId}, ref ${nudgeRef(taskId)})`);
1707
1928
  try {
1708
1929
  void controller.prompt({
1709
- requestId: `a2a-nudge-${taskId}`,
1930
+ requestId: `a2a-nudge-${nudgeRef(taskId)}`,
1710
1931
  sessionId: agent.id,
1711
1932
  mode: 'queue',
1712
- content: [{ type: 'text', text: `[A2A nudge] (task ${taskId}) your earlier routed message was delivered while this session could not start a turn — please consume the inbox backlog now (ignore if there is nothing pending).` }],
1933
+ content: [{ type: 'text', text: `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now (ignore if there is nothing pending).` }],
1713
1934
  }, new AbortController().signal).catch(() => { });
1714
1935
  }
1715
1936
  catch {
@@ -1749,8 +1970,8 @@ export function apply(ctx, config) {
1749
1970
  // the current live node, or the wake path materializes the session
1750
1971
  // afresh.
1751
1972
  const fresh = resolveAgentForTeam(team) ?? agent;
1752
- logger.info(`a2a: async nudge re-waking ${team} (task ${taskId} delivered but not consumed)`);
1753
- steerRelay(fresh, `[A2A nudge] (task ${taskId}) your earlier routed message was delivered while this session could not start a turn — please consume the inbox backlog now.`);
1973
+ logger.info(`a2a: async nudge re-waking ${team} (task ${taskId}, ref ${nudgeRef(taskId)}, delivered but not consumed)`);
1974
+ steerRelay(fresh, `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now.`);
1754
1975
  }, delayMs);
1755
1976
  ctx.effect(() => () => clearTimeout(timer), `a2a: async nudge ${taskId}`);
1756
1977
  }
@@ -1767,7 +1988,7 @@ export function apply(ctx, config) {
1767
1988
  * receipts themselves are never tracked as new debts.
1768
1989
  */
1769
1990
  const receiptWarnAt = new Map();
1770
- function armReceiptAutosend(target, taskId, callerTeam, deliveredText) {
1991
+ function armReceiptAutosend(target, taskId, callerTeam, deliveredText, steerFloor) {
1771
1992
  if (!callerTeam || callerTeam === '')
1772
1993
  return;
1773
1994
  // Never synthesize a receipt for a receipt: answering an envelope by mail
@@ -1781,6 +2002,10 @@ export function apply(ctx, config) {
1781
2002
  // even if a future path reopens it.
1782
2003
  if (callerTeam === `${config.team}/${agentId8(target)}`)
1783
2004
  return;
2005
+ // #114 S5: the waiter is tied to THIS task's steer — only a final the
2006
+ // target produced after consuming `[A2A direct] (task <id>)` answers it
2007
+ // (flushFinals). A busy target's other work line can no longer settle
2008
+ // a task it has not read yet (the #61 direct-bb27ec94 shape).
1784
2009
  registerFinalWaiter(target, (finalText, placeholder) => {
1785
2010
  // W7 slice-2: a host-authored placeholder (flush timeout, dead
1786
2011
  // session) must not synthesize a COMPLETION receipt — a wedged
@@ -1798,7 +2023,15 @@ export function apply(ctx, config) {
1798
2023
  const summary = `${finalText.trim().replace(/\s+/g, ' ').slice(0, SUMMARY_CAP) || 'done'} (auto)`;
1799
2024
  // v2 envelope projection: header stays byte-compatible for every
1800
2025
  // legacy correlator; the machine JSON rides exactly one following line.
1801
- const receipt = formatReceipt(taskId, summary, { outcome: 'completed', idempotencyKey: taskId });
2026
+ // #114 S5 binding: onBehalfOf names the session that did the work (the
2027
+ // host is only its courier); authRef pins the inbound authorization
2028
+ // record the delivery was born with — recorded for audit, judged by S4.
2029
+ const receipt = formatReceipt(taskId, summary, {
2030
+ outcome: 'completed',
2031
+ idempotencyKey: taskId,
2032
+ onBehalfOf: sessionTeamOf(target),
2033
+ ...(row?.auth !== undefined ? { authRef: authorizationRef(row.auth) } : {}),
2034
+ });
1802
2035
  // Three-tier hard order (work-order P2): caller lane first; on failure
1803
2036
  // escalate ONCE to the owner mailbox — the outcome never evaporates
1804
2037
  // just because the original waiter died. Archive holds truth regardless.
@@ -1809,7 +2042,18 @@ export function apply(ctx, config) {
1809
2042
  // settled undefined as tier-1 success, so the receipt would vanish
1810
2043
  // without a log.
1811
2044
  void runReceiptLadder({
1812
- deliver: (team) => dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true),
2045
+ deliver: async (team) => {
2046
+ // gh#217: across hosts the caller admits a session face it linked, never this host's process label; a
2047
+ // task for the bare process team keeps the label, the only host face the settlement judge accepts (N1).
2048
+ const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2049
+ const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, undefined, peerCaller);
2050
+ // gh#141: the caller lane pays this delivery's inbound row across hosts (a
2051
+ // same-host receipt already settled it in-process), unless the caller
2052
+ // refused it. The owner tier is no payment here — but an owner mailbox
2053
+ // on THIS host settles the row in-process, as it always has.
2054
+ if (team === callerTeam && delivered.settlementRefused !== true)
2055
+ settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
2056
+ },
1813
2057
  ownerTeam: config.team,
1814
2058
  log: (stage, error) => {
1815
2059
  // N2 (observability): throttled per task to avoid ping-pong noise.
@@ -1822,17 +2066,26 @@ export function apply(ctx, config) {
1822
2066
  }, callerTeam).catch(() => {
1823
2067
  // Ladder exhausted: the throttled breadcrumbs above already said why.
1824
2068
  });
1825
- });
2069
+ }, { taskId, steerFloor });
1826
2070
  }
1827
2071
  /**
1828
2072
  * Register one final-reply waiter for the agent and arm its flush timeout.
1829
2073
  * @param agent - the agent whose next assistant message answers the waiter.
1830
2074
  * @param answer - how the waiter's reply is delivered.
2075
+ * @param attribution - #114 S5: attribute by consumption — only the turn
2076
+ * that consumed this task's `[A2A direct] (task <id>)` user message
2077
+ * answers the waiter; the search starts at `steerFloor`, the log length
2078
+ * read just before the steer, so an older steer of a reused id never
2079
+ * anchors it.
1831
2080
  * @returns the registered waiter (for callers that may retract it).
1832
2081
  */
1833
- function registerFinalWaiter(agent, answer) {
2082
+ function registerFinalWaiter(agent, answer, attribution) {
1834
2083
  const key = String(agent.id);
1835
- const waiter = { answer, sinceEvents: sessionEventsOf(agent).length };
2084
+ const waiter = {
2085
+ answer,
2086
+ sinceEvents: sessionEventsOf(agent).length,
2087
+ ...(attribution !== undefined && attribution.taskId !== '' ? { taskId: attribution.taskId, scannedTo: attribution.steerFloor } : {}),
2088
+ };
1836
2089
  waiter.timeoutDisposer = armFlushTimeout(key, waiter);
1837
2090
  pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
1838
2091
  return waiter;
@@ -1845,15 +2098,15 @@ export function apply(ctx, config) {
1845
2098
  * marker instead of a (false) completion.
1846
2099
  * @param taskId - the correlation key the steered receipt header carries.
1847
2100
  */
1848
- async function routeIntoAgent(team, message, caller, taskId) {
2101
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
1849
2102
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
1850
2103
  if (agent !== undefined) {
1851
- return routeIntoAgentFor(agent, team, message, caller, taskId);
2104
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
1852
2105
  }
1853
2106
  // Wake-on-route: a cold joined team materializes on demand, then steers.
1854
2107
  const wake = wakeColdTeam(team);
1855
2108
  if (wake.kind === 'flight') {
1856
- return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2109
+ return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
1857
2110
  }
1858
2111
  // Native-teams inbound bridge (opt-in): a registry team claims the
1859
2112
  // handle and dispatches through its authoritative seam. Checked before
@@ -1862,7 +2115,7 @@ export function apply(ctx, config) {
1862
2115
  if (config.nativeTeamsInbound === true) {
1863
2116
  const prepared = await nativeTeamsPrepare(team);
1864
2117
  if (prepared.ok) {
1865
- const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId);
2118
+ const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId, proofClause ?? '未验证(in-process)', undefined, buildHeader === undefined ? undefined : (_team, _taskId, from) => buildHeader(from));
1866
2119
  if (bridged.ok) {
1867
2120
  if (bridged.settled)
1868
2121
  return { ok: true, reply: bridged.reply };
@@ -1882,7 +2135,7 @@ export function apply(ctx, config) {
1882
2135
  if (live !== undefined) {
1883
2136
  if (callerIsAgent(live, caller))
1884
2137
  return { ok: false, error: selfRouteText(team) };
1885
- return routeIntoAgentFor(live, team, message, caller, taskId);
2138
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
1886
2139
  }
1887
2140
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
1888
2141
  }
@@ -1897,7 +2150,7 @@ export function apply(ctx, config) {
1897
2150
  * @param caller - caller label for the steered prefix.
1898
2151
  * @returns the agent's final reply, or the explicit failure.
1899
2152
  */
1900
- function routeIntoAgentFor(agent, team, message, caller, taskId) {
2153
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
1901
2154
  return new Promise((resolve) => {
1902
2155
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
1903
2156
  resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
@@ -1909,12 +2162,12 @@ export function apply(ctx, config) {
1909
2162
  // the caller supplied one, rides the header so an "[A2A receipt] task
1910
2163
  // <id>" answer correlates with the caller's own route result.
1911
2164
  const from = caller === '' ? 'an unknown node' : caller;
1912
- const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
1913
- const text = `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:
1914
-
1915
- ${message}`;
2165
+ const headerLine = buildHeader !== undefined && taskId !== undefined
2166
+ ? buildHeader(from)
2167
+ : `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2168
+ const text = `${headerLine}\n\n${message}`;
1916
2169
  try {
1917
- steerRelay(agent, text, caller === '' ? undefined : caller);
2170
+ steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
1918
2171
  }
1919
2172
  catch (error) {
1920
2173
  // The waiter was registered above, so the key always holds it; the
@@ -1931,227 +2184,75 @@ ${message}`;
1931
2184
  }
1932
2185
  });
1933
2186
  }
1934
- function teamsRegistry() {
1935
- const candidate = ctx.get('teams');
1936
- if (candidate === undefined || candidate === null)
1937
- return undefined;
1938
- if (typeof candidate.describeTarget !== 'function' || typeof candidate.startRound !== 'function' || typeof candidate.listTeams !== 'function')
1939
- return undefined;
1940
- return candidate;
2187
+ // gh#77 G-in (S23 repayment): the native-teams inbound bridge moved verbatim
2188
+ // to src/legacy/teams-bridge-inbound.ts — the registry probe, claim memo,
2189
+ // prepare and the bounded round. The outbound bridgeFace stays here until
2190
+ // the G-out slice (it submits through the dispatch trunk).
2191
+ const { teamsRegistry, nativeTeamsClaims, nativeTeamsPrepare, nativeTeamsRound } = createTeamsBridgeInbound({ ctx, config, liveAgent, fromClause, sanitizeFromClause, proofHeader: hostDirectHeader, taint: agent => { markLineage(agent, 'native-teams'); } });
2192
+ /* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md,
2193
+ src/origin-claim.ts): the caller's card key signs the envelope; the
2194
+ bound form must name one of OUR origins and the route it hits. ts skew
2195
+ ±5 min, no receiver-side state. Fail-closed everywhere. */
2196
+ /** legacyInbound warn metric (M1): envelope-absent deliveries during the
2197
+ * window — the operator watches the tail drain before closing it. */
2198
+ let legacyInboundCount = 0;
2199
+ /** unboundInbound: accepted unbound (v1) envelopes — the drain gauge for
2200
+ * closing originClaimUnboundWindow; boundMissInbound: those of them whose
2201
+ * bound form missed (a peer dials us by a name not in our audiences). */
2202
+ let unboundInboundCount = 0, boundMissInboundCount = 0;
2203
+ function verifyOriginClaim(req, options) {
2204
+ return verifyOriginClaimOf(req, {
2205
+ cardCache,
2206
+ fetch: fetchImpl,
2207
+ audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2208
+ unboundOpen: config.originClaimUnboundWindow !== false,
2209
+ onUnbound: () => { unboundInboundCount += 1; },
2210
+ onBoundMiss: () => { boundMissInboundCount += 1; },
2211
+ loopbackArrival: (config.originClaimUrl ?? '') === '',
2212
+ now: Date.now(),
2213
+ ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2214
+ });
1941
2215
  }
1942
- /** Reply-wait parity with the steer path's 180s deadline: native-teams
1943
- * rounds settle on their own cadence, but a wedged seam must not park a
1944
- * caller past a bounded window (config.nativeRoundWaitMs). */
1945
- /** The claim table changes at registry-edit cadence: a short memo dedupes
1946
- * the double probe (callers pre-check, the round re-verifies) without
1947
- * pretending the answer is permanent. */
1948
- const BRIDGE_CLAIM_TTL_MS = 5_000;
1949
- const bridgeClaimCache = new Map();
1950
- const bridgeTimers = new Set();
1951
- ctx.effect(() => () => {
1952
- for (const timer of bridgeTimers)
1953
- clearTimeout(timer);
1954
- });
1955
2216
  /**
1956
- * Whether the native-teams registry claims `team` as an unambiguous local
1957
- * target, memoized for {@link BRIDGE_CLAIM_TTL_MS}. Ordinary misses never
1958
- * throw (the D2 query contract); anything that does throw is treated as a
1959
- * miss — the bridge is a guest here. The memo is skipped entirely while
1960
- * the bridge is off, so the flag gates the probe itself.
2217
+ * gh#133: the deposited card a CLI claim is judged by, or undefined when
2218
+ * the claim URL is not a deposit face of a handle held here (the ordinary
2219
+ * zone path then decides). The URL's last path segments must be
2220
+ * `/a2a/cli/<handle>` (any prefix or origin: the CLI dials its home under
2221
+ * whatever address it can — the deposited key, not the address, is the
2222
+ * proof). origin-claim.ts verifies the signature under that card and the
2223
+ * signed name against the handle's own; the claim's caller is the full
2224
+ * HANDLE, so it covers exactly that handle — never a namesake CLI, a zone
2225
+ * session, or the host face (review of PR #135 P1).
1961
2226
  */
1962
- async function nativeTeamsClaims(team) {
1963
- if (config.nativeTeamsInbound !== true)
1964
- return false;
1965
- const cached = bridgeClaimCache.get(team);
1966
- if (cached !== undefined && Date.now() - cached.at < BRIDGE_CLAIM_TTL_MS)
1967
- return cached.claimed;
1968
- const teams = teamsRegistry();
1969
- let claimed = false;
1970
- if (teams !== undefined) {
1971
- try {
1972
- const descriptor = await teams.describeTarget(team);
1973
- claimed = descriptor.plane === 'local' && descriptor.ambiguous !== true && descriptor.localLabel !== undefined;
1974
- }
1975
- catch {
1976
- claimed = false;
1977
- }
2227
+ function cliDepositCard(callerUrl) {
2228
+ let path;
2229
+ try {
2230
+ path = new URL(callerUrl).pathname;
1978
2231
  }
1979
- bridgeClaimCache.set(team, { at: Date.now(), claimed });
1980
- // Bounded memo: probe-miss names accumulate one tiny row each; past a
1981
- // generous cap, drop the whole table (a cold re-probe costs one local
1982
- // describeTarget call).
1983
- if (bridgeClaimCache.size > 512)
1984
- bridgeClaimCache.clear();
1985
- return claimed;
1986
- }
1987
- /**
1988
- * Resolve everything the round needs BEFORE any caller is told the
1989
- * dispatch happened: the claim, the seam, and a parent initiator. The
1990
- * `error` variant is the only fast-failure shape — `not-claimed` simply
1991
- * hands the address back to the standard resolution chain.
1992
- */
1993
- async function nativeTeamsPrepare(team) {
1994
- if (!await nativeTeamsClaims(team))
1995
- return { ok: false, reason: 'not-claimed' };
1996
- const teams = teamsRegistry();
1997
- if (teams === undefined)
1998
- return { ok: false, reason: 'not-claimed' };
1999
- const parent = liveAgent();
2000
- if (parent === undefined)
2001
- return { ok: false, reason: 'error', error: 'No live DSH initiator is available to parent the native-teams round.' };
2002
- // The alpha.4 Session face dropped the events member (the widened
2003
- // BridgeTeamsRegistry accepts it optional) — the raw Agent carries the
2004
- // ambient Session whose shape no longer overlaps, so project through the
2005
- // widened structural face explicitly (type-level only; the runtime value
2006
- // is the same live agent).
2007
- const parentFace = parent;
2008
- return { ok: true, teams, parent: parentFace };
2009
- }
2010
- /** The A2A envelope rides the round message so the dispatcher sees the network origin. */
2011
- function bridgeEnvelope(team, caller, message, taskId) {
2012
- const from = caller === '' ? 'an unknown node' : caller;
2013
- const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
2014
- return `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:\n\n${message}`;
2015
- }
2016
- /** The projection for a round that outlived the reply window: unlike the
2017
- * steer deadline's text, this must NOT promise a receipt — native-teams
2018
- * rounds route none in this slice. */
2019
- function bridgeUnsettledReply(team) {
2020
- return `The native-teams round for "${team}" is still running past the reply window; it was delivered and keeps going. Native-teams rounds route no A2A receipt in this slice — reconcile with a follow-up route (continuity rides the team's own durable round chain).`;
2021
- }
2022
- /**
2023
- * Fire the round and wait, bounded. The caller's signal (when it has one)
2024
- * cancels the round itself; the deadline releases the caller with the
2025
- * unsettled projection while the round keeps running detached.
2026
- * @param prepared - a ready dispatch (claim, seam, parent) from {@link nativeTeamsPrepare}.
2027
- */
2028
- async function nativeTeamsRound(prepared, team, caller, message, taskId, external) {
2029
- const controller = new AbortController();
2030
- // One listener, always removed after the race: an external abort cancels
2031
- // the round through its own controller.
2032
- const onExternalAbort = () => { controller.abort(); };
2033
- if (external !== undefined) {
2034
- if (external.aborted)
2035
- controller.abort();
2036
- else
2037
- external.addEventListener('abort', onExternalAbort);
2232
+ catch {
2233
+ return undefined;
2038
2234
  }
2039
- if (controller.signal.aborted)
2040
- return { ok: false, error: 'aborted before the native-teams round was dispatched', phase: 'pre-dispatch' };
2041
- let roundError;
2042
- let deadlineTimer;
2043
- const deadline = new Promise(resolve => {
2044
- deadlineTimer = setTimeout(() => {
2045
- if (deadlineTimer !== undefined)
2046
- bridgeTimers.delete(deadlineTimer);
2047
- resolve({ kind: 'deadline' });
2048
- }, config.nativeRoundWaitMs ?? 180_000);
2049
- deadlineTimer.unref?.();
2050
- bridgeTimers.add(deadlineTimer);
2051
- });
2052
- const verdict = await Promise.race([
2053
- prepared.teams.startRound({ team, message: bridgeEnvelope(team, caller, message, taskId) }, prepared.parent, controller.signal).then((reply) => ({ kind: 'settled', reply }), (error) => { roundError = error; return { kind: 'failed' }; }),
2054
- deadline,
2055
- new Promise(resolve => {
2056
- if (controller.signal.aborted)
2057
- resolve({ kind: 'aborted' });
2058
- else
2059
- controller.signal.addEventListener('abort', () => resolve({ kind: 'aborted' }), { once: true });
2060
- }),
2061
- ]);
2062
- // Hygiene: the losing deadline timer stops here (not 180s later), and
2063
- // the external-signal listener does not outlive the round.
2064
- if (deadlineTimer !== undefined) {
2065
- clearTimeout(deadlineTimer);
2066
- bridgeTimers.delete(deadlineTimer);
2235
+ const segment = /\/a2a\/cli\/([^/]+)\/?$/.exec(path)?.[1];
2236
+ if (segment === undefined)
2237
+ return undefined;
2238
+ let handle;
2239
+ try {
2240
+ handle = decodeURIComponent(segment);
2067
2241
  }
2068
- if (external !== undefined)
2069
- external.removeEventListener('abort', onExternalAbort);
2070
- if (verdict.kind === 'settled')
2071
- return { ok: true, reply: verdict.reply, settled: true };
2072
- if (verdict.kind === 'deadline')
2073
- return { ok: true, reply: bridgeUnsettledReply(team), settled: false };
2074
- if (verdict.kind === 'aborted')
2075
- return { ok: false, error: `the wait for the native-teams round "${team}" was aborted (the round was cancelled with it)`, aborted: true };
2076
- return { ok: false, error: `the native-teams round for "${team}" failed: ${String(roundError)}` };
2077
- }
2078
- /* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md).
2079
- Envelope: X-A2A-Caller / -Url / -Ts / -Sig — canonical form is
2080
- JSON.stringify([caller, callerUrl, ts]) (R2), signed with the CALLER'S
2081
- CARD KEY; verification fetches the caller's card at callerUrl through
2082
- our own enveloped memoized fetch (R1: our fetches are full-form by
2083
- construction — the client stamps the envelope on every GET), then
2084
- verifies the signature against THAT card's key and derives the caller
2085
- name from the card (team or a published session team). ts skew ±5 min,
2086
- stateless (no nonce ring — ruling refinement). Fail-closed everywhere. */
2087
- const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
2088
- /** legacyInbound warn metric (M1): envelope-absent deliveries during the
2089
- * window — the operator watches the tail drain before closing it. */
2090
- let legacyInboundCount = 0;
2091
- const originClaimCallerShape = /^[a-z0-9-]+$/i;
2092
- async function verifyOriginClaim(req) {
2093
- const caller = String(req.headers['x-a2a-caller'] ?? '');
2094
- const callerUrl = String(req.headers['x-a2a-caller-url'] ?? '');
2095
- const ts = Number(req.headers['x-a2a-caller-ts'] ?? '');
2096
- const signature = String(req.headers['x-a2a-caller-sig'] ?? '');
2097
- if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || signature === '')
2098
- return { ok: false, reason: 'absent' };
2099
- if (!originClaimCallerShape.test(caller))
2100
- return { ok: false, reason: 'bad-caller-shape' };
2101
- if (Math.abs(Date.now() - ts) > ORIGIN_CLAIM_SKEW_MS)
2102
- return { ok: false, reason: 'bad-ts' };
2103
- // Fetch the caller's card PLAIN (REVIEW M2 third-round ruling: the
2104
- // envelope names a BARE ZONE, and derivation is the exact match
2105
- // card.team === caller below — both ride the minimal card's signed
2106
- // core, so a single envelope-less fetch suffices). An envelope-less
2107
- // request returns immediately without recursing (the enveloped variant
2108
- // re-opened mutual recursion); a warm full-form cache entry (R1)
2109
- // short-circuits the fetch entirely.
2110
- let card = cardCache.get(callerUrl)?.card;
2111
- if (card === undefined) {
2112
- try {
2113
- // callerUrl is the caller's BASE URL — the card lives at the
2114
- // well-known path under it.
2115
- const cardUrl = callerUrl.endsWith('/') ? `${callerUrl}.well-known/agent-card.json` : `${callerUrl}/.well-known/agent-card.json`;
2116
- const response = await fetchImpl(cardUrl, { method: 'GET', headers: {} });
2117
- const text = await response.text();
2118
- const verdict = verifyCard(JSON.parse(text), Date.now());
2119
- if (verdict.ok)
2120
- card = verdict.card;
2121
- else
2122
- req.__claimDbg = { stage: 'verify', reason: verdict.reason, head: text.slice(0, 100) };
2123
- }
2124
- catch (error) {
2125
- ;
2126
- req.__claimDbg = { stage: 'fetch', error: error instanceof Error ? error.message : String(error) };
2127
- card = undefined;
2128
- }
2242
+ catch {
2243
+ return undefined;
2129
2244
  }
2130
- if (card === undefined)
2131
- return { ok: false, reason: 'no-card' };
2245
+ const shape = CLI_HANDLE_RE.exec(handle);
2246
+ const deposit = shape === null ? undefined : cliDeposits.get(handle);
2247
+ if (shape === null || deposit === undefined)
2248
+ return undefined;
2132
2249
  try {
2133
- const publicKey = createPublicKey({ key: Buffer.from(card.publicKey, 'base64'), format: 'der', type: 'spki' });
2134
- const valid = edVerify(undefined, Buffer.from(JSON.stringify([caller, callerUrl, ts]), 'utf8'), publicKey, Buffer.from(signature, 'base64'));
2135
- if (!valid)
2136
- return { ok: false, reason: 'bad-sig' };
2250
+ const verdict = verifyCard(JSON.parse(deposit.cardJson), Date.now());
2251
+ return { handle, name: shape[1], card: verdict.ok && verdict.card.team === handle ? verdict.card : undefined };
2137
2252
  }
2138
2253
  catch {
2139
- return { ok: false, reason: 'bad-sig' };
2140
- }
2141
- // Derivation (REVIEW M2, third-round ruling — shape narrowed to bare
2142
- // zone): card.team === caller EXACT match. A zone/id8 envelope is
2143
- // rejected as bad-caller-shape at the gate — session-level identity
2144
- // rides the body's caller_session, judged by N3/S3 against the PUBLISHED
2145
- // roster. (The prefix-derivation variant admitted an impersonation
2146
- // chain: a same-zone foreign host signs the target's session handle
2147
- // with its own key — prefix matches, signature verifies, full card
2148
- // serves, and N3 then judges the body by the impersonated roster.
2149
- // Fleet senders stamp bare zone only; the narrowed shape kills the
2150
- // false rejections AND the impersonation vector with zero recursion.)
2151
- if (card.team !== caller) {
2152
- return { ok: false, reason: 'not-derivable' };
2254
+ return { handle, name: shape[1], card: undefined };
2153
2255
  }
2154
- return { ok: true, caller, callerUrl, card };
2155
2256
  }
2156
2257
  /**
2157
2258
  * W3 (#18 half-side): is a VERIFIED caller LINKED to this host? Key
@@ -2177,6 +2278,9 @@ ${message}`;
2177
2278
  * Our operator's config anchors; the card shadow corroborates.
2178
2279
  */
2179
2280
  function callerLinkedToThisHost(caller, callerUrl, callerCard) {
2281
+ // gh#133 review: a CLI identity's card (team = its handle) is never a host link, whatever face asks.
2282
+ if (callerCard !== undefined && CLI_HANDLE_RE.test(callerCard.team))
2283
+ return false;
2180
2284
  // Basis 1, same-host membership (review second-round M-fix): "self" is
2181
2285
  // an ORIGIN equality, not a zone-name equality. `caller === config.team`
2182
2286
  // granted unconditionally in a single-zone fleet (the deployment
@@ -2184,7 +2288,7 @@ ${message}`;
2184
2288
  // shared zone string was "us". True self is the loopback self-fetch
2185
2289
  // face: the caller's card URL origin equals OUR card URL origin.
2186
2290
  try {
2187
- if (callerUrl !== '' && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
2291
+ if (callerUrl !== '' && isRootOriginUrl(callerUrl) && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
2188
2292
  return true;
2189
2293
  }
2190
2294
  catch { /* malformed callerUrl — falls through to the record bases */ }
@@ -2213,12 +2317,13 @@ ${message}`;
2213
2317
  // served-fresh field, exactly the self-description trust class
2214
2318
  // N3/V5 abolished. Our operator's own config anchors; the card
2215
2319
  // shadow only corroborates.
2320
+ // #189: a ROOT claim URL speaks for its origin; a path-prefixed one only for the exact peer URL we seeded (a mounted card).
2216
2321
  if (callerCard !== undefined && ownCardUrl !== '') {
2217
2322
  const ourOrigin = new URL(ownCardUrl).origin;
2218
2323
  const callerOrigin = new URL(callerUrl).origin;
2219
2324
  const weSeedThem = config.peers.some(peer => {
2220
2325
  try {
2221
- return new URL(peer).origin === callerOrigin;
2326
+ return isRootOriginUrl(callerUrl) ? new URL(peer).origin === callerOrigin : sameMountedUrl(peer, callerUrl);
2222
2327
  }
2223
2328
  catch {
2224
2329
  return false;
@@ -2253,218 +2358,112 @@ ${message}`;
2253
2358
  * sessions' declarations. Used by both the inbound faces and the local
2254
2359
  * dispatch candidate so the two paths can never diverge.
2255
2360
  */
2256
- function sameZoneSharedTeamRefusal(caller, targetTeam) {
2257
- const declared = new Set();
2258
- let callerSessionId;
2259
- for (const entry of teamMemberships.list()) {
2260
- if (`${config.team}/${id8(entry.session)}` === caller) {
2261
- callerSessionId = entry.session;
2262
- for (const t of entry.teams)
2263
- declared.add(t);
2264
- }
2265
- }
2266
- // W2②: canvas membership is a link record too — the caller's session
2267
- // sitting in a canvas team declares that team as much as a roster line.
2268
- if (callerSessionId === undefined) {
2269
- for (const id of joinedSessions.list()) {
2270
- if (`${config.team}/${id8(id)}` === caller) {
2271
- callerSessionId = id;
2272
- break;
2273
- }
2274
- }
2275
- }
2276
- if (callerSessionId !== undefined) {
2277
- for (const name of canvasStore.teamsOf(callerSessionId))
2278
- declared.add(canvasTeamOf(name));
2279
- }
2280
- else {
2281
- // Issue #43: a same-zone handle that resolves to NO local session is a
2282
- // CROSS-HOST member (two nodes sharing one zone name — the fleet's
2283
- // common `dsh` spelling). The canvas link record exists on this host
2284
- // as a REMOTE MEMBER REF the operator added; the foreign-zone branch
2285
- // (a2aCallerRefusal) already honors that basis — the same-zone path
2286
- // must too, or a linked remote member is refused as teamless. The
2287
- // trust basis is unchanged: this host's operator-composed canvas,
2288
- // never the caller's self-description (V5).
2289
- for (const name of canvasStore.list()) {
2290
- if (canvasStore.remoteMembersOf(name).includes(caller))
2291
- declared.add(canvasTeamOf(name));
2292
- }
2293
- // gh#71 fix ②: the warm card cache's peer canvasOrg is the same-zone
2294
- // cross-host basis too — an org entry naming BOTH the caller's handle
2295
- // and one of THIS host's own handles is a shared team (双向识别: the
2296
- // composition gesture happened on the other host's board; both ends
2297
- // are named). Read-only over the cache; a caller org that never names
2298
- // an own handle still refuses (V5 preserved).
2299
- const ownHandles = new Set([config.team, ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`)]);
2361
+ // gh#114 S1: the admission rules live in src/task-auth.ts as one pure
2362
+ // decision over these records; the closures below are thin wrappers, so
2363
+ // the faces' refusal texts and the authorization record (S2) can never
2364
+ // disagree. Records are read at decision time, never copied.
2365
+ const admissionRecords = {
2366
+ zone: config.team,
2367
+ get teamScopeRouting() { return config.teamScopeRouting; },
2368
+ id8,
2369
+ canvasTeamOf,
2370
+ memberships: () => teamMemberships.list(),
2371
+ joined: () => joinedSessions.list(),
2372
+ canvasNames: () => canvasStore.list(),
2373
+ canvasTeamsOf: sessionId => canvasStore.teamsOf(sessionId),
2374
+ canvasMembersOf: name => canvasStore.membersOf(name),
2375
+ canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
2376
+ peerOrgs: function* () {
2300
2377
  for (const cached of cardCache.values()) {
2301
2378
  const org = cached.card?.canvasOrg;
2302
- if (!Array.isArray(org))
2303
- continue;
2304
- for (const e of org) {
2305
- if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
2306
- continue;
2307
- if (e.members.includes(caller) && e.members.some(m => typeof m === 'string' && ownHandles.has(m)))
2308
- declared.add(e.team);
2309
- }
2379
+ if (Array.isArray(org))
2380
+ yield org;
2310
2381
  }
2311
- }
2312
- const routable = new Set([config.team]);
2313
- for (const id of joinedSessions.list())
2314
- routable.add(`${config.team}/${id8(id)}`);
2315
- for (const name of canvasStore.list())
2316
- routable.add(canvasTeamOf(name));
2317
- const otherLocalDeclarations = new Set();
2318
- for (const entry of teamMemberships.list()) {
2319
- if (`${config.team}/${id8(entry.session)}` === caller)
2320
- continue;
2321
- for (const t of entry.teams)
2322
- otherLocalDeclarations.add(t);
2323
- }
2324
- const shared = [...declared].some(t => routable.has(t) || otherLocalDeclarations.has(t));
2325
- // F5 (gh#26, attribute-owner ruling): same-host admission needs a
2326
- // TARGET-level intersection — a canvas member used to reach ANY session
2327
- // team on this host through a host-level link alone. The target's link
2328
- // set = its roster declarations ∪ its canvas teams. EXCEPTION (ruling,
2329
- // 成文): the PROCESS face (target === config.team) is the owner face,
2330
- // not a member face — any shared team clears it.
2331
- if (targetTeam !== undefined && targetTeam !== config.team) {
2332
- let targetLinks;
2333
- const targetId8 = targetTeam.startsWith(`${config.team}/`) ? targetTeam.slice(config.team.length + 1) : undefined;
2334
- if (targetId8 !== undefined) {
2335
- // Only a KNOWN local session earns target-level judgement; an
2336
- // unresolvable handle (archived/foreign spelling) keeps the
2337
- // host-level basis — an empty target set must not refuse everyone.
2338
- // The handle carries the id8 SLICE, so resolve it back to the
2339
- // session id before asking the stores (canvas teamsOf keys by id).
2340
- const targetSession = joinedSessions.list().find(sessionId => id8(sessionId) === targetId8)
2341
- ?? teamMemberships.list().find(entry => id8(entry.session) === targetId8)?.session;
2342
- if (targetSession !== undefined) {
2343
- targetLinks = new Set();
2344
- for (const entry of teamMemberships.list()) {
2345
- if (entry.session === targetSession)
2346
- for (const t of entry.teams)
2347
- targetLinks.add(t);
2348
- }
2349
- for (const name of canvasStore.teamsOf(targetSession))
2350
- targetLinks.add(canvasTeamOf(name));
2351
- // gh#71 fix ②: the warm cache's org basis at target level too —
2352
- // an org entry naming BOTH the caller and the target handle is
2353
- // the shared team (same 双向识别 rule as the host-level basis).
2354
- for (const cached of cardCache.values()) {
2355
- const org = cached.card?.canvasOrg;
2356
- if (!Array.isArray(org))
2357
- continue;
2358
- for (const e of org) {
2359
- if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
2360
- continue;
2361
- if (e.members.includes(caller) && e.members.includes(targetTeam))
2362
- targetLinks.add(e.team);
2363
- }
2364
- }
2365
- }
2366
- }
2367
- else {
2368
- const canvasName = canvasStore.list().find(name => canvasTeamOf(name) === targetTeam);
2369
- if (canvasName !== undefined) {
2370
- targetLinks = new Set([targetTeam]);
2371
- for (const memberId of canvasStore.membersOf(canvasName)) {
2372
- for (const entry of teamMemberships.list())
2373
- if (entry.session === memberId)
2374
- for (const t of entry.teams)
2375
- targetLinks.add(t);
2376
- }
2377
- }
2378
- }
2379
- if (targetLinks !== undefined) {
2380
- if ([...declared].some(t => targetLinks.has(t)))
2381
- return undefined;
2382
- return declared.size === 0
2383
- ? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
2384
- : `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared].join(', ')}], target links [${[...targetLinks].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`;
2385
- }
2386
- // Unresolvable target (foreign/zone delegation): host-level basis.
2387
- }
2388
- if (shared)
2389
- return undefined;
2390
- return declared.size === 0
2391
- ? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
2392
- : `caller "${caller}" declares teams [${[...declared].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`;
2382
+ },
2383
+ liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
2384
+ cliDeposited: handle => cliDeposits.get(handle) !== undefined,
2385
+ };
2386
+ /** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
2387
+ function sameZoneSharedTeamRefusal(caller, targetTeam) {
2388
+ return refusalOf(sameZoneAdmission(admissionRecords, caller, targetTeam));
2393
2389
  }
2394
- /**
2395
- * W1: does a caller label fall under the joined-face admission gate? The
2396
- * zone/id8 shape with THIS zone names a joined session node — the face
2397
- * the boundary-hardening wave closes. The initiator face (bare node
2398
- * team) and foreign-zone labels are judged elsewhere (F1b host-face
2399
- * rules; the foreign branch below) and stay untouched.
2400
- */
2401
- const joinedFaceCallerShape = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
2390
+ /** W1: the joined-face gate of the local dispatch candidate (see joinedFaceAdmission). */
2402
2391
  function joinedFaceAdmissionRefusal(callerSession, targetTeam) {
2403
- if (callerSession === undefined || callerSession === '')
2404
- return undefined;
2405
- if (!joinedFaceCallerShape.test(callerSession))
2406
- return undefined;
2407
- if (callerSession.slice(0, callerSession.indexOf('/')) !== config.team)
2408
- return undefined;
2409
- return sameZoneSharedTeamRefusal(callerSession, targetTeam);
2392
+ const verdict = joinedFaceAdmission(admissionRecords, callerSession, targetTeam);
2393
+ return verdict === undefined ? undefined : refusalOf(verdict);
2394
+ }
2395
+ /** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
2396
+ function a2aCallerAdmission(caller, targetTeam) {
2397
+ return callerAdmission(admissionRecords, caller, targetTeam);
2410
2398
  }
2411
2399
  function a2aCallerRefusal(caller, targetTeam) {
2412
- if (!config.teamScopeRouting)
2413
- return undefined;
2414
- if (caller === '') {
2415
- return 'caller_session is required — anonymous deliveries are refused (S3 phase-3b). Pass caller_session as <zone>/<id8>.';
2416
- }
2417
- // Host face: the node's own bare zone team is a host fact, always
2418
- // admissible to itself.
2419
- if (caller === config.team)
2420
- return undefined;
2421
- const zoneShaped = /^[a-z0-9-]+\/[0-9a-f]{8}$/i.test(caller);
2422
- if (zoneShaped) {
2423
- const callerZone = caller.slice(0, caller.indexOf('/'));
2424
- if (callerZone === config.team) {
2425
- // Same-zone handle: the W1/W2 shared admission core — roster
2426
- // declarations UNION canvas memberships against this host's
2427
- // records (canvas names are link records too; see the helper).
2428
- // F5: the routed team rides along so admission can require a
2429
- // TARGET-level intersection.
2430
- return sameZoneSharedTeamRefusal(caller, targetTeam);
2431
- }
2432
- // Foreign-zone handle (N3/N4): admitted ONLY through this host's
2433
- // operator-established cross-host links — a canvas remote member ref
2434
- // the operator added by hand. The card cache used to be a basis; a
2435
- // card is the CALLER'S own claim about itself (V5: any host could
2436
- // declare the target's team on its own card and self-serve
2437
- // admission), so it no longer decides anything here. First-contact
2438
- // learn-and-admit dies with it.
2439
- for (const name of canvasStore.list()) {
2440
- if (canvasStore.remoteMembersOf(name).includes(caller))
2441
- return undefined;
2400
+ return refusalOf(a2aCallerAdmission(caller, targetTeam));
2401
+ }
2402
+ /**
2403
+ * #114 S2: the allowlist version over the operator-controlled records the
2404
+ * admission rules read (design §2.3). The peer-card canvasOrg is left out:
2405
+ * a record admitted by it carries `peer-org:<team>` in its basis instead.
2406
+ */
2407
+ function allowlistVersionNow() {
2408
+ return allowlistVersion(allowlistInputsNow());
2409
+ }
2410
+ /** The records allowlistVersionNow hashes (design §2.3), read live. */
2411
+ function allowlistInputsNow() {
2412
+ const handle = (session) => `${config.team}/${id8(session)}`;
2413
+ return {
2414
+ zone: config.team,
2415
+ scope: [config.teamScopeRouting, config.originClaimWindow !== false],
2416
+ joined: joinedSessions.list().map(handle),
2417
+ roster: teamMemberships.list().flatMap(entry => entry.teams.map(team => [handle(entry.session), team])),
2418
+ // Local members by their handle (design §2.3), remote refs as they are stored.
2419
+ canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
2420
+ cli: cliDeposits.list().map(row => row.handle),
2421
+ links: config.peers.map(peer => { try {
2422
+ return new URL(peer).origin;
2442
2423
  }
2443
- return `caller "${caller}" has no operator-established link on this node — cross-host admission requires a canvas remote membership this host's operator added (N3/N4: the caller's own card claims are self-description, not links). Ask this host's operator to add the member in the A2A workbench, or route through a linked member.`;
2444
- }
2445
- // Free-form label (not zone/id8): only THIS host's own node facts — a
2446
- // joined session id, its zone-qualified pseudo-handle (id8 passes
2447
- // short ids through, so dsh/agent-1 is a local shape), or a live
2448
- // node's label. Anything else is an unverifiable self-description
2449
- // (V3: 'anyone-at-all' used to pass).
2450
- if (joinedSessions.list().includes(caller))
2451
- return undefined;
2452
- for (const id of joinedSessions.list()) {
2453
- if (`${config.team}/${id8(id)}` === caller)
2454
- return undefined;
2424
+ catch {
2425
+ return peer;
2426
+ } }),
2427
+ };
2428
+ }
2429
+ /*
2430
+ * #114 S6 (design §5): when the version moves, the unsettled tasks born
2431
+ * under another one are re-judged against today's records and the
2432
+ * affected ones recorded — OBSERVE ONLY: nothing is cancelled, no row's
2433
+ * state changes, no session is nudged, no receipt is blocked. Called
2434
+ * lazily (state/tasks reads, inbound admission) and at the known change
2435
+ * points (the stores' onChange hooks below); never on a timer.
2436
+ */
2437
+ const authObserver = new AuthObserver(join(home, 'a2a', 'auth-versions.json'));
2438
+ function observeAuthorization(cause) {
2439
+ const inputs = allowlistInputsNow();
2440
+ const entry = authObserver.observe({
2441
+ version: allowlistVersion(inputs),
2442
+ inputs: () => JSON.parse(canonicalAllowlist(inputs)),
2443
+ cause,
2444
+ rows: () => taskLedger.list(),
2445
+ // A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
2446
+ judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
2447
+ });
2448
+ if (entry === undefined)
2449
+ return;
2450
+ const revoke = entry.tasks.filter(task => task.verdict === 'would-revoke').length;
2451
+ logger.info(`a2a: allowlist ${entry.from_version ?? '(none)'} → ${entry.to_version} (${entry.cause}): ${String(entry.tasks.length)} affected task(s), ${String(revoke)} would-revoke — observe only, nothing cancelled`);
2452
+ try {
2453
+ ctx.emit('a2a/authorization-affected', entry);
2455
2454
  }
2456
- for (const agent of sessionNodes.values()) {
2457
- if (String(agent.id) === caller)
2458
- return undefined;
2455
+ catch (error) {
2456
+ logger.warn(`a2a: authorization-affected listener rejected: ${String(error)}`);
2459
2457
  }
2460
- // gh#45: a DEPOSITED CLI handle is a published node fact of this host
2461
- // (the card's sessionTeams carries it; the envelope's zone derivation
2462
- // already proved the CLI key on the wire face's own verify). The
2463
- // handle is <agent-name>/<uuid16> — not zone/id8-shaped, hence this
2464
- // branch, but it is as much a fact as a joined session.
2465
- if (cliDeposits.get(caller) !== undefined)
2466
- return undefined;
2467
- return `caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`;
2458
+ }
2459
+ /** The /state `authorization` block: the version history (newest first, inputs omitted) and the affected entries. */
2460
+ function authorizationView() {
2461
+ observeAuthorization('lazy');
2462
+ return {
2463
+ version: allowlistVersionNow(),
2464
+ history: authObserver.history().map(({ version, at, cause }) => ({ version, at, cause })),
2465
+ affected: authObserver.entries(),
2466
+ };
2468
2467
  }
2469
2468
  /* ── Session-log read faces (shared by the local control route and the
2470
2469
  peer /a2a/log face): both answer from <dshHome>/sessions with
@@ -2535,15 +2534,19 @@ ${message}`;
2535
2534
  config,
2536
2535
  cliDeposits,
2537
2536
  canvasStore,
2537
+ canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
2538
2538
  sessionNodes,
2539
2539
  joinedSessions,
2540
2540
  idempotencyStore,
2541
2541
  verifyOriginClaim,
2542
- callerLinkedToThisHost,
2542
+ callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2543
2543
  recordActivity,
2544
2544
  bumpLegacyInbound: () => { legacyInboundCount += 1; },
2545
2545
  secretEqual,
2546
2546
  a2aCallerRefusal,
2547
+ a2aCallerAdmission,
2548
+ allowlistVersionNow,
2549
+ observeAuthorization,
2547
2550
  scanSessionLogList,
2548
2551
  readSessionLogTail,
2549
2552
  taskLedger,
@@ -2556,9 +2559,13 @@ ${message}`;
2556
2559
  canvasLiveAgent,
2557
2560
  wakeColdTeam,
2558
2561
  probeConsumption,
2562
+ sessionTeamOf,
2563
+ localSessionOfHandle,
2559
2564
  steerRelay,
2560
2565
  fromClause,
2566
+ sanitizeFromClause,
2561
2567
  armReceiptAutosend,
2568
+ eventCountOf: (agent) => sessionEventsOf(agent).length,
2562
2569
  receiptExpectationOf,
2563
2570
  armAsyncNudge,
2564
2571
  liveAgent,
@@ -2685,160 +2692,9 @@ ${message}`;
2685
2692
  });
2686
2693
  }),
2687
2694
  }), 'a2a: quick-dispatch route');
2688
- // Session transcript reader (0.6.11): the workbench inspector shows the
2689
- // tail of a LOCAL node's own conversation log inline — owner ruling
2690
- // 2026-10-20 banned navigation, and reading is the need jumps used to
2691
- // serve. The id gate is artifact existence under this host's sessions
2692
- // tree — reading is a host-workbench concern, not a network-membership
2693
- // concern, so archived and never-joined sessions stay readable while the
2694
- // network faces keep their own visibility rules. Path shape is still
2695
- // checked (no '/', '\', '..') and the tree never leaves <dshHome>/sessions.
2696
- // Without an id the same face lists readable sessions (newest activity
2697
- // first, capped) so the workbench directory can offer log rows even when
2698
- // the network directory shows none. Caps mirror session-transcript.ts.
2699
- ctx.effect(() => webServer.register({
2700
- kind: 'exact',
2701
- path: '/__dsh_a2a/session-log',
2702
- handler: controlRoute((req, res) => {
2703
- const url = new URL(req.url ?? '/', 'http://x');
2704
- const id = url.searchParams.get('id') ?? '';
2705
- const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
2706
- const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
2707
- let result;
2708
- if (id.includes('/') || id.includes('\\') || id.includes('..')) {
2709
- result = { ok: false, error: 'id is required' };
2710
- }
2711
- else if (id === '') {
2712
- // Listing face: every session directory holding a log artifact.
2713
- try {
2714
- const rows = scanSessionLogList();
2715
- result = { ok: true, count: rows.length, sessions: rows.slice(0, 50) };
2716
- }
2717
- catch (error) {
2718
- result = { ok: false, error: `transcript list failed: ${error instanceof Error ? error.message : String(error)}` };
2719
- }
2720
- }
2721
- else {
2722
- try {
2723
- const tail = readSessionLogTail(id, limit);
2724
- if (!tail.ok)
2725
- result = { ok: false, error: tail.error };
2726
- else
2727
- result = { ok: true, id: tail.id, count: tail.messages.length, messages: tail.messages };
2728
- }
2729
- catch (error) {
2730
- result = { ok: false, error: `transcript failed: ${error instanceof Error ? error.message : String(error)}` };
2731
- }
2732
- }
2733
- const payload = JSON.stringify(result);
2734
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2735
- res.end(payload);
2736
- }),
2737
- }), 'a2a: session-log route');
2738
- // 0.6.15: the local proxy for REMOTE session logs. The workbench runs
2739
- // in this host's browser page, so cross-host reads must originate from
2740
- // this host (no CORS, one trust boundary). ?team=<zone/id8> resolves
2741
- // directory candidates that publish that team (same walk as routing);
2742
- // ?peer=<base-url> targets one tracked peer directly and MUST name a
2743
- // URL already in the peer store — the proxy never fetches an
2744
- // arbitrary address. The caller label we present is config.team (this
2745
- // node's own zone handle, the same host face quick-dispatch uses).
2746
- ctx.effect(() => webServer.register({
2747
- kind: 'exact',
2748
- path: '/__dsh_a2a/remote-session-log',
2749
- handler: controlRoute(async (req, res) => {
2750
- const url = new URL(req.url ?? '/', 'http://x');
2751
- const team = url.searchParams.get('team') ?? '';
2752
- const peer = url.searchParams.get('peer') ?? '';
2753
- const id = url.searchParams.get('id') ?? '';
2754
- const limitArg = Number(url.searchParams.get('limit') ?? TRANSCRIPT_DEFAULT_LIMIT);
2755
- const limit = Number.isFinite(limitArg) ? Math.max(1, Math.min(TRANSCRIPT_MAX_LIMIT, Math.floor(limitArg))) : TRANSCRIPT_DEFAULT_LIMIT;
2756
- const finish = (body) => {
2757
- const payload = JSON.stringify(body);
2758
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2759
- res.end(payload);
2760
- };
2761
- if (id.includes('/') || id.includes('\\') || id.includes('..')) {
2762
- finish({ ok: false, error: 'id is required' });
2763
- return;
2764
- }
2765
- const trackedPeers = new Set(peerStore.list().map(entry => entry.replace(/\/+$/, '')));
2766
- let targets;
2767
- if (peer !== '') {
2768
- const normalized = peer.replace(/\/+$/, '');
2769
- if (!trackedPeers.has(normalized)) {
2770
- finish({ ok: false, error: 'unknown peer on this node — the proxy only reads tracked peers' });
2771
- return;
2772
- }
2773
- targets = [normalized];
2774
- }
2775
- else if (team !== '') {
2776
- const failures = [];
2777
- targets = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
2778
- if (targets.length === 0) {
2779
- finish({ ok: false, error: `no directory candidate publishes team "${team}"${failures.length > 0 ? ` (${failures.slice(0, 3).join('; ')})` : ''}` });
2780
- return;
2781
- }
2782
- }
2783
- else {
2784
- finish({ ok: false, error: 'team or peer is required' });
2785
- return;
2786
- }
2787
- // 0.6.16: a team-handle read without an explicit id names a NODE, not
2788
- // a session. The handle suffix (zone/<id8>) is the first 8 hex of the
2789
- // peer-side session id (sessionTeamOf), so resolve the listing once
2790
- // and pick that session — bare zone teams fall back to the newest.
2791
- // An explicit id (peer drill) short-circuits this.
2792
- let resolvedId = id;
2793
- if (resolvedId === '' && team !== '') {
2794
- const suffix = team.includes('/') ? team.slice(team.lastIndexOf('/') + 1) : '';
2795
- for (const base of targets) {
2796
- const listUrl = new URL('/a2a/log', base);
2797
- listUrl.searchParams.set('caller_session', config.team);
2798
- try {
2799
- const listResponse = await globalThis.fetch(listUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
2800
- const listBody = await listResponse.json().catch(() => ({ ok: false, error: `peer answered HTTP ${listResponse.status}` }));
2801
- if (listResponse.ok && listBody.ok === true && Array.isArray(listBody.sessions)) {
2802
- const picked = suffix !== '' ? listBody.sessions.find(row => row.id.includes(suffix)) : listBody.sessions[0];
2803
- if (picked !== undefined) {
2804
- resolvedId = picked.id;
2805
- break;
2806
- }
2807
- }
2808
- }
2809
- catch {
2810
- // listing failed on this candidate — the tail attempt below
2811
- // will surface the honest error for the same base
2812
- }
2813
- }
2814
- if (resolvedId === '') {
2815
- finish({ ok: false, error: `no readable session on this peer matches "${team}"` });
2816
- return;
2817
- }
2818
- }
2819
- const attempts = [];
2820
- for (const base of targets) {
2821
- const target = new URL('/a2a/log', base);
2822
- target.searchParams.set('caller_session', config.team);
2823
- if (resolvedId !== '')
2824
- target.searchParams.set('id', resolvedId);
2825
- target.searchParams.set('limit', String(limit));
2826
- try {
2827
- const response = await globalThis.fetch(target, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
2828
- const body = await response.json().catch(() => ({ ok: false, error: `peer answered HTTP ${response.status}` }));
2829
- if (response.ok && body.ok === true) {
2830
- finish({ ...body, via: base });
2831
- return;
2832
- }
2833
- attempts.push(`${base}: ${body.error ?? `HTTP ${response.status}`}`);
2834
- }
2835
- catch (error) {
2836
- attempts.push(`${base}: ${error instanceof Error ? error.message : String(error)}`);
2837
- }
2838
- }
2839
- finish({ ok: false, error: `remote log unavailable — ${attempts.join(' | ')}` });
2840
- }),
2841
- }), 'a2a: remote-session-log proxy route');
2695
+ // gh#77: the session-log faces (local transcript reader + remote-log proxy) live in src/faces/session-log.ts.
2696
+ registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch });
2697
+ registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim, secretEqual, controlRoute, recordActivity });
2842
2698
  });
2843
2699
  /**
2844
2700
  * Settle one card-fetch outcome into the peer store and the plain card:
@@ -2852,7 +2708,10 @@ ${message}`;
2852
2708
  */
2853
2709
  function settlePeerCard(peer, outcome) {
2854
2710
  if (!outcome.ok) {
2855
- peerStore.noteFailure(peer);
2711
+ // gh#193: a fetch the isolation switch refused says nothing about the
2712
+ // peer — scoring it would evict learned peers and rewrite peers.json.
2713
+ if (!config.isolated)
2714
+ peerStore.noteFailure(peer);
2856
2715
  return undefined;
2857
2716
  }
2858
2717
  // A self-referral learned from a peer's card (the peer lists this node's
@@ -2919,58 +2778,24 @@ ${message}`;
2919
2778
  */
2920
2779
  const cardCache = new Map();
2921
2780
  const cardCacheInFlight = new Map();
2922
- /**
2923
- * The from-line display name (owner ruling, claim 8ea4150f94): a bare
2924
- * routing handle (`dsh/74555a36`) carries zero semantics for the reader.
2925
- * Resolve what the handle NAMES, cheaply and read-only:
2926
- * 1. a local handle — a joined/live session id, its zone/id8 pseudo-team,
2927
- * or a live node label → nodeMetadataOf(agent).name (the session-title
2928
- * product, exactly what the directory shows);
2929
- * 2. a remote bare-zone caller → the warmest cached card whose team
2930
- * equals the zone (the publisher's own card NAME is the display name);
2931
- * 3. a remote zone/id8 caller → the cached card whose sessionTeams row
2932
- * matches the handle → that row's name;
2933
- * 4. nothing resolves → '' (the from line stays bare — no false names).
2934
- * Read-only over warm caches: never fetches, so a cold cache degrades to
2935
- * the bare handle, never to a stall.
2936
- */
2937
- function callerDisplayName(caller) {
2938
- if (caller === '' || caller === 'an unknown node')
2939
- return '';
2940
- // Local: joined/live session facts on THIS host.
2941
- for (const [id, agent] of sessionNodes) {
2942
- if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
2943
- return nodeMetadataOf(agent).name;
2944
- }
2945
- }
2946
- for (const [id, agent] of liveRoots) {
2947
- if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
2948
- return nodeMetadataOf(agent).name;
2949
- }
2950
- }
2951
- // Remote: warm card cache only.
2952
- const zone = caller.includes('/') ? caller.slice(0, caller.indexOf('/')) : caller;
2953
- let zoneName = '';
2954
- for (const entry of cardCache.values()) {
2955
- const card = entry.card;
2956
- if (card === undefined)
2957
- continue;
2958
- if (card.team === zone && zoneName === '')
2959
- zoneName = card.name;
2960
- if (caller.includes('/')) {
2961
- const row = (card.sessionTeams ?? []).find(item => item.team === caller);
2962
- if (row !== undefined && row.name !== '')
2963
- return row.name;
2964
- }
2965
- }
2966
- if (!caller.includes('/') && zoneName !== '')
2967
- return zoneName;
2968
- return '';
2969
- }
2970
- /** The from clause with the display name in full-width parens when resolved. */
2781
+ // #114 S6: the change hooks and the boot observation start only once every
2782
+ // record admission reads exists (the peer-org basis reads cardCache above).
2783
+ // The cache is still empty here, so the observer skips cache-resting rows
2784
+ // on 'boot' (#138 R1); the first lazy read rechecks them.
2785
+ ctx.effect(() => canvasStore.onChange(() => observeAuthorization('canvas')), 'a2a: S6 canvas observer');
2786
+ ctx.effect(() => joinedSessions.onChange(() => observeAuthorization('joined')), 'a2a: S6 joined observer');
2787
+ ctx.effect(() => teamMemberships.onChange(() => observeAuthorization('roster')), 'a2a: S6 roster observer');
2788
+ observeAuthorization('boot');
2789
+ /** Security headers omit all unsigned peer display metadata. */
2971
2790
  function fromClause(from) {
2972
- const display = callerDisplayName(from);
2973
- return `from "${from}"${display !== '' ? `(${display})` : ''}`;
2791
+ const caller = from.replace(/[\x00-\x1f\x7f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\uff08\uff09\ufe59\ufe5a]/g, '');
2792
+ return `from ${JSON.stringify(caller)}`;
2793
+ }
2794
+ function sanitizeFromClause(from) {
2795
+ return fromClause(from);
2796
+ }
2797
+ function hostDirectHeader(team, taskId, from, proofClause) {
2798
+ return `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2974
2799
  }
2975
2800
  /**
2976
2801
  * gh#20 W-A: THIS host's view of one tracked peer's link state (the
@@ -3143,544 +2968,54 @@ ${message}`;
3143
2968
  // loud, structured, and schema-free (no success shape is produced).
3144
2969
  throw new Error('你被禁止使用 a2a 网络:本会话未加入 A2A 网络(join 是用户手势)。Join via the sidebar control to enable, then retry.');
3145
2970
  };
3146
- // S4: team-membership tools — node-declared rosters under the owner's
3147
- // allowlist curation. Joining is a network-visible declaration (the card
3148
- // publishes it), so the default allowlist denies everything: a refused
3149
- // join says why and names the curation surface instead of failing
3150
- // silently. Leaving is always allowed — it only shrinks exposure.
3151
- // S4 audit: every join/leave attempt — allowed or refused — lands in
3152
- // a bounded ring the state route serves (the ring itself is declared with
3153
- // the stores: the boot-settlement prune records before this section
3154
- // initializes). Refusals are the audit-worthy half (each one is widening
3155
- // pressure on the allowlist); log lines rotate away, this face is queryable.
3156
- // gh#11 (N7, operator directive: team creation is a human act): only an
3157
- // EXACT allowlist entry authorizes a join — each exact name is a
3158
- // deliberate operator act of creation. Wildcard/prefix patterns match
3159
- // nothing at join time: a pattern pre-authorizing every future name
3160
- // under it would let a model mint teams nobody created. Pattern entries
3161
- // remain readable (whoami shows them) but carry no mint rights.
3162
- const teamJoinExactAllowed = (team) => ownerAllowlist.includes(team);
3163
- const teamJoinPatternMatches = (team) => ownerAllowlist.some(pattern => {
3164
- if (pattern === '*')
3165
- return true;
3166
- if (pattern.endsWith('*')) {
3167
- // Boundary: the wildcard consumes at most the remainder of one
3168
- // '/'-delimited segment — `dsh*` matches `dsh/agent-1`, never
3169
- // `dshield`; `dsh/canvas/*` matches the canvas subtree only.
3170
- const prefix = pattern.slice(0, -1);
3171
- return team.startsWith(prefix) && (prefix.endsWith('/') || (team.length > prefix.length && team[prefix.length] === '/'));
3172
- }
3173
- return team === pattern;
2971
+ const SWEEP_CONCURRENCY = 6;
2972
+ const infoTools = createInfoTools({
2973
+ ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
2974
+ inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
2975
+ teamMemberships, exposureStore, idempotencyStore, cardCache,
2976
+ id8, sessionTeamOf,
2977
+ nodeMetadataOf, canvasTeamOf, memoizedCardFetch, mapBounded,
2978
+ // Hot-applied owner settings: getters keep the tools' call-time read live.
2979
+ // Passing these by value snapshots the apply-scope lets and silently
2980
+ // disables owner-settings hot-apply (whoami allowlist / teams scoping).
2981
+ get ownerAllowlist() { return ownerAllowlist; },
2982
+ get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
2983
+ teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
2984
+ settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
2985
+ SWEEP_CONCURRENCY,
3174
2986
  });
3175
- const joinedNodeOrNone = (id) => joinedSessions.has(id) || sessionNodes.has(id) ? id : undefined;
3176
- // K3 feedback P0 (2026-10-20): a session could appear in a2a_teams, route
3177
- // fine, and still be refused by a2a_team_join — with no way to ask "what am
3178
- // I, actually?". whoami is that one-call answer. Deliberately NOT gated by
3179
- // a2aJoinGateRefusal: the unjoined session is precisely the caller that
3180
- // needs it.
3181
- ctx.tools.register(defineTool({
3182
- name: 'a2a_whoami',
3183
- description: 'One-call self-introspection on this host\'s A2A network: the calling session\'s node id, joined/live '
3184
- + 'state, routable team handle, declared team memberships (roster + canvas), the owner\'s teamJoinAllowlist, '
3185
- + 'the teamScopeRouting switch, and this node\'s most recent refused join/leave attempts with reasons. '
3186
- + 'Note: outbound routing never requires join — the originating session is always exempt (initiator face); '
3187
- + 'join buys presence, discovery, and being routed to.',
3188
- parameters: {},
3189
- output: {
3190
- schema: {
3191
- type: 'object',
3192
- additionalProperties: false,
3193
- properties: {
3194
- ok: { type: 'boolean', required: true },
3195
- face: { type: 'string', required: true, description: 'session | host' },
3196
- node: {
3197
- type: 'object',
3198
- additionalProperties: true,
3199
- description: 'id, joined, live, and the routable team handle (zone/id8) once joined.',
3200
- },
3201
- teams: {
3202
- type: 'object',
3203
- additionalProperties: true,
3204
- description: 'roster: allowlisted teams declared via a2a_team_join; canvas: workbench-assigned teams.',
3205
- },
3206
- allowlist: {
3207
- type: 'object',
3208
- additionalProperties: true,
3209
- 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).',
3210
- },
3211
- teamScopeRouting: { type: 'boolean' },
3212
- note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
3213
- lastRefusals: {
3214
- type: 'array',
3215
- description: 'Most recent refused join/leave attempts for this node (newest first, ≤3), with reason codes.',
3216
- items: { type: 'object', additionalProperties: true },
3217
- },
3218
- },
3219
- },
3220
- render: (_args, value) => [{
3221
- type: 'text',
3222
- text: value.face === 'host'
3223
- ? `Host face on team ${String(value.node?.team ?? '?')}: routing rides the bare node team (能发即能收)`
3224
- : `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 ?? '?')}`,
3225
- }],
3226
- },
3227
- presentCall: () => ({ card: 'generic', title: 'A2A whoami', kind: 'other', rawInput: null }),
3228
- execute: async (_args, exec) => {
3229
- const agentId = exec?.agent !== undefined ? String(exec.agent.id) : '';
3230
- if (agentId === '') {
3231
- return {
3232
- ok: true,
3233
- face: 'host',
3234
- node: { team: config.team },
3235
- note: 'host face — routing rides the bare node team (能发即能收); joins are session-node concerns',
3236
- };
3237
- }
3238
- const joined = joinedSessions.has(agentId);
3239
- const live = sessionNodes.has(agentId);
3240
- const refusals = teamAuditRing
3241
- .filter(entry => entry.id === agentId && !entry.ok)
3242
- .slice(-3)
3243
- .reverse()
3244
- .map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
3245
- const patterns = [...ownerAllowlist];
3246
- return {
3247
- ok: true,
3248
- face: 'session',
3249
- node: {
3250
- id: agentId,
3251
- ...(joined ? { team: `${config.team}/${id8(agentId)}` } : {}),
3252
- joined,
3253
- live,
3254
- },
3255
- teams: {
3256
- roster: [...teamMemberships.teamsOf(agentId)],
3257
- canvas: [...canvasStore.teamsOf(agentId)],
3258
- },
3259
- allowlist: {
3260
- patterns,
3261
- effect: patterns.length === 0 ? 'deny-all (empty allowlist refuses every join)' : patterns.includes('*') ? 'allow-all' : 'curated',
3262
- },
3263
- teamScopeRouting: config.teamScopeRouting === true,
3264
- ...(refusals.length > 0 ? { lastRefusals: refusals } : {}),
3265
- note: joined
3266
- ? 'join buys presence + being routed to; outbound routing needs no join (initiator face is exempt)'
3267
- : '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)',
3268
- };
3269
- },
3270
- }));
3271
- ctx.tools.register(defineTool({
3272
- name: 'a2a_team_join',
3273
- description: 'Declare one of this host\'s joined session nodes a member of a routable team '
3274
- + '(node-declared roster; the host card publishes it and peers rebuild team rosters as the union of '
3275
- + 'member declarations). Gated by the owner\'s teamJoinAllowlist — an empty list denies every join; '
3276
- + 'patterns are exact names or trailing-`*` prefixes. Canvas teams are out of scope here: their '
3277
- + 'membership is assigned by hand in the A2A workbench, and the tool refuses `/canvas/` names. '
3278
- + 'Collaboration itself stays within teams (team-scoped routing is the separate teamScopeRouting switch).',
3279
- parameters: {
3280
- team: { type: 'string', description: 'The routable team to join (full form, e.g. dsh/review-gate).' },
3281
- id: { type: 'string', description: 'The joined session node id on this host that declares the membership.' },
3282
- },
3283
- output: {
3284
- schema: {
3285
- type: 'object',
3286
- additionalProperties: false,
3287
- properties: {
3288
- ok: { type: 'boolean', required: true },
3289
- team: { type: 'string' },
3290
- id: { type: 'string' },
3291
- teams: { type: 'array', items: { type: 'string' } },
3292
- error: { type: 'string', description: 'Refusal reason when ok is false: unknown node or team not allowlisted.' },
3293
- },
3294
- },
3295
- render: (_args, value) => [{
3296
- type: 'text',
3297
- text: value.ok === true
3298
- ? `Node ${String(value.id)} joined team ${String(value.team)}; its teams: ${Array.isArray(value.teams) ? value.teams.join(', ') : String(value.team)}`
3299
- : `Join refused: ${String(value.error)}`,
3300
- }],
3301
- },
3302
- presentCall: () => ({ card: 'generic', title: 'A2A team join', kind: 'other', rawInput: null }),
3303
- execute: async (args, exec) => {
3304
- a2aJoinGateRefusal(exec);
3305
- const team = String(args.team ?? '').trim();
3306
- const id = String(args.id ?? '').trim();
3307
- if (team === '' || id === '') {
3308
- const reason = '[BAD_ARGUMENTS] both team and id are required';
3309
- recordTeamAudit('join', team, id, false, reason);
3310
- return { ok: false, error: reason };
3311
- }
3312
- if (joinedNodeOrNone(id) === undefined) {
3313
- // K3 feedback (2026-10-20): reason code + a step the MODEL can take.
3314
- // The old text pointed at a sidebar control and a CLI — neither is
3315
- // executable by a session agent. Joining is a host-side human
3316
- // control; the model's executable step is to ask its operator and
3317
- // to self-check state with a2a_whoami.
3318
- const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — network joining is a host-side human control (the GUI sidebar A2A panel), so relay the request to your human operator; once the node is joined, retry this declaration. Your current state (joined/live, teams, allowlist) is one a2a_whoami call away`;
3319
- recordTeamAudit('join', team, id, false, reason);
3320
- return { ok: false, error: reason };
3321
- }
3322
- // V4 (gh#7, N2): a model tool changes membership for the CALLING
3323
- // session only — agent-1 declaring agent-2 into a team was an agent
3324
- // creating a link for another node, published to the whole fleet via
3325
- // the card. The host face (agent-less, operator surface) may still
3326
- // act for any joined node.
3327
- if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
3328
- const reason = `[NOT_SELF] a model tool may only declare membership for its own session node (${String(exec.agent.id)}) — membership for other nodes is your operator's gesture (the workbench or the host face); N2: agents use links, they do not create them for others`;
3329
- recordTeamAudit('join', team, id, false, reason);
3330
- return { ok: false, error: reason };
3331
- }
3332
- // Unified teaming (0.6.6): canvas teams are the human-composed surface.
3333
- // A session may not self-join one through the roster tool — membership
3334
- // there is assigned by hand, so the refusal names the surface where the
3335
- // assignment happens. Any `/canvas/` name (this zone's or a peer's) is
3336
- // refused: remote canvas membership is the owning host's store, and a
3337
- // roster declaration here would neither feed it nor be read by it.
3338
- if (team.includes('/canvas/')) {
3339
- const reason = `[CANVAS_TEAM] team "${team}" is a canvas team — canvas membership is assigned by hand in the A2A workbench (open /__dsh_a2a_nexus/, pick the team, add or remove members there); the roster tool manages allowlisted teams only. If this membership must change now, relay the request to your human operator`;
3340
- recordTeamAudit('join', team, id, false, reason);
3341
- return { ok: false, error: reason };
3342
- }
3343
- if (!teamJoinExactAllowed(team)) {
3344
- const reason = teamJoinPatternMatches(team)
3345
- ? `[PATTERN_NO_MINT] team "${team}" matches only a wildcard/pattern entry of this host's teamJoinAllowlist — patterns carry no mint rights (gh#11/N7: team creation is a human act); ask the owner to add the EXACT team name, which is the act of creating it`
3346
- : `[ALLOWLIST_DENY] team "${team}" is not in this host's teamJoinAllowlist — the owner curates joinable teams with exact names (patterns match nothing at join time; human-composed groups live in the workbench's canvas teams); ask them to add the exact name. The live list is visible in a2a_whoami`;
3347
- recordTeamAudit('join', team, id, false, reason);
3348
- return { ok: false, error: reason };
3349
- }
3350
- teamMemberships.add(id, team);
3351
- recordTeamAudit('join', team, id, true);
3352
- logger.info(`a2a: ${id8(id)} declared membership in ${team}`);
3353
- return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
3354
- },
3355
- }));
3356
- ctx.tools.register(defineTool({
3357
- name: 'a2a_team_leave',
3358
- description: 'Retract one team-membership declaration previously made by a2a_team_join. Always allowed — leaving only '
3359
- + 'shrinks the node\'s visible collaboration surface.',
3360
- parameters: {
3361
- team: { type: 'string', description: 'The routable team to leave.' },
3362
- id: { type: 'string', description: 'The session node id retracting the membership.' },
3363
- },
3364
- output: {
3365
- schema: {
3366
- type: 'object',
3367
- additionalProperties: false,
3368
- properties: {
3369
- ok: { type: 'boolean', required: true },
3370
- team: { type: 'string' },
3371
- id: { type: 'string' },
3372
- teams: { type: 'array', items: { type: 'string' } },
3373
- error: { type: 'string', description: 'Refusal reason when ok is false: node unknown or membership absent.' },
3374
- },
3375
- },
3376
- render: (_args, value) => [{
3377
- type: 'text',
3378
- text: value.ok === true
3379
- ? `Node ${String(value.id)} left team ${String(value.team)}; remaining teams: ${Array.isArray(value.teams) && value.teams.length > 0 ? value.teams.join(', ') : 'none'}`
3380
- : `Leave refused: ${String(value.error)}`,
3381
- }],
3382
- },
3383
- presentCall: () => ({ card: 'generic', title: 'A2A team leave', kind: 'other', rawInput: null }),
3384
- execute: async (args, exec) => {
3385
- a2aJoinGateRefusal(exec);
3386
- const team = String(args.team ?? '').trim();
3387
- const id = String(args.id ?? '').trim();
3388
- if (team === '' || id === '') {
3389
- recordTeamAudit('leave', team, id, false, 'both team and id are required');
3390
- return { ok: false, error: 'both team and id are required' };
3391
- }
3392
- if (joinedNodeOrNone(id) === undefined) {
3393
- const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — check current state with a2a_whoami`;
3394
- recordTeamAudit('leave', team, id, false, reason);
3395
- return { ok: false, error: reason };
3396
- }
3397
- // V4 (gh#7, N2): same self-only rule as join — narrowing another
3398
- // node's surface is still a link change by a third party.
3399
- if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
3400
- const reason = `[NOT_SELF] a model tool may only retract membership for its own session node (${String(exec.agent.id)}); N2 — use links, do not reshape another node's`;
3401
- recordTeamAudit('leave', team, id, false, reason);
3402
- return { ok: false, error: reason };
3403
- }
3404
- if (!teamMemberships.teamsOf(id).includes(team)) {
3405
- const reason = `session ${id} declares no membership in "${team}" — nothing to leave`;
3406
- recordTeamAudit('leave', team, id, false, reason);
3407
- return { ok: false, error: reason };
3408
- }
3409
- teamMemberships.remove(id, team);
3410
- recordTeamAudit('leave', team, id, true);
3411
- logger.info(`a2a: ${id8(id)} retracted membership in ${team}`);
3412
- return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
3413
- },
3414
- }));
3415
- ctx.tools.register(defineTool({
3416
- name: 'a2a_exposure',
3417
- description: '#19 exposure tiers: set THIS session\'s network visibility. \'linked\' (the default '
3418
- + 'floor) — visible to linked callers per the card-face link gate; \'fleet\' — a voluntary '
3419
- + 'opt-UP making the session\'s team visible to any fleet peer. Members may raise and lower '
3420
- + 'their own tier freely; nothing exists below linked (no private tier in v1), and group '
3421
- + 'visibility always derives from the strictest member (no group knobs). Requires the '
3422
- + 'session to be joined to the network.',
3423
- parameters: {
3424
- tier: { type: 'string', enum: ['linked', 'fleet'], required: true, description: 'The exposure tier to set for this session.' },
3425
- },
3426
- output: {
3427
- schema: {
3428
- type: 'object',
3429
- additionalProperties: false,
3430
- properties: {
3431
- ok: { type: 'boolean', required: true },
3432
- id: { type: 'string' },
3433
- tier: { type: 'string' },
3434
- error: { type: 'string', description: 'Refusal reason when ok is false (not joined, or no agent context).' },
3435
- },
3436
- },
3437
- render: (_args, value) => [{
3438
- type: 'text',
3439
- text: value.ok === true
3440
- ? `Session ${String(value.id)} exposure tier set to ${String(value.tier)}`
3441
- : `Exposure refused: ${String(value.error)}`,
3442
- }],
3443
- },
3444
- presentCall: () => ({ card: 'generic', title: 'A2A exposure tier', kind: 'other', rawInput: null }),
3445
- execute: async (args, exec) => {
3446
- const agent = exec.agent;
3447
- if (agent === undefined) {
3448
- return { ok: false, error: 'no agent context — the exposure tier is a per-session fact; call from a session' };
3449
- }
3450
- const id = String(agent.id);
3451
- if (!joinedSessions.has(id)) {
3452
- return { ok: false, error: 'session is not joined to the A2A network — join first (the sidebar control or the host join route)' };
3453
- }
3454
- const tier = args.tier === 'fleet' ? 'fleet' : 'linked';
3455
- exposureStore.setTier(id, tier);
3456
- logger.info(`a2a: ${id8(id)} exposure tier -> ${tier}`);
3457
- return { ok: true, id, tier };
3458
- },
3459
- }));
3460
- ctx.tools.register(defineTool({
3461
- name: 'a2a_report',
3462
- description: 'Report a task or status to this host\'s A2A network — the ONE ingress a session WITHOUT network '
3463
- + 'membership may use (deliberately exempt from the join gate: one-way reporting grants no presence, '
3464
- + 'no discovery, no routing rights; the report lands in the state face\'s reports ring for '
3465
- + 'supervision to read and act on, e.g. adopt or re-join the reporter). Use when you are off the '
3466
- + 'mesh and need to say so. Rate limited to one report per 30s per session; messages cap at 4000 chars.',
3467
- parameters: {
3468
- message: { type: 'string', required: true, description: 'The report text (≤4000 chars).' },
3469
- level: { type: 'string', enum: ['info', 'warning', 'error'], description: 'Severity; error also warns the host log.' },
3470
- task: { type: 'string', description: 'Optional task reference (id/link).' },
3471
- },
3472
- output: {
3473
- schema: {
3474
- type: 'object',
3475
- additionalProperties: false,
3476
- properties: {
3477
- ok: { type: 'boolean', required: true },
3478
- reportId: { type: 'string' },
3479
- error: { type: 'string', description: 'Refusal reason when ok is false (empty message, over-cap, or rate limited).' },
3480
- },
3481
- },
3482
- render: (_args, value) => [{
3483
- type: 'text',
3484
- text: value.ok === true ? `Report registered (${String(value.reportId)})` : `Report refused: ${String(value.error)}`,
3485
- }],
3486
- },
3487
- presentCall: () => ({ card: 'generic', title: 'A2A report', kind: 'other', rawInput: null }),
3488
- execute: async (args, exec) => {
3489
- // Deliberately NO a2aJoinGateRefusal: reporting is the one thing an
3490
- // unjoined session may do — that is the tool's entire purpose.
3491
- const message = String(args.message ?? '').trim();
3492
- if (message === '')
3493
- return { ok: false, error: 'message is required' };
3494
- if (message.length > 4000)
3495
- return { ok: false, error: 'message exceeds the 4000-char cap' };
3496
- const session = exec?.agent !== undefined ? String(exec.agent.id) : 'host';
3497
- const now = Date.now();
3498
- const last = reportLastAt.get(session) ?? 0;
3499
- if (now - last < 30_000)
3500
- return { ok: false, error: 'rate limited: one report per 30s per session' };
3501
- // Fix ④ (loop circuit-breaker, d44efa9f pattern): the SAME session
3502
- // reporting the SAME text ≥3 times inside a 10-minute window is a
3503
- // loop, not a situation — refuse with a named reason and audit the
3504
- // ring (the first copies go through; the bounded ring self-expires).
3505
- const loopKey = `${session}:${createHash('sha256').update(message).digest('hex').slice(0, 16)}`;
3506
- const loopWindow = (reportLoopRing.get(loopKey) ?? []).filter(at => now - at < 600_000);
3507
- if (loopWindow.length >= 3) {
3508
- recordTeamAudit('agent-less', 'a2a_report', session, false, 'loop detected: 3 identical reports in 10min');
3509
- return { ok: false, error: '循环检测:同会话同文 10 分钟内已上报 3 次,本 report 熔断拒绝。若确需再报请变更内容或等窗口滑出' };
3510
- }
3511
- loopWindow.push(now);
3512
- reportLoopRing.set(loopKey, loopWindow);
3513
- reportLastAt.set(session, now);
3514
- const level = args.level === 'error' || args.level === 'warning' ? args.level : 'info';
3515
- const reportId = `report-${Math.random().toString(16).slice(2, 10)}`;
3516
- reportRing.push({
3517
- at: now,
3518
- level,
3519
- message: message.slice(0, 4000),
3520
- session,
3521
- ...(typeof args.task === 'string' && args.task.trim() !== '' ? { task: args.task.trim() } : {}),
3522
- });
3523
- if (reportRing.length > 64)
3524
- reportRing.splice(0, reportRing.length - 64);
3525
- if (level === 'error')
3526
- logger.warn(`a2a report (${id8(session)}): ${message.slice(0, 200)}`);
3527
- return { ok: true, reportId };
3528
- },
3529
- }));
3530
- // S5 (owner directive 2026-10-20): information-plane scoping. The host's
3531
- // joined-team scope is the union of every local node's roster
3532
- // declarations — a host-level set, not per-session: supervisor seats need
3533
- // the cross-team overview, and 「加入的团队」 reads as the teams this
3534
- // host's nodes joined. The information-plane tools (a2a_teams /
3535
- // a2a_status / a2a_probe) disclose remote facts only inside that scope;
3536
- // a2a_tasks — this host's own dispatch ledger and receipt-reconciliation
3537
- // surface, including initiator-exempt dispatches — stays unscoped by
3538
- // ruling (its rows name teams this host itself routed to, never a fleet
3539
- // census).
3540
- const joinedTeamScope = () => {
3541
- const joined = new Set();
3542
- for (const entry of teamMemberships.list()) {
3543
- for (const team of entry.teams)
3544
- joined.add(team);
3545
- }
3546
- // G4 (review artifact 688c0354d3 v2): canvas membership is join intent
3547
- // too. This host's canvas teams — and every member handle they name
3548
- // (remote zone-qualified handles as-is, local session ids as their
3549
- // session-team handle) — belong to the scope, so canvas teammates stay
3550
- // visible to each other under teamScopeInfo instead of the roster-only
3551
- // reading splitting them apart.
3552
- for (const name of canvasStore.list()) {
3553
- joined.add(canvasTeamOf(name));
3554
- for (const member of canvasStore.membersOf(name)) {
3555
- joined.add(member.includes('/') ? member : `${config.team}/${id8(member)}`);
3556
- }
3557
- }
3558
- return joined;
3559
- };
2987
+ infoTools.registerWhoami();
2988
+ const actTools = createActTools({
2989
+ ctx, config, logger, sessionNodes, joinedSessions,
2990
+ teamMemberships, exposureStore, reportRing, recordTeamAudit,
2991
+ a2aJoinGateRefusal, id8, routeTrunk,
2992
+ // Owner-settings hot-apply REBINDS this let: the getter keeps the tool
2993
+ // module's call-time read live instead of snapshotting at construction.
2994
+ get ownerAllowlist() { return ownerAllowlist; },
2995
+ });
2996
+ actTools.registerTeamJoin();
2997
+ actTools.registerTeamLeave();
2998
+ actTools.registerExposure();
2999
+ actTools.registerReport();
3000
+ infoTools.registerTeams();
3560
3001
  /**
3561
- * Whether a peer card proves service to any joined team — its own zone
3562
- * team, a published session team, or any roster declaration. A card that
3563
- * proves nothing serves nothing: under scoping it is withheld, which is
3564
- * the legacy-peer cost the directive accepts (pre-roster peers cannot
3565
- * prove service either way).
3002
+ * The directory's listing: this host's own teams lead (the loopback
3003
+ * candidates a2a_route dials first) — the process team, then every joined
3004
+ * live session node with its title and activity facts; then every tracked
3005
+ * peer card's own team plus its joined session teams. Delegated names are
3006
+ * not listed — only names a tracked zone publishes directly appear;
3007
+ * delegations resolve at route time (a resolvable delegation always
3008
+ * aliases a listed team, so rows for them would repeat).
3009
+ *
3010
+ * Discovery is gossip: each fetch offers the card's referrals to the
3011
+ * store, and mid-iteration offers are normally fetched on the next call.
3012
+ * A search pass chases one extra hop within the call — after the first
3013
+ * sweep, peers that were newly offered get their own card fetched — so a
3014
+ * keyword hunt reaches sessions the previous call had not yet gossiped,
3015
+ * while staying bounded (one extra sweep, still under the store cap).
3016
+ * @param expand - chase one referral hop beyond the current store walk.
3566
3017
  */
3567
- const cardServesJoined = (card, joined) => joined.has(card.team)
3568
- || (card.sessionTeams ?? []).some(entry => joined.has(entry.team))
3569
- || (card.teamMemberships ?? []).some(membership => joined.has(membership.team));
3570
- ctx.tools.register(defineTool({
3571
- name: 'a2a_teams',
3572
- description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
3573
- + 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
3574
- + 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
3575
- + 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
3576
- + 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
3577
- + 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
3578
- + 'list as local rows; routing to one resolves the first live member or wakes the first cold one — the dispatching session '
3579
- + 'itself is skipped on the local tiers, falling through to the other members (issue #41: a member routing to its own group '
3580
- + 'never loops home). '
3581
- + 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote rows serve only teams joined by this host\'s nodes, 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. '
3582
- + 'Call this before a2a_route to pick a target team.',
3583
- parameters: {
3584
- query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
3585
- },
3586
- output: {
3587
- schema: {
3588
- type: 'object',
3589
- additionalProperties: false,
3590
- properties: {
3591
- ok: { type: 'boolean', required: true },
3592
- query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
3593
- teams: {
3594
- type: 'array',
3595
- required: true,
3596
- items: {
3597
- type: 'object',
3598
- additionalProperties: false,
3599
- properties: {
3600
- team: { type: 'string', required: true },
3601
- session: { type: 'string', required: true },
3602
- name: { type: 'string', required: true },
3603
- description: { type: 'string', required: true },
3604
- local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
3605
- origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
3606
- via: { type: 'string', description: 'The peer URL this row was discovered through (host:port), when it came from a peer card.' },
3607
- workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
3608
- legacy: { type: 'boolean', description: 'true when the publishing node advertises no roster capability (pre-roster peer).' },
3609
- teams: { type: 'array', items: { type: 'string' }, description: 'Roster teams the node declared for this handle (card teamMemberships).' },
3610
- },
3611
- },
3612
- },
3613
- 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).' },
3614
- },
3615
- },
3616
- render: (args, value) => [{
3617
- type: 'text',
3618
- text: (value.teams.length === 0
3619
- ? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
3620
- : 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'))
3621
- + (value.note === undefined ? '' : `\n${value.note}`),
3622
- }],
3623
- },
3624
- presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
3625
- execute: async (args, exec) => {
3626
- a2aJoinGateRefusal(exec);
3627
- const query = args.query?.trim().toLowerCase() ?? '';
3628
- let teams = await listDirectoryTeams(query !== '');
3629
- // S5: remote rows survive only when they serve a joined team (the
3630
- // row's own name or any roster declaration it carries); local rows
3631
- // are host-internal facts and always survive. The note keeps the
3632
- // model from reading a scoped listing as an empty fleet.
3633
- let note;
3634
- if (ownerTeamScopeInfo) {
3635
- const joined = joinedTeamScope();
3636
- const remoteBefore = teams.filter(row => row.local !== true).length;
3637
- teams = teams.filter(row => row.local === true || joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name)));
3638
- const hiddenRemote = remoteBefore - teams.filter(row => row.local !== true).length;
3639
- // gh#39 (owner ruling: the listing ADVERTISES targets — an exposed
3640
- // node id invites a session with no collaboration relationship to
3641
- // route at it): [this host] session rows follow the CALLER's
3642
- // collaboration circle. The host's own GUI/panel keeps the full
3643
- // plane (the owner's eyes); the model-tool face is scoped.
3644
- let hiddenLocal = 0;
3645
- const callerAgent = exec.agent;
3646
- if (callerAgent !== undefined) {
3647
- const callerId = String(callerAgent.id);
3648
- const ownHandle = sessionTeamOf(callerAgent);
3649
- const myCanvas = new Set(canvasStore.teamsOf(callerId));
3650
- const myRoster = new Set(teamMemberships.teamsOf(callerId));
3651
- const before = teams.length;
3652
- teams = teams.filter(row => {
3653
- if (row.local !== true)
3654
- return true;
3655
- if (row.team === config.team || row.team === ownHandle)
3656
- return true;
3657
- if (row.team.startsWith(`${config.team}/canvas/`))
3658
- return myCanvas.has(row.name);
3659
- // Another session's team row: visible only when we share a
3660
- // canvas or a declared team with that session.
3661
- const targetId8 = row.team.slice(config.team.length + 1);
3662
- const target = [...sessionNodes.values()].find(agent => id8(String(agent.id)) === targetId8);
3663
- if (target === undefined)
3664
- return true; // unresolvable: keep (host fact)
3665
- const targetId = String(target.id);
3666
- const shares = [...canvasStore.teamsOf(targetId)].some(name => myCanvas.has(name))
3667
- || [...teamMemberships.teamsOf(targetId)].some(name => myRoster.has(name));
3668
- return shares;
3669
- });
3670
- hiddenLocal = before - teams.length;
3671
- }
3672
- const hidden = hiddenRemote + hiddenLocal;
3673
- 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`;
3674
- }
3675
- if (query === '')
3676
- return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
3677
- const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
3678
- || team.session.toLowerCase().includes(query)
3679
- || (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query) || (team.via ?? '').toLowerCase().includes(query);
3680
- return { ok: true, query, teams: teams.filter(matches), ...(note !== undefined ? { note } : {}) };
3681
- },
3682
- }));
3683
- async function listDirectoryTeams(expand) {
3018
+ async function listDirectoryTeams(expand, includeCanvas = false) {
3684
3019
  const localOrigin = lanIp === '' ? `${session} [this host]` : `${session} [this host, ${lanIp}]`;
3685
3020
  const teams = [
3686
3021
  { team: config.team, session, name: config.agentName, description: '', local: true, origin: localOrigin },
@@ -3695,7 +3030,10 @@ ${message}`;
3695
3030
  // count are the facts a caller needs before routing — resolution picks
3696
3031
  // the first live member at route time.
3697
3032
  ...canvasStore.list().map(name => {
3698
- const members = canvasStore.membersOf(name);
3033
+ // gh#152: members as the local tiers read them (gh#131) — a handle
3034
+ // naming one of this host's own sessions counts as that member, and
3035
+ // the two stored shapes of one session collapse to one.
3036
+ const members = [...new Set(canvasLocalMembersOf(name))];
3699
3037
  const live = members.filter(id => sessionNodes.has(id)).length;
3700
3038
  return {
3701
3039
  team: canvasTeamOf(name),
@@ -3728,35 +3066,12 @@ ${message}`;
3728
3066
  const card = await fetch(peer);
3729
3067
  if (card === undefined)
3730
3068
  return [];
3731
- // The origin fields (publisher session label + LAN IP) are natural
3732
- // grouping dimensions: a fleet's rows cluster by the host that owns
3733
- // them, no manual grouping required.
3734
- const origin = card.lanIp !== undefined ? `${card.session} (${card.lanIp})` : card.session;
3735
- // via = the peer URL the card was fetched from: the panel shows which
3736
- // host:port each remote row came from, so an unknown node is traceable
3737
- // to its publishing endpoint at a glance.
3738
- // legacy: the publisher's card carries no roster capability — a
3739
- // pre-roster node (0.5.43-). Rows from it are marked so the fleet
3740
- // audit (and the phase-2 teamed-only default flip) knows which peers
3741
- // never grow the roster face.
3742
- const legacy = card.capabilities?.roster !== true;
3743
- const rows = [
3744
- { team: card.team, session: card.session, name: card.name, description: '', origin, via: peer, ...(legacy ? { legacy } : {}) },
3745
- ];
3746
- // Roster reader half: the card's unsigned teamMemberships map each
3747
- // published session team to its declared roster teams, so a remote
3748
- // node's teamed/teamless state is visible without visiting its host.
3749
- const rosterOf = new Map();
3750
- for (const membership of card.teamMemberships ?? []) {
3751
- const list = rosterOf.get(membership.node) ?? [];
3752
- list.push(membership.team);
3753
- rosterOf.set(membership.node, list);
3754
- }
3755
- for (const entry of card.sessionTeams ?? []) {
3756
- const declared = rosterOf.get(entry.team);
3757
- rows.push({ team: entry.team, session: card.session, name: entry.name, description: entry.description, origin, via: peer, ...(entry.workspace !== undefined ? { workspace: entry.workspace } : {}), ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}) });
3758
- }
3759
- return rows;
3069
+ // Keep the GUI's remote node projection session-only; the model tool
3070
+ // opts into the additional canvas-team rows (#159).
3071
+ const rows = peerDirectoryRows(card, peer, includeCanvas);
3072
+ // gh#106: a card kept through a miss still lists (until its own
3073
+ // expiresAt), but the row says the peer did not answer the last read.
3074
+ return cardCache.get(peer)?.missAt !== undefined ? rows.map(row => ({ ...row, unreachable: true })) : rows;
3760
3075
  };
3761
3076
  // Bounded-concurrency sweep (mapBounded keeps row order by index, so the
3762
3077
  // listing still follows the peer store's preference order): a cold cache
@@ -3777,25 +3092,6 @@ ${message}`;
3777
3092
  }
3778
3093
  return teams;
3779
3094
  }
3780
- /**
3781
- * Whether one caller label can receive network traffic back. Composite
3782
- * `<zone>/<id>` shapes route anywhere (N1); bare zone names resolve to a
3783
- * live initiator per host, so cross-host they would steer whichever node
3784
- * shares the name — treated as unroutable everywhere.
3785
- */
3786
- function routableCallerLabel(callerSession) {
3787
- return callerSession !== undefined &&
3788
- (/[a-z0-9-]+\/[0-9a-f]{8}$/i.test(callerSession) || /[a-z0-9-]+\/[a-z0-9]{4,}$/i.test(callerSession));
3789
- }
3790
- /**
3791
- * Why an async route from such a caller cannot expect its receipt: the
3792
- * autosend arm skips it and the steered hint is omitted, so the debt has
3793
- * no paying path — say so at dispatch time instead of silently stranding
3794
- * a pending row.
3795
- */
3796
- function unroutableCallerNote(label) {
3797
- return `\n(Note: caller label "${label}" is not network-routable, so the target cannot auto-send a receipt back to you — reconcile via context_id follow-ups instead.)`;
3798
- }
3799
3095
  /**
3800
3096
  * Track one routed task in the ledger when its result leaves it owed a
3801
3097
  * receipt: ONLY an explicitly async dispatch (fire-and-forget) owes one —
@@ -3805,8 +3101,38 @@ ${message}`;
3805
3101
  * decision seat: sync Q&A must not enter pending; only real
3806
3102
  * receipt-awaiting rows stay on the book). A synchronous completion
3807
3103
  * already carries its answer and owes nothing.
3104
+ * #114 S3: `sender` is the real originator the row is attributed to.
3105
+ */
3106
+ /**
3107
+ * #139: what an in-process async delivery learned about its own birth —
3108
+ * the admission it ran under (design §2.4) and the member that received it
3109
+ * — handed from dispatchLocalCandidate to the caller's outbound row, keyed
3110
+ * by task id and taken (deleted) by whoever dispatched.
3808
3111
  */
3809
- const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief) => {
3112
+ const localBirths = new Map();
3113
+ const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
3114
+ const admission = localCandidateAdmission(admissionRecords, callerSession, team);
3115
+ const caller = callerSession ?? session;
3116
+ const auth = admission.ok && caller !== ''
3117
+ ? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
3118
+ : undefined;
3119
+ return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3120
+ };
3121
+ /**
3122
+ * #139: the peer facts a remote outbound row is born with — the signed
3123
+ * card's key and session label (the host identity whatever spelling of its
3124
+ * address dialed it) and, for a canvas row, the member handle the request
3125
+ * carried. The memo makes the card read free after the directory walk.
3126
+ */
3127
+ const peerBirth = async (fetch, candidate, member) => {
3128
+ const card = await fetch(candidate).catch(() => undefined);
3129
+ return {
3130
+ ...(card !== undefined && card.publicKey !== '' ? { peerKey: card.publicKey } : {}),
3131
+ ...(card !== undefined && card.session !== '' ? { peerHost: card.session } : {}),
3132
+ ...(member !== undefined ? { deliveredTo: member } : {}),
3133
+ };
3134
+ };
3135
+ const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
3810
3136
  if (!result.ok)
3811
3137
  return;
3812
3138
  // Native-teams bridge rounds emit no A2A receipt in this slice: booking
@@ -3822,10 +3148,10 @@ ${message}`;
3822
3148
  // row whose only exit was the dead-letter sweep (57 dead letters, half
3823
3149
  // this shape).
3824
3150
  if (callbackAddress === undefined) {
3825
- taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
3151
+ taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
3826
3152
  return;
3827
3153
  }
3828
- taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
3154
+ taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
3829
3155
  };
3830
3156
  /**
3831
3157
  * Build the -32004 verdict. The error string is the calling model's ONLY
@@ -3844,6 +3170,9 @@ ${message}`;
3844
3170
  * routable; the dispatch registers receiptless per fix 2).
3845
3171
  */
3846
3172
  const routeVerdict = (team, failures, candidateCount) => {
3173
+ const ambiguous = failures.find(failure => failure.startsWith('ambiguous: '));
3174
+ if (candidateCount === 0 && ambiguous !== undefined)
3175
+ return { ok: false, error: ambiguous, code: -32004 };
3847
3176
  if (candidateCount > 0) {
3848
3177
  return {
3849
3178
  ok: false,
@@ -3872,49 +3201,11 @@ ${message}`;
3872
3201
  ].filter((line) => line !== undefined);
3873
3202
  return { ok: false, error: lines.join(' — '), code: -32004 };
3874
3203
  };
3875
- ctx.tools.register(defineTool({
3876
- name: 'a2a_route',
3877
- description: 'Route a message to a remote A2A team on the peer network and await its reply. '
3878
- + 'Pick the team with a2a_teams first. Team names also resolve through peers\' signed zone '
3879
- + 'delegation records (zone-relative, at most 5 hops). Reuse context_id from a previous reply '
3880
- + 'to continue that conversation. The call blocks until the remote team responds, which may take minutes — '
3881
- + 'pass async for long-running tasks: delivery returns immediately and the target routes a receipt '
3882
- + '(message starting "[A2A receipt] task <task_id> <outcome summary>") back to your team.',
3883
- parameters: {
3884
- team: { type: 'string', required: true, description: 'Target team name, from a2a_teams.' },
3885
- message: { type: 'string', required: true, description: 'The request text to send. For long verdict-style payloads, split into parts and label each "1/N", "2/N"… so the receiver can reassemble.' },
3886
- context_id: { type: 'string', description: 'Context id from a previous a2a_route reply, to continue that conversation.' },
3887
- 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).' },
3888
- },
3889
- output: {
3890
- schema: {
3891
- type: 'object',
3892
- additionalProperties: true,
3893
- },
3894
- render: renderRoute,
3895
- },
3896
- timeoutMs: config.routeTimeoutMs,
3897
- presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
3898
- execute: async (args, exec) => {
3899
- // Owner ruling (tool-layer interception): the PROCESS face (bare
3900
- // zone === config.team) keeps its host-level EXEMPTION on the network
3901
- // admission layer — receipts, quick dispatch and host-side routing
3902
- // still target it internally — but a MODEL TOOL call must not address
3903
- // it: the machine's own mailbox is not a member face, and letting a
3904
- // session route there would turn the host entrance into a group-free
3905
- // hop. Host-face entry (agent-less control routes, receipt ladders)
3906
- // never passes through this tool and stays untouched.
3907
- const requestedTeam = String(args.team ?? '').trim();
3908
- if (exec.agent !== undefined && requestedTeam === config.team) {
3909
- return {
3910
- ok: false,
3911
- error: `the process face "${config.team}" is the host's own mailbox — model tools must target a member face. Route to a session team handle ("${config.team}/<id8>" from a2a_teams) or a canvas team; receipts and quick dispatch reach the host face on their own paths.`,
3912
- code: -32000,
3913
- };
3914
- }
3915
- return routeTrunk(args, exec.agent, exec.signal);
3916
- },
3917
- }));
3204
+ actTools.registerRoute();
3205
+ // #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
3206
+ const fileTools = createFileTools({ ctx, config, blobStore, canvasMembersOf: name => canvasStore.membersOf(name), ownCardUrl: () => ownCardUrl, sessionTeamOf, a2aJoinGateRefusal, routeTrunk, peerFetch, originClaimHeaders });
3207
+ fileTools.registerFileSend();
3208
+ fileTools.registerFileFetch();
3918
3209
  /**
3919
3210
  * The routing trunk (0.6.9): the one failover sequence behind both the
3920
3211
  * a2a_route tool (agent face) and the quick-dispatch control route (host
@@ -3952,6 +3243,7 @@ ${message}`;
3952
3243
  // calls (no agent)
3953
3244
  // and the initiator face stay exempt, like the join gate; discovery
3954
3245
  // (a2a_teams) stays open so a teamless node can still see what to join.
3246
+ let scopedCanvasCaller;
3955
3247
  if (config.teamScopeRouting && agent !== undefined) {
3956
3248
  const scopeCaller = String(agent.id);
3957
3249
  let initiator = false;
@@ -3962,6 +3254,7 @@ ${message}`;
3962
3254
  if (!initiator) {
3963
3255
  const callerTeams = teamMemberships.teamsOf(scopeCaller);
3964
3256
  const team = String(args.team ?? '').trim();
3257
+ const sharedRoster = callerTeams.some(roster => teamMemberships.membersOf(roster).some(id => `${config.team}/${id8(id)}` === team));
3965
3258
  // gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
3966
3259
  // basis (an org entry naming BOTH the caller and the target
3967
3260
  // handle) count as declared here too — the same link the
@@ -3970,6 +3263,7 @@ ${message}`;
3970
3263
  // Canvas members mix two shapes: LOCAL sessions sit by session
3971
3264
  // id, cross-host members by zone/id8 handle — match either.
3972
3265
  const callerHandle = `${config.team}/${id8(scopeCaller)}`;
3266
+ scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3973
3267
  // gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
3974
3268
  // (`<team>/canvas/<name>`) never matches member handles — a
3975
3269
  // whole-group dispatch from a group member was refused. The
@@ -3980,6 +3274,11 @@ ${message}`;
3980
3274
  const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
3981
3275
  return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
3982
3276
  });
3277
+ // A direct group route must not require a prior directory call. Fetch
3278
+ // only the same eligible peers as resolution; then apply the
3279
+ // unchanged sender/receiver admission rules to their verified cards.
3280
+ if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
3281
+ await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
3983
3282
  let orgShared = false;
3984
3283
  for (const cached of cardCache.values()) {
3985
3284
  const org = cached.card?.canvasOrg;
@@ -3997,17 +3296,17 @@ ${message}`;
3997
3296
  if (orgShared)
3998
3297
  break;
3999
3298
  }
4000
- if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
4001
- return {
3299
+ if (callerTeams.length === 0 && !sharedRoster && !sharedCanvas && !orgShared) {
3300
+ return localRoute({
4002
3301
  ok: false,
4003
3302
  error: `team-scoped routing is on and this session (${id8(scopeCaller)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)`,
4004
- };
3303
+ });
4005
3304
  }
4006
- if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
4007
- return {
3305
+ if (!callerTeams.includes(team) && !sharedRoster && !sharedCanvas && !orgShared) {
3306
+ return localRoute({
4008
3307
  ok: false,
4009
3308
  error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${callerTeams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)`,
4010
- };
3309
+ });
4011
3310
  }
4012
3311
  }
4013
3312
  }
@@ -4032,21 +3331,39 @@ ${message}`;
4032
3331
  const callbackAddress = routableCallerLabel(callerSession) || callerSession === config.team
4033
3332
  ? callerSession
4034
3333
  : undefined;
3334
+ // #114 S3: the outbound row records who really sent it instead of
3335
+ // every row reading as "home". A session is always its own node team
3336
+ // (<zone>/<id8>) — joined or not, initiator or not — so one seat keeps
3337
+ // one bucket. An agent-less call is the host process itself; the label
3338
+ // it borrowed on the wire (quick dispatch rides a live joined session's
3339
+ // team) is kept apart as `face`, so a human's workbench gesture never
3340
+ // lands in that session's seat.
3341
+ const sender = agent !== undefined
3342
+ ? { kind: 'session', id: sessionTeamOf(agent) }
3343
+ : { kind: 'host', id: session, ...(callerSession !== undefined && callerSession !== session ? { face: callerSession } : {}) };
4035
3344
  const fetch = memoizedCardFetch();
4036
3345
  // The task id is born at the caller (idempotency key semantics): both
4037
3346
  // dispatchers carry the one id, the peer request echoes it, and the
4038
3347
  // steered receipt header names it — so "[A2A receipt] task <id>" from
4039
3348
  // the target correlates with the route's own result verbatim.
4040
3349
  const taskId = `direct-${Math.random().toString(16).slice(2, 10)}`;
3350
+ const routedReceipt = parseReceipt(args.message) !== null;
4041
3351
  // Candidate order: this host's own teams first (an in-process steer —
4042
3352
  // cheapest possible and immune to nested-signal aborts), then direct
4043
3353
  // publishers, then zone delegations, deduplicated by URL.
4044
3354
  const failures = [];
3355
+ if (signal?.aborted)
3356
+ return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
4045
3357
  const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3358
+ const localBirth = localBirths.get(taskId);
3359
+ localBirths.delete(taskId);
4046
3360
  if (outcome !== undefined) {
4047
3361
  if (outcome.ok) {
4048
- trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message));
4049
- return outcome;
3362
+ // gh#141: a receipt owes no receipt — booking it would strand a row nothing settles.
3363
+ if (!routedReceipt)
3364
+ trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message), sender, localBirth);
3365
+ // gh#198: host-built delivery notes, or a reply from a clean local session (resolved as routeIntoAgent does), are this host's alone.
3366
+ return ((outcome.bridge === undefined && (outcome.task_status === 'TASK_STATE_DELIVERED' || outcome.task_status === 'TASK_STATE_ABORTED_WAIT' || (t => t !== undefined && nodeTaint.of(String(t.id)) === '')(resolveAgentForTeam(args.team) ?? canvasLiveAgent(parseCanvasTeamName(args.team))))) ? localRoute(outcome) : outcome);
4050
3367
  }
4051
3368
  failures.push(`local: ${outcome.error}`);
4052
3369
  }
@@ -4059,7 +3376,8 @@ ${message}`;
4059
3376
  // unchanged. The ledger stays keyed to the canvas team the caller
4060
3377
  // named; the peer request carries the member handle it must resolve.
4061
3378
  const canvasName = parseCanvasTeamName(args.team);
4062
- const remoteTargets = canvasName !== undefined ? canvasStore.remoteMembersOf(canvasName) : [];
3379
+ // gh#131: one of this host's own handles is a local member, never a directory target.
3380
+ const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
4063
3381
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
4064
3382
  if (remoteTargets.length > 0) {
4065
3383
  failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
@@ -4073,7 +3391,7 @@ ${message}`;
4073
3391
  }
4074
3392
  const candidatePool = [];
4075
3393
  for (const target of targets) {
4076
- for (const candidate of await directoryPeerCandidates(fetch, target, failures)) {
3394
+ for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
4077
3395
  candidatePool.push({ candidate, target });
4078
3396
  }
4079
3397
  }
@@ -4094,8 +3412,13 @@ ${message}`;
4094
3412
  }
4095
3413
  const result = await dispatchPeerCandidate(candidate, target, args.message, args.context_id, signal, callerSession, peerAsync, taskId, callbackAddress);
4096
3414
  if (result.ok || signal.aborted) {
4097
- if (result.ok)
4098
- trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message));
3415
+ // A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
3416
+ if (result.ok && routedReceipt) {
3417
+ if (result.settlementRefused !== true)
3418
+ settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' });
3419
+ }
3420
+ else if (result.ok)
3421
+ trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
4099
3422
  const notes = [];
4100
3423
  if (result.ok && args.async === true && result.task_status === 'TASK_STATE_DELIVERED' && !routableCallerLabel(callerSession)) {
4101
3424
  notes.push(unroutableCallerNote(String(callerSession ?? session)));
@@ -4141,6 +3464,12 @@ ${message}`;
4141
3464
  recordActivity('out', team, callerSession ?? 'local', false);
4142
3465
  return { ok: false, error: joinedRefusal, code: -32000 };
4143
3466
  }
3467
+ // #114 S5: the same header-safe id rule the wire face enforces.
3468
+ const idRefusal = taskIdFromCaller === undefined ? undefined : taskIdRefusal(taskIdFromCaller);
3469
+ if (idRefusal !== undefined) {
3470
+ recordActivity('out', team, callerSession ?? 'local', false);
3471
+ return { ok: false, error: idRefusal, code: -32000 };
3472
+ }
4144
3473
  const canvasName = parseCanvasTeamName(team);
4145
3474
  // Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
4146
3475
  // or a session label) and quick-dispatch BORROWS a live session's team
@@ -4159,8 +3488,14 @@ ${message}`;
4159
3488
  // gh#45 CLI mailbox: a delivery targeting a DEPOSITED CLI handle is
4160
3489
  // local by definition — it lands in the deposit's mailbox (the CLI
4161
3490
  // polls while alive; receipts and messages never wake anything).
3491
+ // The settler of any receipt on this path: this host's own caller (in-process).
3492
+ const localSettler = { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' };
4162
3493
  const cliRow = cliDeposits.get(team);
4163
3494
  if (cliRow !== undefined) {
3495
+ // #114 S4 (design §4.5): a receipt dropped into a local CLI mailbox is
3496
+ // exactly the settlement a CLI-originated task waits for — settle it,
3497
+ // as the wire face settles before ITS mailbox split.
3498
+ settleAndAnnounce(message, localSettler);
4164
3499
  cliDeposits.mail(team, callerSession ?? 'unknown', message);
4165
3500
  recordActivity('in', team, callerSession ?? 'peer', true);
4166
3501
  logger.info(`a2a: cli mailbox delivery for ${team} (from ${callerSession ?? 'unknown'})`);
@@ -4176,7 +3511,7 @@ ${message}`;
4176
3511
  // A receipt relayed between same-host sessions (the answering session
4177
3512
  // routing its `[A2A receipt]` back over the in-process candidate)
4178
3513
  // correlates here too — same contract, no HTTP on the path.
4179
- settleAndAnnounce(message);
3514
+ settleAndAnnounce(message, localSettler);
4180
3515
  const taskId = taskIdFromCaller ?? `direct-${Math.random().toString(16).slice(2, 10)}`;
4181
3516
  const flight = beginRoute(team, 'local');
4182
3517
  if (asyncMode) {
@@ -4205,7 +3540,7 @@ ${message}`;
4205
3540
  if (prepared.ok) {
4206
3541
  endRoute(flight);
4207
3542
  recordActivity('out', team, 'local', true);
4208
- void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId)
3543
+ void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', undefined, hostDirectHeader)
4209
3544
  .then(outcome => {
4210
3545
  if (!outcome.ok) {
4211
3546
  recordActivity('out', team, 'local', false);
@@ -4254,8 +3589,13 @@ ${message}`;
4254
3589
  ? callbackAddress
4255
3590
  : routable ? callerSession : undefined;
4256
3591
  const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
4257
- steerRelay(target, `[A2A direct] (task ${taskId}) ${fromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam);
4258
- armReceiptAutosend(target, taskId, callerTeam, message);
3592
+ // #114 S5: the consumption search starts at the pre-steer log length.
3593
+ const steerFloor = sessionEventsOf(target).length;
3594
+ steerRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId);
3595
+ armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
3596
+ // #139: the caller's outbound row is born with this admission and the member that received it.
3597
+ if (taskIdFromCaller !== undefined)
3598
+ localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
4259
3599
  endRoute(flight);
4260
3600
  recordActivity('out', team, 'local', true);
4261
3601
  return {
@@ -4289,7 +3629,7 @@ ${message}`;
4289
3629
  if (!steerable && team !== config.team) {
4290
3630
  const prepared = await nativeTeamsPrepare(team);
4291
3631
  if (prepared.ok) {
4292
- const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, signal);
3632
+ const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', signal, hostDirectHeader);
4293
3633
  endRoute(flight);
4294
3634
  if (!outcome.ok) {
4295
3635
  // The aborted-wait shape is earned only by an actual round
@@ -4330,7 +3670,7 @@ ${message}`;
4330
3670
  }
4331
3671
  }
4332
3672
  }
4333
- const wait = routeIntoAgent(team, message, callerSession ?? session, taskId);
3673
+ const wait = routeIntoAgent(team, message, callerSession ?? session, taskId, undefined, undefined, originAgentId);
4334
3674
  const aborted = new Promise((_, reject) => {
4335
3675
  signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
4336
3676
  });
@@ -4437,12 +3777,15 @@ ${message}`;
4437
3777
  * receipt ladder can distinguish "delivered" from "no route" and its
4438
3778
  * owner escalation actually fires for cross-node callback addresses.
4439
3779
  */
4440
- async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId) {
3780
+ async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId, peerCaller) {
4441
3781
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
3782
+ // A background send books no outbound row: its birth facts have no row to go on.
3783
+ if (taskId !== undefined)
3784
+ localBirths.delete(taskId);
4442
3785
  if (local !== undefined) {
4443
3786
  if (!local.ok)
4444
3787
  throw new Error(local.error);
4445
- return;
3788
+ return local;
4446
3789
  }
4447
3790
  const failures = [];
4448
3791
  const candidates = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
@@ -4451,9 +3794,9 @@ ${message}`;
4451
3794
  }
4452
3795
  let lastError = 'no candidate delivered';
4453
3796
  for (const candidate of candidates) {
4454
- const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, callerSession, asyncMode, taskId);
3797
+ const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
4455
3798
  if (result.ok)
4456
- return;
3799
+ return result;
4457
3800
  lastError = result.error;
4458
3801
  if (signal.aborted)
4459
3802
  break;
@@ -4471,7 +3814,6 @@ ${message}`;
4471
3814
  */
4472
3815
  const CANDIDATE_CONCURRENCY = 6;
4473
3816
  /** Concurrent card fetches in one directory sweep (cold-cache burst bound). */
4474
- const SWEEP_CONCURRENCY = 6;
4475
3817
  async function mapBounded(items, limit, task) {
4476
3818
  const results = new Array(items.length);
4477
3819
  let next = 0;
@@ -4485,7 +3827,10 @@ ${message}`;
4485
3827
  await Promise.all(workers);
4486
3828
  return results;
4487
3829
  }
4488
- async function directoryPeerCandidates(fetch, team, failures) {
3830
+ function resolutionPeers() {
3831
+ return config.referralTeamResolution === true ? peerStore.list() : [...new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])];
3832
+ }
3833
+ async function directoryPeerCandidates(fetch, team, failures, scopedCanvasCaller) {
4489
3834
  // gh#8 (N9): a team name resolves only to hosts an operator linked —
4490
3835
  // the seeded subset of the peer store (config seeds, eviction-immune)
4491
3836
  // plus the delegate records (config-time operator acts). The
@@ -4494,16 +3839,23 @@ ${message}`;
4494
3839
  // (gh#8: a squatter two gossip hops away captured routes by naming
4495
3840
  // itself after the target). referralTeamResolution restores the legacy
4496
3841
  // whole-store basis for fleets mid-migration.
4497
- const peers = config.referralTeamResolution === true ? peerStore.list() : [...new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])];
3842
+ const peers = resolutionPeers();
4498
3843
  const candidates = [];
4499
3844
  const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
3845
+ // Check after fetching all cards: a seeded peer publishing the same id8
3846
+ // makes the caller handle ambiguous, so do not send a canvas payload.
3847
+ const resolveLocal = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3848
+ const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3849
+ if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
3850
+ failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
3851
+ return [];
3852
+ }
4500
3853
  for (const entry of cards) {
4501
3854
  if (entry.card === undefined)
4502
3855
  continue;
4503
- if (entry.card.team === team || (entry.card.sessionTeams ?? []).some(item => item.team === team))
3856
+ if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
4504
3857
  candidates.push(entry.peer);
4505
3858
  }
4506
- const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
4507
3859
  for (const { peer, outcome } of resolutions) {
4508
3860
  if (outcome.ok) {
4509
3861
  if (!candidates.includes(outcome.url))
@@ -4564,6 +3916,14 @@ ${message}`;
4564
3916
  if (sessionNodes.has(parent) || joinedSessions.has(parent))
4565
3917
  callbackAddress = `${config.team}/${id8(parent)}`;
4566
3918
  }
3919
+ // #114 S3: the submitting session's node team, but only when the
3920
+ // transport names a session this host knows (a node, a join, or a live
3921
+ // root) — an unchecked id would let any submitter mint a seat. Anything
3922
+ // else (absent, unknown, non-string) is attributed to the host label.
3923
+ const parentSession = request.callbackTarget?.parentSessionId;
3924
+ const knownParent = typeof parentSession === 'string' && parentSession !== ''
3925
+ && (sessionNodes.has(parentSession) || joinedSessions.has(parentSession) || liveRoots.has(parentSession));
3926
+ const sender = { kind: 'bridge', id: knownParent ? `${config.team}/${id8(parentSession)}` : session };
4567
3927
  for (const candidate of candidates) {
4568
3928
  // Capability gate per candidate, like a2a_route's loop: a
4569
3929
  // delivery:async submit never dials a peer whose signed card does
@@ -4607,7 +3967,7 @@ ${message}`;
4607
3967
  break;
4608
3968
  continue;
4609
3969
  }
4610
- trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message));
3970
+ trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4611
3971
  const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
4612
3972
  if (result.task_status === 'TASK_STATE_COMPLETED') {
4613
3973
  return { kind: 'completed', text: result.reply, taskId, contextId };
@@ -4712,368 +4072,15 @@ ${message}`;
4712
4072
  },
4713
4073
  };
4714
4074
  ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
4715
- ctx.tools.register(defineTool({
4716
- name: 'a2a_status',
4717
- description: 'Read-only A2A network health: the tracked peer fleet with quality scores, routes currently in flight '
4718
- + '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
4719
- + 'Includes the idempotency window occupancy (claims, replays, conflicts) behind the UNIQUE-ization '
4720
- + 'traffic watch. '
4721
- + 'While teamScopeInfo is on (default), peers / in-flight / activity narrow to the joined-team scope — local traffic and idempotency aggregates stay. '
4722
- + 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
4723
- parameters: {},
4724
- output: {
4725
- schema: {
4726
- type: 'object',
4727
- additionalProperties: false,
4728
- properties: {
4729
- ok: { type: 'boolean', required: true },
4730
- peers: {
4731
- type: 'array',
4732
- required: true,
4733
- items: {
4734
- type: 'object',
4735
- additionalProperties: false,
4736
- properties: {
4737
- url: { type: 'string', required: true },
4738
- score: { type: 'number' },
4739
- },
4740
- },
4741
- },
4742
- inFlight: {
4743
- type: 'array',
4744
- required: true,
4745
- items: {
4746
- type: 'object',
4747
- additionalProperties: false,
4748
- properties: {
4749
- team: { type: 'string', required: true },
4750
- peer: { type: 'string', required: true },
4751
- startedAt: { type: 'number', required: true },
4752
- },
4753
- },
4754
- },
4755
- activity: {
4756
- type: 'array',
4757
- required: true,
4758
- items: {
4759
- type: 'object',
4760
- additionalProperties: false,
4761
- properties: {
4762
- ts: { type: 'number', required: true },
4763
- dir: { type: 'string', required: true },
4764
- team: { type: 'string', required: true },
4765
- peer: { type: 'string', required: true },
4766
- ok: { type: 'boolean', required: true },
4767
- },
4768
- },
4769
- },
4770
- idempotency: {
4771
- type: 'object',
4772
- additionalProperties: false,
4773
- required: true,
4774
- properties: {
4775
- window: { type: 'number', required: true },
4776
- cap: { type: 'number', required: true },
4777
- pending: { type: 'number', required: true },
4778
- settled: { type: 'number', required: true },
4779
- claimsFresh: { type: 'number', required: true },
4780
- replays: { type: 'number', required: true },
4781
- conflicts: { type: 'number', required: true },
4782
- },
4783
- },
4784
- },
4785
- },
4786
- render: (_args, value) => [{
4787
- type: 'text',
4788
- text: [
4789
- ...(value.inFlight.length === 0 ? [] : ['In flight:']),
4790
- ...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
4791
- `Peers (${String(value.peers.length)}):`,
4792
- ...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
4793
- `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)}`,
4794
- ...(value.activity.length === 0 ? [] : ['Recent routes:']),
4795
- ...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
4796
- ].join('\n'),
4797
- }],
4798
- },
4799
- presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
4800
- execute: async (_args, exec) => {
4801
- a2aJoinGateRefusal(exec);
4802
- // S5 open plane: the unscoped snapshot, as before.
4803
- if (!config.teamScopeInfo) {
4804
- return {
4805
- ok: true,
4806
- peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
4807
- inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
4808
- activity: recentActivity.slice(),
4809
- idempotency: idempotencyStore.stats(),
4810
- };
4811
- }
4812
- // S5 scoped plane: the tracked fleet narrows to peers whose verified
4813
- // card serves a joined team (bounded-concurrency memoized fetch — a
4814
- // warm sweep costs no extra round trips); routing facts keep
4815
- // host-internal traffic (peer 'local') and drop remote rows whose
4816
- // team is outside the scope, so cross-fleet collaborations stay
4817
- // unread here. The idempotency aggregate is host machinery, not
4818
- // attribution — it stays unscoped like the ledger it serves.
4819
- const joined = joinedTeamScope();
4820
- const visibleTeams = new Set(joined);
4821
- for (const agent of sessionNodes.values()) {
4822
- if (teamMemberships.teamsOf(String(agent.id)).some(team => joined.has(team)))
4823
- visibleTeams.add(sessionTeamOf(agent));
4824
- }
4825
- // The host's own process-team face is host-internal ALWAYS — a
4826
- // zero-roster host must still see inbound deliveries and outbound
4827
- // attempts addressed to itself (review M2: gating this on
4828
- // joined.size > 0 self-blinded exactly that traffic).
4829
- visibleTeams.add(config.team);
4830
- // G2 (review artifact 688c0354d3 v2): the host-synthetic face rows —
4831
- // the /a2a/log telemetry records under the synthetic team label
4832
- // 'log' (recordActivity('in', 'log', caller) on both admission
4833
- // outcomes) — are THIS host's own face facts, not fleet observation.
4834
- // Scoping filtered them like foreign traffic and hid the host's own
4835
- // inbound log-face telemetry from its own supervisor; they pass.
4836
- visibleTeams.add('log');
4837
- const fetch = memoizedCardFetch();
4838
- const peerRows = await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => {
4839
- const card = await fetch(url);
4840
- return card !== undefined && cardServesJoined(card, joined) ? { url, score: peerStore.score(url) } : undefined;
4841
- });
4842
- const peers = peerRows.filter((row) => row !== undefined);
4843
- const inScope = (entry) => entry.peer === 'local' || visibleTeams.has(entry.team);
4844
- return {
4845
- ok: true,
4846
- peers,
4847
- inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })).filter(inScope),
4848
- activity: recentActivity.filter(inScope),
4849
- idempotency: idempotencyStore.stats(),
4850
- };
4851
- },
4852
- }));
4853
- /** Human duration for a ledger row: how long a task has been owed or took. */
4854
- const describeAge = (ms) => {
4855
- const minutes = Math.floor(ms / 60_000);
4856
- if (minutes < 1)
4857
- return 'under a minute';
4858
- if (minutes < 60)
4859
- return `${String(minutes)}m`;
4860
- return `${String(Math.floor(minutes / 60))}h${minutes % 60 === 0 ? '' : `${String(minutes % 60)}m`}`;
4861
- };
4862
- // S5 ruling: a2a_tasks stays unscoped — it is this host's own dispatch
4863
- // ledger (pending / dead / archived), the receipt-reconciliation surface
4864
- // for work this host itself routed, initiator-exempt dispatches included.
4865
- // Its rows name teams this host already talked to; reading them back is
4866
- // bookkeeping, not fleet disclosure.
4867
- ctx.tools.register(defineTool({
4868
- name: 'a2a_tasks',
4869
- description: 'Read-only three-tier ledger of routed A2A tasks: pending rows still owed a receipt, rows '
4870
- + 'auto-dead-lettered past the stale TTL, and the bounded archive of correlated '
4871
- + '`[A2A receipt] task <task_id>` outcomes. Use it to reconcile dispatched async work instead of '
4872
- + 're-routing to ask for progress.',
4873
- parameters: {},
4874
- output: {
4875
- schema: {
4876
- type: 'object',
4877
- additionalProperties: false,
4878
- properties: {
4879
- ok: { type: 'boolean', required: true },
4880
- tasks: {
4881
- type: 'array',
4882
- required: true,
4883
- items: {
4884
- type: 'object',
4885
- additionalProperties: false,
4886
- properties: {
4887
- taskId: { type: 'string', required: true },
4888
- team: { type: 'string', required: true },
4889
- peer: { type: 'string', required: true },
4890
- startedAt: { type: 'number', required: true },
4891
- contextId: { type: 'string' },
4892
- brief: { type: 'string' },
4893
- status: { type: 'string', required: true },
4894
- deadAt: { type: 'number' },
4895
- direction: { type: 'string', enum: ['inbound', 'outbound'] },
4896
- receiptExpected: { type: 'boolean' },
4897
- reason: { type: 'string', enum: ['fire-forget', 'unconsumed'] },
4898
- },
4899
- },
4900
- },
4901
- archive: {
4902
- type: 'array',
4903
- required: true,
4904
- items: {
4905
- type: 'object',
4906
- additionalProperties: false,
4907
- properties: {
4908
- taskId: { type: 'string', required: true },
4909
- team: { type: 'string', required: true },
4910
- startedAt: { type: 'number', required: true },
4911
- resolvedAt: { type: 'number', required: true },
4912
- summary: { type: 'string' },
4913
- brief: { type: 'string' },
4914
- },
4915
- },
4916
- },
4917
- archivedTotal: { type: 'number', required: true },
4918
- },
4919
- },
4920
- render: (_args, value) => [{
4921
- type: 'text',
4922
- text: value.tasks.length === 0 && value.archivedTotal === 0
4923
- ? 'No routed tasks are owed a receipt.'
4924
- : [
4925
- ...(() => {
4926
- // C-ruling split (2026-09-01 decision seat): only young,
4927
- // receipt-expected pending rows are settleable debt — the
4928
- // stale tail is conversational noise waiting on the dead
4929
- // TTL; count it, don't list it.
4930
- const pending = value.tasks.filter((task) => task.status === 'pending');
4931
- const settleable = pending.filter((task) => task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000);
4932
- const staleCount = pending.length - settleable.length;
4933
- return [
4934
- ...(settleable.length > 0 ? ['Owed receipts:'] : []),
4935
- ...settleable.map((task) => {
4936
- const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
4937
- const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
4938
- return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), waiting ${describeAge(Date.now() - task.startedAt)}${context}`;
4939
- }),
4940
- ...(staleCount > 0 ? [`Delivered conversationally without a formal receipt (${String(staleCount)}) — not debts; they auto-dead-letter at the stale TTL.`] : []),
4941
- ];
4942
- })(),
4943
- ...(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):'] : []),
4944
- ...value.tasks.filter((task) => task.status === 'dead').map((task) => {
4945
- const context = task.contextId === undefined || task.contextId === '' ? '' : `, follow-up context ${task.contextId}`;
4946
- const brief = task.brief === undefined || task.brief === '' ? '' : ` — "${task.brief}"`;
4947
- return ` - ${task.taskId}${brief} → ${task.team} (via ${task.peer === 'local' ? 'this host' : task.peer}), dispatched ${describeAge(Date.now() - task.startedAt)} ago${context}`;
4948
- }),
4949
- ...(value.archivedTotal > 0 ? [`Archived (${String(value.archivedTotal)}), most recent first:`] : []),
4950
- ...value.archive.map((entry) => {
4951
- const brief = entry.brief === undefined || entry.brief === '' ? '' : ` — "${entry.brief}"`;
4952
- return ` - ${entry.taskId}${brief} → ${entry.team}: after ${describeAge(entry.resolvedAt - entry.startedAt)}, ${entry.summary === undefined || entry.summary === '' ? 'resolved' : entry.summary}`;
4953
- }),
4954
- ...(value.archivedTotal > value.archive.length ? [` (+${String(value.archivedTotal - value.archive.length)} older not shown)`] : []),
4955
- ].join('\n'),
4956
- }],
4957
- },
4958
- presentCall: () => ({ card: 'generic', title: 'A2A task ledger', kind: 'other', rawInput: null }),
4959
- execute: async (_args, exec) => {
4960
- a2aJoinGateRefusal(exec);
4961
- const archiveAll = taskLedger.archive();
4962
- return {
4963
- ok: true,
4964
- tasks: taskLedger.list().map(task => ({ ...task })),
4965
- archive: archiveAll.slice(0, 5).map(({ taskId, team, startedAt, resolvedAt, summary, brief }) => ({ taskId, team, startedAt, resolvedAt, ...(summary !== undefined && summary !== '' ? { summary } : {}), ...(brief !== undefined ? { brief } : {}) })),
4966
- archivedTotal: archiveAll.length,
4967
- };
4968
- },
4969
- }));
4970
- ctx.tools.register(defineTool({
4971
- name: 'a2a_probe',
4972
- description: 'Probe the tracked peer fleet for reachability and round-trip latency: one verified-card fetch per peer '
4973
- + '(the same fetch discovery uses), reporting reachable peers with their team and latency and unreachable '
4974
- + 'ones with the failure reason. Pass url to probe one target instead of the whole fleet. '
4975
- + '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. '
4976
- + 'Use it before dispatching verification work or when routes fail over, instead of guessing which node is down.',
4977
- parameters: {
4978
- url: { type: 'string', description: 'Optional single peer base URL to probe; empty probes every tracked peer.' },
4979
- },
4980
- output: {
4981
- schema: {
4982
- type: 'object',
4983
- additionalProperties: false,
4984
- properties: {
4985
- ok: { type: 'boolean', required: true },
4986
- results: {
4987
- type: 'array',
4988
- required: true,
4989
- items: {
4990
- type: 'object',
4991
- additionalProperties: false,
4992
- properties: {
4993
- url: { type: 'string', required: true },
4994
- reachable: { type: 'boolean', required: true },
4995
- ms: { type: 'number', description: 'Round-trip of the card fetch, reachable or not.' },
4996
- team: { type: 'string', description: 'The team the peer publishes, when reachable.' },
4997
- error: { type: 'string', description: 'Why the peer is unreachable.' },
4998
- },
4999
- },
5000
- },
5001
- },
5002
- },
5003
- render: (_args, value) => [{
5004
- type: 'text',
5005
- text: value.results.length === 0
5006
- ? 'No peers are tracked; add seeds or let referrals arrive.'
5007
- : [
5008
- `Fleet probe (${String(value.results.length)}): ${String(value.results.filter((row) => row.reachable).length)} reachable, ${String(value.results.filter((row) => !row.reachable).length)} down`,
5009
- ...value.results.map((row) => ` ${row.reachable ? '✓' : '✗'} ${row.url}${row.reachable ? ` (team ${String(row.team)}, ${String(row.ms)}ms)` : ` (${String(row.error)})`}`),
5010
- ].join('\n'),
5011
- }],
5012
- },
5013
- presentCall: args => ({ card: 'generic', title: args.url === undefined || args.url === '' ? 'Probe the A2A fleet' : `Probe A2A peer: ${args.url}`, kind: 'other', rawInput: args }),
5014
- execute: async (args, exec) => {
5015
- a2aJoinGateRefusal(exec);
5016
- const probeOne = async (url) => {
5017
- const startedAt = Date.now();
5018
- const outcome = await client.fetchCardDetail(url);
5019
- const ms = Date.now() - startedAt;
5020
- // The fetch is the probe: settling it moves the peer's quality score
5021
- // and learns its referrals, exactly like a discovery sweep would,
5022
- // while the outcome's stage names the failure for the report.
5023
- const card = settlePeerCard(url, outcome);
5024
- if (card !== undefined) {
5025
- // gh#71 fix ①: a successful full-face fetch persists into the warm
5026
- // cache — the probe is often the FIRST contact (no directory sweep
5027
- // has run), and the state face's peerOrg reads exactly this cache.
5028
- cardCache.set(url, { at: Date.now(), card });
5029
- return { url, reachable: true, ms, team: card.team };
5030
- }
5031
- if (outcome.ok)
5032
- return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
5033
- return outcome.stage === 'unreachable'
5034
- ? { url, reachable: false, ms, error: `unreachable: ${outcome.detail}` }
5035
- : { url, reachable: false, ms, error: `rejected: ${outcome.reason} card` };
5036
- };
5037
- // S5: a reachability report is fleet disclosure too. The sweep probes
5038
- // only peers whose verified card serves a joined team (memoized —
5039
- // warm cards cost nothing); a single-URL probe needs proof of service
5040
- // — a FRESH card, or a stale cached one: a peer that once served a
5041
- // joined team and is now down gets the normal down report (that
5042
- // reachability fact belongs to the team), while a URL that never
5043
- // proved service is refused without a probe (its reachability is
5044
- // exactly the fact scoping withholds).
5045
- const joined = joinedTeamScope();
5046
- const fetch = memoizedCardFetch();
5047
- const serves = async (url) => {
5048
- const card = await fetch(url);
5049
- return card !== undefined && cardServesJoined(card, joined);
5050
- };
5051
- if (args.url !== undefined && args.url !== '') {
5052
- if (config.teamScopeInfo && !(await serves(args.url))) {
5053
- const stale = cardCache.get(args.url)?.card;
5054
- if (stale !== undefined && cardServesJoined(stale, joined)) {
5055
- // Was serving, now unverifiable fresh — probe and report the
5056
- // down state through the normal path.
5057
- return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
5058
- }
5059
- return { ok: true, results: [{ url: args.url, reachable: false, ms: 0, error: stale === undefined
5060
- ? 'refused (not probed): teamScopeInfo on — no verified card for this URL proves service to a joined team'
5061
- : 'refused (not probed): teamScopeInfo on — this URL\'s verified card shows no team joined by this host\'s nodes' }] };
5062
- }
5063
- return { ok: true, results: await Promise.all([args.url].map(probeOne)) };
5064
- }
5065
- const targets = config.teamScopeInfo
5066
- ? await mapBounded(peerStore.list(), SWEEP_CONCURRENCY, async (url) => await serves(url) ? url : undefined).then(rows => rows.filter((url) => url !== undefined))
5067
- : peerStore.list();
5068
- // Promise.all keeps the report in the store's preference order.
5069
- return { ok: true, results: await Promise.all(targets.map(probeOne)) };
5070
- },
5071
- }));
4075
+ infoTools.registerStatus();
4076
+ infoTools.registerTasks();
4077
+ infoTools.registerProbe();
5072
4078
  const pendingFinals = new Map();
5073
4079
  const armFlushTimeout = (agentId, waiter) => {
5074
4080
  return ctx.timer.timeout(() => {
5075
4081
  const entries = pendingFinals.get(agentId);
5076
- if (entries === undefined)
4082
+ // An already-answered waiter is gone from the list: never answer twice.
4083
+ if (entries === undefined || !entries.includes(waiter))
5077
4084
  return;
5078
4085
  const kept = entries.filter(entry => entry !== waiter);
5079
4086
  if (kept.length === 0)
@@ -5096,27 +4103,114 @@ ${message}`;
5096
4103
  pendingFinals.delete(agentId);
5097
4104
  return;
5098
4105
  }
4106
+ // #114 S5: each waiter is answered from its OWN floor. The old shared
4107
+ // floor (the minimum sinceEvents) answered every waiter with one text, so
4108
+ // a busy target's other work line settled tasks it had not read yet.
5099
4109
  const events = sessionEventsOf(agent);
5100
- const floor = Math.min(...entries.map(entry => entry.sinceEvents));
5101
- let reply = '';
5102
- for (let index = events.length - 1; index >= floor; index--) {
4110
+ const answered = [];
4111
+ // Logs that record turn boundaries bound each waiter to its consuming
4112
+ // turn; a log without any marker falls back to "this idle ends it".
4113
+ const turnMarked = events.some(event => event?.type === 'turn/start' || event?.type === 'turn/end');
4114
+ for (const entry of entries) {
4115
+ if (entry.taskId !== undefined) {
4116
+ entry.consumedAt ??= consumptionIndex(events, entry.taskId, entry.scannedTo ?? entry.sinceEvents);
4117
+ if (entry.consumedAt === undefined) {
4118
+ // Not consumed yet (the steer waits for the running turn's next
4119
+ // step, or the next turn): no final belongs to it — keep waiting.
4120
+ entry.scannedTo = events.length;
4121
+ continue;
4122
+ }
4123
+ // The consuming turn's window: up to its own `turn/end`, never into a
4124
+ // following turn (back-to-back turns can run with no idle between).
4125
+ // Without turn markers, this idle is the turn's end.
4126
+ let end = events.length;
4127
+ let completed = true;
4128
+ if (turnMarked) {
4129
+ const close = turnEndIndex(events, entry.consumedAt + 1);
4130
+ if (close === undefined) {
4131
+ // The consuming turn is still open: wait for its turn/end.
4132
+ continue;
4133
+ }
4134
+ end = close;
4135
+ completed = events[close]?.data?.reason?.kind === 'completed';
4136
+ }
4137
+ // The waiter closes HERE with that turn's last text. A turn that said
4138
+ // nothing, or ended aborted/failed/blocked, did no work this host can
4139
+ // vouch for: close as a placeholder — no receipt, the row stays owed.
4140
+ const reply = latestAssistantText(events, entry.consumedAt + 1, end);
4141
+ answered.push(reply !== '' && completed ? { entry, reply } : { entry, reply: '', placeholder: true });
4142
+ continue;
4143
+ }
4144
+ const reply = latestAssistantText(events, entry.sinceEvents);
4145
+ if (reply !== '')
4146
+ answered.push({ entry, reply });
4147
+ }
4148
+ if (answered.length === 0)
4149
+ return;
4150
+ const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
4151
+ if (kept.length === 0)
4152
+ pendingFinals.delete(agentId);
4153
+ else
4154
+ pendingFinals.set(agentId, kept);
4155
+ for (const { entry, reply, placeholder } of answered) {
4156
+ entry.timeoutDisposer?.();
4157
+ if (placeholder === true)
4158
+ entry.answer('The consuming turn produced no completed reply.', true);
4159
+ else
4160
+ entry.answer(reply);
4161
+ }
4162
+ }
4163
+ /** #114 S5: the index of the first `turn/end` at or after `from`, or undefined while the turn is open. */
4164
+ function turnEndIndex(events, from) {
4165
+ for (let index = from; index < events.length; index++) {
4166
+ if (events[index]?.type === 'turn/end')
4167
+ return index;
4168
+ }
4169
+ return undefined;
4170
+ }
4171
+ /** #114 S5: the steer header both delivery paths write; the id is captured whole, never prefix-matched. */
4172
+ const STEER_HEADER = /^\[A2A direct\] \(task ([^\s)]+)\) /;
4173
+ /**
4174
+ * #114 S5: the index of the user/message that consumed one task's steer —
4175
+ * the first at or after `from` whose first text block opens with the
4176
+ * `[A2A direct] (task <id>)` header for exactly this id.
4177
+ * @param from - first index not yet searched (starts at the pre-steer log length).
4178
+ * @returns the index, or undefined while the steer is still unconsumed.
4179
+ */
4180
+ function consumptionIndex(events, taskId, from) {
4181
+ for (let index = from; index < events.length; index++) {
4182
+ const event = events[index];
4183
+ if (event?.type !== 'user/message')
4184
+ continue;
4185
+ const first = event.data?.content?.find(block => block.type === 'text' && typeof block.text === 'string');
4186
+ if (STEER_HEADER.exec(first?.text ?? '')?.[1] === taskId)
4187
+ return index;
4188
+ }
4189
+ return undefined;
4190
+ }
4191
+ /**
4192
+ * The newest assistant text at or after `floor`: at an idle flush that is
4193
+ * the final of the turn that just ended. Textless messages (tool-only, or
4194
+ * empty/whitespace text) are skipped, so a turn ending on a tool call still
4195
+ * answers with the last thing it said. '' when no message has text.
4196
+ * @param end - exclusive upper bound (default: the whole log).
4197
+ */
4198
+ function latestAssistantText(events, floor, end = events.length) {
4199
+ for (let index = end - 1; index >= floor; index--) {
5103
4200
  const event = events[index];
5104
4201
  if (event === undefined || event.type !== 'assistant/message')
5105
4202
  continue;
5106
4203
  const content = event.data?.message?.content;
5107
4204
  if (content === undefined)
5108
4205
  continue;
5109
- reply = content
4206
+ const text = content
5110
4207
  .filter((block) => block.type === 'text' && typeof block.text === 'string')
5111
4208
  .map(block => block.text)
5112
4209
  .join('\n');
5113
- break;
5114
- }
5115
- if (reply !== '') {
5116
- for (const entry of entries)
5117
- entry.answer(reply);
5118
- pendingFinals.delete(agentId);
4210
+ if (text.trim() !== '')
4211
+ return text;
5119
4212
  }
4213
+ return '';
5120
4214
  }
5121
4215
  // Every inbound route is a direct route (final semantics), so the idle
5122
4216
  // flush listens unconditionally — an empty pendingFinals map makes it a