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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +4 -1
  2. package/README_zh.md +3 -0
  3. package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
  4. package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +445 -57
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/card.js +4 -2
  11. package/lib/cli-deposit.js +122 -7
  12. package/lib/client.js +64 -64
  13. package/lib/client.js.map +1 -1
  14. package/lib/faces/admin.js +133 -43
  15. package/lib/faces/canvas-notify.js +12 -3
  16. package/lib/faces/files.js +155 -0
  17. package/lib/faces/nodes.js +171 -0
  18. package/lib/faces/session-log.js +162 -0
  19. package/lib/faces/wire.js +206 -39
  20. package/lib/index.js +809 -1720
  21. package/lib/joined-store.js +23 -0
  22. package/lib/ledger-conservation.js +97 -0
  23. package/lib/legacy/teams-bridge-inbound.js +156 -0
  24. package/lib/node-bind.js +534 -0
  25. package/lib/node-github.js +72 -0
  26. package/lib/node-self.js +453 -0
  27. package/lib/node-store.js +484 -0
  28. package/lib/node-tasks.js +342 -0
  29. package/lib/origin-claim.js +246 -0
  30. package/lib/peer-directory.js +134 -0
  31. package/lib/receipt.js +26 -1
  32. package/lib/skill/SKILL.md +3 -1
  33. package/lib/skill/references/cli.md +82 -3
  34. package/lib/skill/references/collaboration.md +126 -74
  35. package/lib/skill/references/discovery.md +29 -0
  36. package/lib/skill/references/intake.md +107 -0
  37. package/lib/skill/references/router.md +96 -0
  38. package/lib/skill/references/setup.md +2 -1
  39. package/lib/task-auth.js +483 -0
  40. package/lib/task-ledger.js +324 -12
  41. package/lib/team-store.js +24 -1
  42. package/lib/tools/act.js +383 -0
  43. package/lib/tools/files.js +201 -0
  44. package/lib/tools/info.js +684 -0
  45. package/lib/types/a2a-client.d.ts +10 -4
  46. package/lib/types/auth-observer.d.ts +110 -0
  47. package/lib/types/blob-store.d.ts +83 -0
  48. package/lib/types/cli-deposit.d.ts +38 -0
  49. package/lib/types/faces/admin.d.ts +14 -2
  50. package/lib/types/faces/canvas-notify.d.ts +4 -1
  51. package/lib/types/faces/files.d.ts +53 -0
  52. package/lib/types/faces/nodes.d.ts +69 -0
  53. package/lib/types/faces/session-log.d.ts +44 -0
  54. package/lib/types/faces/wire.d.ts +50 -5
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/joined-store.d.ts +9 -0
  57. package/lib/types/ledger-conservation.d.ts +77 -0
  58. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  59. package/lib/types/node-bind.d.ts +220 -0
  60. package/lib/types/node-github.d.ts +35 -0
  61. package/lib/types/node-self.d.ts +178 -0
  62. package/lib/types/node-store.d.ts +180 -0
  63. package/lib/types/node-tasks.d.ts +121 -0
  64. package/lib/types/origin-claim.d.ts +119 -0
  65. package/lib/types/peer-directory.d.ts +39 -0
  66. package/lib/types/receipt.d.ts +12 -0
  67. package/lib/types/task-auth.d.ts +271 -0
  68. package/lib/types/task-ledger.d.ts +143 -6
  69. package/lib/types/team-store.d.ts +9 -0
  70. package/lib/types/tools/act.d.ts +52 -0
  71. package/lib/types/tools/files.d.ts +38 -0
  72. package/lib/types/tools/info.d.ts +102 -0
  73. package/lib/types/types.d.ts +6 -0
  74. package/nexus-stage/src/canvas-ops.ts +1 -1
  75. package/nexus-stage/src/federation.ts +1 -0
  76. package/nexus-stage/src/main.ts +41 -18
  77. package/nexus-stage/src/planning-member-handles.ts +34 -0
  78. package/nexus-stage/src/planning-mirror.ts +47 -3
  79. package/nexus-stage/src/planning-reachability.ts +28 -0
  80. package/nexus-stage/src/planning-types.ts +1 -1
  81. package/nexus-stage/src/planning-view.ts +29 -26
  82. package/nexus-stage/src/planning.css +4 -0
  83. package/nexus-stage/src/wb-directory.ts +13 -0
  84. package/nexus-stage/src/workbench.css +3 -0
  85. package/nexus-stage/src/workbench.ts +5 -1
  86. package/package.json +1 -1
  87. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  88. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
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';
@@ -29,29 +29,45 @@ export function resolveCallerLabel(input) {
29
29
  import { signCard, verifyCard } from "./card.js";
30
30
  import { GroupStore } from "./group-store.js";
31
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";
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,15 @@ 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
+ const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true);
2047
+ // gh#141: the caller lane pays this delivery's inbound row across hosts (a
2048
+ // same-host receipt already settled it in-process), unless the caller
2049
+ // refused it. The owner tier is no payment here — but an owner mailbox
2050
+ // on THIS host settles the row in-process, as it always has.
2051
+ if (team === callerTeam && delivered.settlementRefused !== true)
2052
+ settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
2053
+ },
1813
2054
  ownerTeam: config.team,
1814
2055
  log: (stage, error) => {
1815
2056
  // N2 (observability): throttled per task to avoid ping-pong noise.
@@ -1822,17 +2063,26 @@ export function apply(ctx, config) {
1822
2063
  }, callerTeam).catch(() => {
1823
2064
  // Ladder exhausted: the throttled breadcrumbs above already said why.
1824
2065
  });
1825
- });
2066
+ }, { taskId, steerFloor });
1826
2067
  }
1827
2068
  /**
1828
2069
  * Register one final-reply waiter for the agent and arm its flush timeout.
1829
2070
  * @param agent - the agent whose next assistant message answers the waiter.
1830
2071
  * @param answer - how the waiter's reply is delivered.
2072
+ * @param attribution - #114 S5: attribute by consumption — only the turn
2073
+ * that consumed this task's `[A2A direct] (task <id>)` user message
2074
+ * answers the waiter; the search starts at `steerFloor`, the log length
2075
+ * read just before the steer, so an older steer of a reused id never
2076
+ * anchors it.
1831
2077
  * @returns the registered waiter (for callers that may retract it).
1832
2078
  */
1833
- function registerFinalWaiter(agent, answer) {
2079
+ function registerFinalWaiter(agent, answer, attribution) {
1834
2080
  const key = String(agent.id);
1835
- const waiter = { answer, sinceEvents: sessionEventsOf(agent).length };
2081
+ const waiter = {
2082
+ answer,
2083
+ sinceEvents: sessionEventsOf(agent).length,
2084
+ ...(attribution !== undefined && attribution.taskId !== '' ? { taskId: attribution.taskId, scannedTo: attribution.steerFloor } : {}),
2085
+ };
1836
2086
  waiter.timeoutDisposer = armFlushTimeout(key, waiter);
1837
2087
  pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
1838
2088
  return waiter;
@@ -1845,15 +2095,15 @@ export function apply(ctx, config) {
1845
2095
  * marker instead of a (false) completion.
1846
2096
  * @param taskId - the correlation key the steered receipt header carries.
1847
2097
  */
1848
- async function routeIntoAgent(team, message, caller, taskId) {
2098
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
1849
2099
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
1850
2100
  if (agent !== undefined) {
1851
- return routeIntoAgentFor(agent, team, message, caller, taskId);
2101
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
1852
2102
  }
1853
2103
  // Wake-on-route: a cold joined team materializes on demand, then steers.
1854
2104
  const wake = wakeColdTeam(team);
1855
2105
  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)}` }));
2106
+ 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
2107
  }
1858
2108
  // Native-teams inbound bridge (opt-in): a registry team claims the
1859
2109
  // handle and dispatches through its authoritative seam. Checked before
@@ -1862,7 +2112,7 @@ export function apply(ctx, config) {
1862
2112
  if (config.nativeTeamsInbound === true) {
1863
2113
  const prepared = await nativeTeamsPrepare(team);
1864
2114
  if (prepared.ok) {
1865
- const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId);
2115
+ const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId, proofClause ?? '未验证(in-process)', undefined, buildHeader === undefined ? undefined : (_team, _taskId, from) => buildHeader(from));
1866
2116
  if (bridged.ok) {
1867
2117
  if (bridged.settled)
1868
2118
  return { ok: true, reply: bridged.reply };
@@ -1882,7 +2132,7 @@ export function apply(ctx, config) {
1882
2132
  if (live !== undefined) {
1883
2133
  if (callerIsAgent(live, caller))
1884
2134
  return { ok: false, error: selfRouteText(team) };
1885
- return routeIntoAgentFor(live, team, message, caller, taskId);
2135
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
1886
2136
  }
1887
2137
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
1888
2138
  }
@@ -1897,7 +2147,7 @@ export function apply(ctx, config) {
1897
2147
  * @param caller - caller label for the steered prefix.
1898
2148
  * @returns the agent's final reply, or the explicit failure.
1899
2149
  */
1900
- function routeIntoAgentFor(agent, team, message, caller, taskId) {
2150
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
1901
2151
  return new Promise((resolve) => {
1902
2152
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
1903
2153
  resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
@@ -1909,12 +2159,12 @@ export function apply(ctx, config) {
1909
2159
  // the caller supplied one, rides the header so an "[A2A receipt] task
1910
2160
  // <id>" answer correlates with the caller's own route result.
1911
2161
  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}`;
2162
+ const headerLine = buildHeader !== undefined && taskId !== undefined
2163
+ ? buildHeader(from)
2164
+ : `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2165
+ const text = `${headerLine}\n\n${message}`;
1916
2166
  try {
1917
- steerRelay(agent, text, caller === '' ? undefined : caller);
2167
+ steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
1918
2168
  }
1919
2169
  catch (error) {
1920
2170
  // The waiter was registered above, so the key always holds it; the
@@ -1931,227 +2181,75 @@ ${message}`;
1931
2181
  }
1932
2182
  });
1933
2183
  }
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;
2184
+ // gh#77 G-in (S23 repayment): the native-teams inbound bridge moved verbatim
2185
+ // to src/legacy/teams-bridge-inbound.ts — the registry probe, claim memo,
2186
+ // prepare and the bounded round. The outbound bridgeFace stays here until
2187
+ // the G-out slice (it submits through the dispatch trunk).
2188
+ const { teamsRegistry, nativeTeamsClaims, nativeTeamsPrepare, nativeTeamsRound } = createTeamsBridgeInbound({ ctx, config, liveAgent, fromClause, sanitizeFromClause, proofHeader: hostDirectHeader, taint: agent => { markLineage(agent, 'native-teams'); } });
2189
+ /* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md,
2190
+ src/origin-claim.ts): the caller's card key signs the envelope; the
2191
+ bound form must name one of OUR origins and the route it hits. ts skew
2192
+ ±5 min, no receiver-side state. Fail-closed everywhere. */
2193
+ /** legacyInbound warn metric (M1): envelope-absent deliveries during the
2194
+ * window — the operator watches the tail drain before closing it. */
2195
+ let legacyInboundCount = 0;
2196
+ /** unboundInbound: accepted unbound (v1) envelopes — the drain gauge for
2197
+ * closing originClaimUnboundWindow; boundMissInbound: those of them whose
2198
+ * bound form missed (a peer dials us by a name not in our audiences). */
2199
+ let unboundInboundCount = 0, boundMissInboundCount = 0;
2200
+ function verifyOriginClaim(req, options) {
2201
+ return verifyOriginClaimOf(req, {
2202
+ cardCache,
2203
+ fetch: fetchImpl,
2204
+ audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2205
+ unboundOpen: config.originClaimUnboundWindow !== false,
2206
+ onUnbound: () => { unboundInboundCount += 1; },
2207
+ onBoundMiss: () => { boundMissInboundCount += 1; },
2208
+ loopbackArrival: (config.originClaimUrl ?? '') === '',
2209
+ now: Date.now(),
2210
+ ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2211
+ });
1941
2212
  }
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
2213
  /**
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.
2214
+ * gh#133: the deposited card a CLI claim is judged by, or undefined when
2215
+ * the claim URL is not a deposit face of a handle held here (the ordinary
2216
+ * zone path then decides). The URL's last path segments must be
2217
+ * `/a2a/cli/<handle>` (any prefix or origin: the CLI dials its home under
2218
+ * whatever address it can — the deposited key, not the address, is the
2219
+ * proof). origin-claim.ts verifies the signature under that card and the
2220
+ * signed name against the handle's own; the claim's caller is the full
2221
+ * HANDLE, so it covers exactly that handle — never a namesake CLI, a zone
2222
+ * session, or the host face (review of PR #135 P1).
1961
2223
  */
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
- }
2224
+ function cliDepositCard(callerUrl) {
2225
+ let path;
2226
+ try {
2227
+ path = new URL(callerUrl).pathname;
1978
2228
  }
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);
2229
+ catch {
2230
+ return undefined;
2038
2231
  }
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);
2232
+ const segment = /\/a2a\/cli\/([^/]+)\/?$/.exec(path)?.[1];
2233
+ if (segment === undefined)
2234
+ return undefined;
2235
+ let handle;
2236
+ try {
2237
+ handle = decodeURIComponent(segment);
2067
2238
  }
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
- }
2239
+ catch {
2240
+ return undefined;
2129
2241
  }
2130
- if (card === undefined)
2131
- return { ok: false, reason: 'no-card' };
2242
+ const shape = CLI_HANDLE_RE.exec(handle);
2243
+ const deposit = shape === null ? undefined : cliDeposits.get(handle);
2244
+ if (shape === null || deposit === undefined)
2245
+ return undefined;
2132
2246
  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' };
2247
+ const verdict = verifyCard(JSON.parse(deposit.cardJson), Date.now());
2248
+ return { handle, name: shape[1], card: verdict.ok && verdict.card.team === handle ? verdict.card : undefined };
2137
2249
  }
2138
2250
  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' };
2251
+ return { handle, name: shape[1], card: undefined };
2153
2252
  }
2154
- return { ok: true, caller, callerUrl, card };
2155
2253
  }
2156
2254
  /**
2157
2255
  * W3 (#18 half-side): is a VERIFIED caller LINKED to this host? Key
@@ -2177,6 +2275,9 @@ ${message}`;
2177
2275
  * Our operator's config anchors; the card shadow corroborates.
2178
2276
  */
2179
2277
  function callerLinkedToThisHost(caller, callerUrl, callerCard) {
2278
+ // gh#133 review: a CLI identity's card (team = its handle) is never a host link, whatever face asks.
2279
+ if (callerCard !== undefined && CLI_HANDLE_RE.test(callerCard.team))
2280
+ return false;
2180
2281
  // Basis 1, same-host membership (review second-round M-fix): "self" is
2181
2282
  // an ORIGIN equality, not a zone-name equality. `caller === config.team`
2182
2283
  // granted unconditionally in a single-zone fleet (the deployment
@@ -2184,7 +2285,7 @@ ${message}`;
2184
2285
  // shared zone string was "us". True self is the loopback self-fetch
2185
2286
  // face: the caller's card URL origin equals OUR card URL origin.
2186
2287
  try {
2187
- if (callerUrl !== '' && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
2288
+ if (callerUrl !== '' && isRootOriginUrl(callerUrl) && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
2188
2289
  return true;
2189
2290
  }
2190
2291
  catch { /* malformed callerUrl — falls through to the record bases */ }
@@ -2213,12 +2314,13 @@ ${message}`;
2213
2314
  // served-fresh field, exactly the self-description trust class
2214
2315
  // N3/V5 abolished. Our operator's own config anchors; the card
2215
2316
  // shadow only corroborates.
2317
+ // #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
2318
  if (callerCard !== undefined && ownCardUrl !== '') {
2217
2319
  const ourOrigin = new URL(ownCardUrl).origin;
2218
2320
  const callerOrigin = new URL(callerUrl).origin;
2219
2321
  const weSeedThem = config.peers.some(peer => {
2220
2322
  try {
2221
- return new URL(peer).origin === callerOrigin;
2323
+ return isRootOriginUrl(callerUrl) ? new URL(peer).origin === callerOrigin : sameMountedUrl(peer, callerUrl);
2222
2324
  }
2223
2325
  catch {
2224
2326
  return false;
@@ -2253,218 +2355,112 @@ ${message}`;
2253
2355
  * sessions' declarations. Used by both the inbound faces and the local
2254
2356
  * dispatch candidate so the two paths can never diverge.
2255
2357
  */
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)}`)]);
2358
+ // gh#114 S1: the admission rules live in src/task-auth.ts as one pure
2359
+ // decision over these records; the closures below are thin wrappers, so
2360
+ // the faces' refusal texts and the authorization record (S2) can never
2361
+ // disagree. Records are read at decision time, never copied.
2362
+ const admissionRecords = {
2363
+ zone: config.team,
2364
+ get teamScopeRouting() { return config.teamScopeRouting; },
2365
+ id8,
2366
+ canvasTeamOf,
2367
+ memberships: () => teamMemberships.list(),
2368
+ joined: () => joinedSessions.list(),
2369
+ canvasNames: () => canvasStore.list(),
2370
+ canvasTeamsOf: sessionId => canvasStore.teamsOf(sessionId),
2371
+ canvasMembersOf: name => canvasStore.membersOf(name),
2372
+ canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
2373
+ peerOrgs: function* () {
2300
2374
  for (const cached of cardCache.values()) {
2301
2375
  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
- }
2376
+ if (Array.isArray(org))
2377
+ yield org;
2310
2378
  }
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.`;
2379
+ },
2380
+ liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
2381
+ cliDeposited: handle => cliDeposits.get(handle) !== undefined,
2382
+ };
2383
+ /** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
2384
+ function sameZoneSharedTeamRefusal(caller, targetTeam) {
2385
+ return refusalOf(sameZoneAdmission(admissionRecords, caller, targetTeam));
2393
2386
  }
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;
2387
+ /** W1: the joined-face gate of the local dispatch candidate (see joinedFaceAdmission). */
2402
2388
  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);
2389
+ const verdict = joinedFaceAdmission(admissionRecords, callerSession, targetTeam);
2390
+ return verdict === undefined ? undefined : refusalOf(verdict);
2391
+ }
2392
+ /** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
2393
+ function a2aCallerAdmission(caller, targetTeam) {
2394
+ return callerAdmission(admissionRecords, caller, targetTeam);
2410
2395
  }
2411
2396
  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;
2397
+ return refusalOf(a2aCallerAdmission(caller, targetTeam));
2398
+ }
2399
+ /**
2400
+ * #114 S2: the allowlist version over the operator-controlled records the
2401
+ * admission rules read (design §2.3). The peer-card canvasOrg is left out:
2402
+ * a record admitted by it carries `peer-org:<team>` in its basis instead.
2403
+ */
2404
+ function allowlistVersionNow() {
2405
+ return allowlistVersion(allowlistInputsNow());
2406
+ }
2407
+ /** The records allowlistVersionNow hashes (design §2.3), read live. */
2408
+ function allowlistInputsNow() {
2409
+ const handle = (session) => `${config.team}/${id8(session)}`;
2410
+ return {
2411
+ zone: config.team,
2412
+ scope: [config.teamScopeRouting, config.originClaimWindow !== false],
2413
+ joined: joinedSessions.list().map(handle),
2414
+ roster: teamMemberships.list().flatMap(entry => entry.teams.map(team => [handle(entry.session), team])),
2415
+ // Local members by their handle (design §2.3), remote refs as they are stored.
2416
+ canvas: canvasStore.list().map(name => [canvasTeamOf(name), canvasStore.membersOf(name).map(m => (m.includes('/') ? m : handle(m)))]),
2417
+ cli: cliDeposits.list().map(row => row.handle),
2418
+ links: config.peers.map(peer => { try {
2419
+ return new URL(peer).origin;
2442
2420
  }
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;
2421
+ catch {
2422
+ return peer;
2423
+ } }),
2424
+ };
2425
+ }
2426
+ /*
2427
+ * #114 S6 (design §5): when the version moves, the unsettled tasks born
2428
+ * under another one are re-judged against today's records and the
2429
+ * affected ones recorded — OBSERVE ONLY: nothing is cancelled, no row's
2430
+ * state changes, no session is nudged, no receipt is blocked. Called
2431
+ * lazily (state/tasks reads, inbound admission) and at the known change
2432
+ * points (the stores' onChange hooks below); never on a timer.
2433
+ */
2434
+ const authObserver = new AuthObserver(join(home, 'a2a', 'auth-versions.json'));
2435
+ function observeAuthorization(cause) {
2436
+ const inputs = allowlistInputsNow();
2437
+ const entry = authObserver.observe({
2438
+ version: allowlistVersion(inputs),
2439
+ inputs: () => JSON.parse(canonicalAllowlist(inputs)),
2440
+ cause,
2441
+ rows: () => taskLedger.list(),
2442
+ // A local-candidate record (#139) is judged as the local candidate admits; every other one by the wire face.
2443
+ judge: (caller, target, ingress) => (ingress === 'local' ? localCandidateAdmission(admissionRecords, caller, target) : callerAdmission(admissionRecords, caller, target)),
2444
+ });
2445
+ if (entry === undefined)
2446
+ return;
2447
+ const revoke = entry.tasks.filter(task => task.verdict === 'would-revoke').length;
2448
+ 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`);
2449
+ try {
2450
+ ctx.emit('a2a/authorization-affected', entry);
2455
2451
  }
2456
- for (const agent of sessionNodes.values()) {
2457
- if (String(agent.id) === caller)
2458
- return undefined;
2452
+ catch (error) {
2453
+ logger.warn(`a2a: authorization-affected listener rejected: ${String(error)}`);
2459
2454
  }
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.`;
2455
+ }
2456
+ /** The /state `authorization` block: the version history (newest first, inputs omitted) and the affected entries. */
2457
+ function authorizationView() {
2458
+ observeAuthorization('lazy');
2459
+ return {
2460
+ version: allowlistVersionNow(),
2461
+ history: authObserver.history().map(({ version, at, cause }) => ({ version, at, cause })),
2462
+ affected: authObserver.entries(),
2463
+ };
2468
2464
  }
2469
2465
  /* ── Session-log read faces (shared by the local control route and the
2470
2466
  peer /a2a/log face): both answer from <dshHome>/sessions with
@@ -2539,11 +2535,14 @@ ${message}`;
2539
2535
  joinedSessions,
2540
2536
  idempotencyStore,
2541
2537
  verifyOriginClaim,
2542
- callerLinkedToThisHost,
2538
+ callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2543
2539
  recordActivity,
2544
2540
  bumpLegacyInbound: () => { legacyInboundCount += 1; },
2545
2541
  secretEqual,
2546
2542
  a2aCallerRefusal,
2543
+ a2aCallerAdmission,
2544
+ allowlistVersionNow,
2545
+ observeAuthorization,
2547
2546
  scanSessionLogList,
2548
2547
  readSessionLogTail,
2549
2548
  taskLedger,
@@ -2556,9 +2555,13 @@ ${message}`;
2556
2555
  canvasLiveAgent,
2557
2556
  wakeColdTeam,
2558
2557
  probeConsumption,
2558
+ sessionTeamOf,
2559
+ localSessionOfHandle,
2559
2560
  steerRelay,
2560
2561
  fromClause,
2562
+ sanitizeFromClause,
2561
2563
  armReceiptAutosend,
2564
+ eventCountOf: (agent) => sessionEventsOf(agent).length,
2562
2565
  receiptExpectationOf,
2563
2566
  armAsyncNudge,
2564
2567
  liveAgent,
@@ -2685,160 +2688,9 @@ ${message}`;
2685
2688
  });
2686
2689
  }),
2687
2690
  }), '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');
2691
+ // gh#77: the session-log faces (local transcript reader + remote-log proxy) live in src/faces/session-log.ts.
2692
+ registerSessionLogFaces(webServer, { ctx, config, controlRoute, scanSessionLogList, readSessionLogTail, peerStore, peerFetch, directoryPeerCandidates, memoizedCardFetch });
2693
+ registerFileFaces(webServer, { ctx, config, blobStore, canvasStore, verifyOriginClaim, secretEqual, controlRoute, recordActivity });
2842
2694
  });
2843
2695
  /**
2844
2696
  * Settle one card-fetch outcome into the peer store and the plain card:
@@ -2852,7 +2704,10 @@ ${message}`;
2852
2704
  */
2853
2705
  function settlePeerCard(peer, outcome) {
2854
2706
  if (!outcome.ok) {
2855
- peerStore.noteFailure(peer);
2707
+ // gh#193: a fetch the isolation switch refused says nothing about the
2708
+ // peer — scoring it would evict learned peers and rewrite peers.json.
2709
+ if (!config.isolated)
2710
+ peerStore.noteFailure(peer);
2856
2711
  return undefined;
2857
2712
  }
2858
2713
  // A self-referral learned from a peer's card (the peer lists this node's
@@ -2919,58 +2774,24 @@ ${message}`;
2919
2774
  */
2920
2775
  const cardCache = new Map();
2921
2776
  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. */
2777
+ // #114 S6: the change hooks and the boot observation start only once every
2778
+ // record admission reads exists (the peer-org basis reads cardCache above).
2779
+ // The cache is still empty here, so the observer skips cache-resting rows
2780
+ // on 'boot' (#138 R1); the first lazy read rechecks them.
2781
+ ctx.effect(() => canvasStore.onChange(() => observeAuthorization('canvas')), 'a2a: S6 canvas observer');
2782
+ ctx.effect(() => joinedSessions.onChange(() => observeAuthorization('joined')), 'a2a: S6 joined observer');
2783
+ ctx.effect(() => teamMemberships.onChange(() => observeAuthorization('roster')), 'a2a: S6 roster observer');
2784
+ observeAuthorization('boot');
2785
+ /** Security headers omit all unsigned peer display metadata. */
2971
2786
  function fromClause(from) {
2972
- const display = callerDisplayName(from);
2973
- return `from "${from}"${display !== '' ? `(${display})` : ''}`;
2787
+ const caller = from.replace(/[\x00-\x1f\x7f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\uff08\uff09\ufe59\ufe5a]/g, '');
2788
+ return `from ${JSON.stringify(caller)}`;
2789
+ }
2790
+ function sanitizeFromClause(from) {
2791
+ return fromClause(from);
2792
+ }
2793
+ function hostDirectHeader(team, taskId, from, proofClause) {
2794
+ return `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2974
2795
  }
2975
2796
  /**
2976
2797
  * gh#20 W-A: THIS host's view of one tracked peer's link state (the
@@ -3143,544 +2964,54 @@ ${message}`;
3143
2964
  // loud, structured, and schema-free (no success shape is produced).
3144
2965
  throw new Error('你被禁止使用 a2a 网络:本会话未加入 A2A 网络(join 是用户手势)。Join via the sidebar control to enable, then retry.');
3145
2966
  };
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;
2967
+ const SWEEP_CONCURRENCY = 6;
2968
+ const infoTools = createInfoTools({
2969
+ ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
2970
+ inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
2971
+ teamMemberships, exposureStore, idempotencyStore, cardCache,
2972
+ id8, sessionTeamOf,
2973
+ nodeMetadataOf, canvasTeamOf, memoizedCardFetch, mapBounded,
2974
+ // Hot-applied owner settings: getters keep the tools' call-time read live.
2975
+ // Passing these by value snapshots the apply-scope lets and silently
2976
+ // disables owner-settings hot-apply (whoami allowlist / teams scoping).
2977
+ get ownerAllowlist() { return ownerAllowlist; },
2978
+ get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
2979
+ teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
2980
+ settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
2981
+ SWEEP_CONCURRENCY,
3174
2982
  });
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
- };
2983
+ infoTools.registerWhoami();
2984
+ const actTools = createActTools({
2985
+ ctx, config, logger, sessionNodes, joinedSessions,
2986
+ teamMemberships, exposureStore, reportRing, recordTeamAudit,
2987
+ a2aJoinGateRefusal, id8, routeTrunk,
2988
+ // Owner-settings hot-apply REBINDS this let: the getter keeps the tool
2989
+ // module's call-time read live instead of snapshotting at construction.
2990
+ get ownerAllowlist() { return ownerAllowlist; },
2991
+ });
2992
+ actTools.registerTeamJoin();
2993
+ actTools.registerTeamLeave();
2994
+ actTools.registerExposure();
2995
+ actTools.registerReport();
2996
+ infoTools.registerTeams();
3560
2997
  /**
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).
2998
+ * The directory's listing: this host's own teams lead (the loopback
2999
+ * candidates a2a_route dials first) — the process team, then every joined
3000
+ * live session node with its title and activity facts; then every tracked
3001
+ * peer card's own team plus its joined session teams. Delegated names are
3002
+ * not listed — only names a tracked zone publishes directly appear;
3003
+ * delegations resolve at route time (a resolvable delegation always
3004
+ * aliases a listed team, so rows for them would repeat).
3005
+ *
3006
+ * Discovery is gossip: each fetch offers the card's referrals to the
3007
+ * store, and mid-iteration offers are normally fetched on the next call.
3008
+ * A search pass chases one extra hop within the call — after the first
3009
+ * sweep, peers that were newly offered get their own card fetched — so a
3010
+ * keyword hunt reaches sessions the previous call had not yet gossiped,
3011
+ * while staying bounded (one extra sweep, still under the store cap).
3012
+ * @param expand - chase one referral hop beyond the current store walk.
3566
3013
  */
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) {
3014
+ async function listDirectoryTeams(expand, includeCanvas = false) {
3684
3015
  const localOrigin = lanIp === '' ? `${session} [this host]` : `${session} [this host, ${lanIp}]`;
3685
3016
  const teams = [
3686
3017
  { team: config.team, session, name: config.agentName, description: '', local: true, origin: localOrigin },
@@ -3695,7 +3026,10 @@ ${message}`;
3695
3026
  // count are the facts a caller needs before routing — resolution picks
3696
3027
  // the first live member at route time.
3697
3028
  ...canvasStore.list().map(name => {
3698
- const members = canvasStore.membersOf(name);
3029
+ // gh#152: members as the local tiers read them (gh#131) — a handle
3030
+ // naming one of this host's own sessions counts as that member, and
3031
+ // the two stored shapes of one session collapse to one.
3032
+ const members = [...new Set(canvasLocalMembersOf(name))];
3699
3033
  const live = members.filter(id => sessionNodes.has(id)).length;
3700
3034
  return {
3701
3035
  team: canvasTeamOf(name),
@@ -3728,35 +3062,12 @@ ${message}`;
3728
3062
  const card = await fetch(peer);
3729
3063
  if (card === undefined)
3730
3064
  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;
3065
+ // Keep the GUI's remote node projection session-only; the model tool
3066
+ // opts into the additional canvas-team rows (#159).
3067
+ const rows = peerDirectoryRows(card, peer, includeCanvas);
3068
+ // gh#106: a card kept through a miss still lists (until its own
3069
+ // expiresAt), but the row says the peer did not answer the last read.
3070
+ return cardCache.get(peer)?.missAt !== undefined ? rows.map(row => ({ ...row, unreachable: true })) : rows;
3760
3071
  };
3761
3072
  // Bounded-concurrency sweep (mapBounded keeps row order by index, so the
3762
3073
  // listing still follows the peer store's preference order): a cold cache
@@ -3777,25 +3088,6 @@ ${message}`;
3777
3088
  }
3778
3089
  return teams;
3779
3090
  }
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
3091
  /**
3800
3092
  * Track one routed task in the ledger when its result leaves it owed a
3801
3093
  * receipt: ONLY an explicitly async dispatch (fire-and-forget) owes one —
@@ -3805,8 +3097,38 @@ ${message}`;
3805
3097
  * decision seat: sync Q&A must not enter pending; only real
3806
3098
  * receipt-awaiting rows stay on the book). A synchronous completion
3807
3099
  * already carries its answer and owes nothing.
3100
+ * #114 S3: `sender` is the real originator the row is attributed to.
3101
+ */
3102
+ /**
3103
+ * #139: what an in-process async delivery learned about its own birth —
3104
+ * the admission it ran under (design §2.4) and the member that received it
3105
+ * — handed from dispatchLocalCandidate to the caller's outbound row, keyed
3106
+ * by task id and taken (deleted) by whoever dispatched.
3808
3107
  */
3809
- const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief) => {
3108
+ const localBirths = new Map();
3109
+ const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
3110
+ const admission = localCandidateAdmission(admissionRecords, callerSession, team);
3111
+ const caller = callerSession ?? session;
3112
+ const auth = admission.ok && caller !== ''
3113
+ ? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
3114
+ : undefined;
3115
+ return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3116
+ };
3117
+ /**
3118
+ * #139: the peer facts a remote outbound row is born with — the signed
3119
+ * card's key and session label (the host identity whatever spelling of its
3120
+ * address dialed it) and, for a canvas row, the member handle the request
3121
+ * carried. The memo makes the card read free after the directory walk.
3122
+ */
3123
+ const peerBirth = async (fetch, candidate, member) => {
3124
+ const card = await fetch(candidate).catch(() => undefined);
3125
+ return {
3126
+ ...(card !== undefined && card.publicKey !== '' ? { peerKey: card.publicKey } : {}),
3127
+ ...(card !== undefined && card.session !== '' ? { peerHost: card.session } : {}),
3128
+ ...(member !== undefined ? { deliveredTo: member } : {}),
3129
+ };
3130
+ };
3131
+ const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
3810
3132
  if (!result.ok)
3811
3133
  return;
3812
3134
  // Native-teams bridge rounds emit no A2A receipt in this slice: booking
@@ -3822,10 +3144,10 @@ ${message}`;
3822
3144
  // row whose only exit was the dead-letter sweep (57 dead letters, half
3823
3145
  // this shape).
3824
3146
  if (callbackAddress === undefined) {
3825
- taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
3147
+ taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
3826
3148
  return;
3827
3149
  }
3828
- taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief);
3150
+ taskLedger.track(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
3829
3151
  };
3830
3152
  /**
3831
3153
  * Build the -32004 verdict. The error string is the calling model's ONLY
@@ -3844,6 +3166,9 @@ ${message}`;
3844
3166
  * routable; the dispatch registers receiptless per fix 2).
3845
3167
  */
3846
3168
  const routeVerdict = (team, failures, candidateCount) => {
3169
+ const ambiguous = failures.find(failure => failure.startsWith('ambiguous: '));
3170
+ if (candidateCount === 0 && ambiguous !== undefined)
3171
+ return { ok: false, error: ambiguous, code: -32004 };
3847
3172
  if (candidateCount > 0) {
3848
3173
  return {
3849
3174
  ok: false,
@@ -3872,49 +3197,11 @@ ${message}`;
3872
3197
  ].filter((line) => line !== undefined);
3873
3198
  return { ok: false, error: lines.join(' — '), code: -32004 };
3874
3199
  };
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
- }));
3200
+ actTools.registerRoute();
3201
+ // #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
3202
+ const fileTools = createFileTools({ ctx, config, blobStore, canvasMembersOf: name => canvasStore.membersOf(name), ownCardUrl: () => ownCardUrl, sessionTeamOf, a2aJoinGateRefusal, routeTrunk, peerFetch, originClaimHeaders });
3203
+ fileTools.registerFileSend();
3204
+ fileTools.registerFileFetch();
3918
3205
  /**
3919
3206
  * The routing trunk (0.6.9): the one failover sequence behind both the
3920
3207
  * a2a_route tool (agent face) and the quick-dispatch control route (host
@@ -3952,6 +3239,7 @@ ${message}`;
3952
3239
  // calls (no agent)
3953
3240
  // and the initiator face stay exempt, like the join gate; discovery
3954
3241
  // (a2a_teams) stays open so a teamless node can still see what to join.
3242
+ let scopedCanvasCaller;
3955
3243
  if (config.teamScopeRouting && agent !== undefined) {
3956
3244
  const scopeCaller = String(agent.id);
3957
3245
  let initiator = false;
@@ -3970,6 +3258,7 @@ ${message}`;
3970
3258
  // Canvas members mix two shapes: LOCAL sessions sit by session
3971
3259
  // id, cross-host members by zone/id8 handle — match either.
3972
3260
  const callerHandle = `${config.team}/${id8(scopeCaller)}`;
3261
+ scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3973
3262
  // gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
3974
3263
  // (`<team>/canvas/<name>`) never matches member handles — a
3975
3264
  // whole-group dispatch from a group member was refused. The
@@ -3980,6 +3269,11 @@ ${message}`;
3980
3269
  const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
3981
3270
  return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
3982
3271
  });
3272
+ // A direct group route must not require a prior directory call. Fetch
3273
+ // only the same eligible peers as resolution; then apply the
3274
+ // unchanged sender/receiver admission rules to their verified cards.
3275
+ if (team.includes('/canvas/') && !sharedCanvas && !callerTeams.includes(team))
3276
+ await mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch);
3983
3277
  let orgShared = false;
3984
3278
  for (const cached of cardCache.values()) {
3985
3279
  const org = cached.card?.canvasOrg;
@@ -3998,16 +3292,16 @@ ${message}`;
3998
3292
  break;
3999
3293
  }
4000
3294
  if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
4001
- return {
3295
+ return localRoute({
4002
3296
  ok: false,
4003
3297
  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
- };
3298
+ });
4005
3299
  }
4006
3300
  if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
4007
- return {
3301
+ return localRoute({
4008
3302
  ok: false,
4009
3303
  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
- };
3304
+ });
4011
3305
  }
4012
3306
  }
4013
3307
  }
@@ -4032,21 +3326,39 @@ ${message}`;
4032
3326
  const callbackAddress = routableCallerLabel(callerSession) || callerSession === config.team
4033
3327
  ? callerSession
4034
3328
  : undefined;
3329
+ // #114 S3: the outbound row records who really sent it instead of
3330
+ // every row reading as "home". A session is always its own node team
3331
+ // (<zone>/<id8>) — joined or not, initiator or not — so one seat keeps
3332
+ // one bucket. An agent-less call is the host process itself; the label
3333
+ // it borrowed on the wire (quick dispatch rides a live joined session's
3334
+ // team) is kept apart as `face`, so a human's workbench gesture never
3335
+ // lands in that session's seat.
3336
+ const sender = agent !== undefined
3337
+ ? { kind: 'session', id: sessionTeamOf(agent) }
3338
+ : { kind: 'host', id: session, ...(callerSession !== undefined && callerSession !== session ? { face: callerSession } : {}) };
4035
3339
  const fetch = memoizedCardFetch();
4036
3340
  // The task id is born at the caller (idempotency key semantics): both
4037
3341
  // dispatchers carry the one id, the peer request echoes it, and the
4038
3342
  // steered receipt header names it — so "[A2A receipt] task <id>" from
4039
3343
  // the target correlates with the route's own result verbatim.
4040
3344
  const taskId = `direct-${Math.random().toString(16).slice(2, 10)}`;
3345
+ const routedReceipt = parseReceipt(args.message) !== null;
4041
3346
  // Candidate order: this host's own teams first (an in-process steer —
4042
3347
  // cheapest possible and immune to nested-signal aborts), then direct
4043
3348
  // publishers, then zone delegations, deduplicated by URL.
4044
3349
  const failures = [];
3350
+ if (signal?.aborted)
3351
+ return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
4045
3352
  const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3353
+ const localBirth = localBirths.get(taskId);
3354
+ localBirths.delete(taskId);
4046
3355
  if (outcome !== undefined) {
4047
3356
  if (outcome.ok) {
4048
- trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message));
4049
- return outcome;
3357
+ // gh#141: a receipt owes no receipt — booking it would strand a row nothing settles.
3358
+ if (!routedReceipt)
3359
+ trackOwedTask(taskId, args.team, 'local', outcome, callbackAddress, briefOf(args.message), sender, localBirth);
3360
+ // gh#198: host-built delivery notes, or a reply from a clean local session (resolved as routeIntoAgent does), are this host's alone.
3361
+ 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
3362
  }
4051
3363
  failures.push(`local: ${outcome.error}`);
4052
3364
  }
@@ -4059,7 +3371,8 @@ ${message}`;
4059
3371
  // unchanged. The ledger stays keyed to the canvas team the caller
4060
3372
  // named; the peer request carries the member handle it must resolve.
4061
3373
  const canvasName = parseCanvasTeamName(args.team);
4062
- const remoteTargets = canvasName !== undefined ? canvasStore.remoteMembersOf(canvasName) : [];
3374
+ // gh#131: one of this host's own handles is a local member, never a directory target.
3375
+ const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
4063
3376
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
4064
3377
  if (remoteTargets.length > 0) {
4065
3378
  failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
@@ -4073,7 +3386,7 @@ ${message}`;
4073
3386
  }
4074
3387
  const candidatePool = [];
4075
3388
  for (const target of targets) {
4076
- for (const candidate of await directoryPeerCandidates(fetch, target, failures)) {
3389
+ for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
4077
3390
  candidatePool.push({ candidate, target });
4078
3391
  }
4079
3392
  }
@@ -4094,8 +3407,13 @@ ${message}`;
4094
3407
  }
4095
3408
  const result = await dispatchPeerCandidate(candidate, target, args.message, args.context_id, signal, callerSession, peerAsync, taskId, callbackAddress);
4096
3409
  if (result.ok || signal.aborted) {
4097
- if (result.ok)
4098
- trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message));
3410
+ // A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
3411
+ if (result.ok && routedReceipt) {
3412
+ if (result.settlementRefused !== true)
3413
+ settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' });
3414
+ }
3415
+ else if (result.ok)
3416
+ trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
4099
3417
  const notes = [];
4100
3418
  if (result.ok && args.async === true && result.task_status === 'TASK_STATE_DELIVERED' && !routableCallerLabel(callerSession)) {
4101
3419
  notes.push(unroutableCallerNote(String(callerSession ?? session)));
@@ -4141,6 +3459,12 @@ ${message}`;
4141
3459
  recordActivity('out', team, callerSession ?? 'local', false);
4142
3460
  return { ok: false, error: joinedRefusal, code: -32000 };
4143
3461
  }
3462
+ // #114 S5: the same header-safe id rule the wire face enforces.
3463
+ const idRefusal = taskIdFromCaller === undefined ? undefined : taskIdRefusal(taskIdFromCaller);
3464
+ if (idRefusal !== undefined) {
3465
+ recordActivity('out', team, callerSession ?? 'local', false);
3466
+ return { ok: false, error: idRefusal, code: -32000 };
3467
+ }
4144
3468
  const canvasName = parseCanvasTeamName(team);
4145
3469
  // Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
4146
3470
  // or a session label) and quick-dispatch BORROWS a live session's team
@@ -4159,8 +3483,14 @@ ${message}`;
4159
3483
  // gh#45 CLI mailbox: a delivery targeting a DEPOSITED CLI handle is
4160
3484
  // local by definition — it lands in the deposit's mailbox (the CLI
4161
3485
  // polls while alive; receipts and messages never wake anything).
3486
+ // The settler of any receipt on this path: this host's own caller (in-process).
3487
+ const localSettler = { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' };
4162
3488
  const cliRow = cliDeposits.get(team);
4163
3489
  if (cliRow !== undefined) {
3490
+ // #114 S4 (design §4.5): a receipt dropped into a local CLI mailbox is
3491
+ // exactly the settlement a CLI-originated task waits for — settle it,
3492
+ // as the wire face settles before ITS mailbox split.
3493
+ settleAndAnnounce(message, localSettler);
4164
3494
  cliDeposits.mail(team, callerSession ?? 'unknown', message);
4165
3495
  recordActivity('in', team, callerSession ?? 'peer', true);
4166
3496
  logger.info(`a2a: cli mailbox delivery for ${team} (from ${callerSession ?? 'unknown'})`);
@@ -4176,7 +3506,7 @@ ${message}`;
4176
3506
  // A receipt relayed between same-host sessions (the answering session
4177
3507
  // routing its `[A2A receipt]` back over the in-process candidate)
4178
3508
  // correlates here too — same contract, no HTTP on the path.
4179
- settleAndAnnounce(message);
3509
+ settleAndAnnounce(message, localSettler);
4180
3510
  const taskId = taskIdFromCaller ?? `direct-${Math.random().toString(16).slice(2, 10)}`;
4181
3511
  const flight = beginRoute(team, 'local');
4182
3512
  if (asyncMode) {
@@ -4205,7 +3535,7 @@ ${message}`;
4205
3535
  if (prepared.ok) {
4206
3536
  endRoute(flight);
4207
3537
  recordActivity('out', team, 'local', true);
4208
- void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId)
3538
+ void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', undefined, hostDirectHeader)
4209
3539
  .then(outcome => {
4210
3540
  if (!outcome.ok) {
4211
3541
  recordActivity('out', team, 'local', false);
@@ -4254,8 +3584,13 @@ ${message}`;
4254
3584
  ? callbackAddress
4255
3585
  : routable ? callerSession : undefined;
4256
3586
  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);
3587
+ // #114 S5: the consumption search starts at the pre-steer log length.
3588
+ const steerFloor = sessionEventsOf(target).length;
3589
+ steerRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId);
3590
+ armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
3591
+ // #139: the caller's outbound row is born with this admission and the member that received it.
3592
+ if (taskIdFromCaller !== undefined)
3593
+ localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
4259
3594
  endRoute(flight);
4260
3595
  recordActivity('out', team, 'local', true);
4261
3596
  return {
@@ -4289,7 +3624,7 @@ ${message}`;
4289
3624
  if (!steerable && team !== config.team) {
4290
3625
  const prepared = await nativeTeamsPrepare(team);
4291
3626
  if (prepared.ok) {
4292
- const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, signal);
3627
+ const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', signal, hostDirectHeader);
4293
3628
  endRoute(flight);
4294
3629
  if (!outcome.ok) {
4295
3630
  // The aborted-wait shape is earned only by an actual round
@@ -4330,7 +3665,7 @@ ${message}`;
4330
3665
  }
4331
3666
  }
4332
3667
  }
4333
- const wait = routeIntoAgent(team, message, callerSession ?? session, taskId);
3668
+ const wait = routeIntoAgent(team, message, callerSession ?? session, taskId, undefined, undefined, originAgentId);
4334
3669
  const aborted = new Promise((_, reject) => {
4335
3670
  signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
4336
3671
  });
@@ -4439,10 +3774,13 @@ ${message}`;
4439
3774
  */
4440
3775
  async function dispatchAnywhere(team, message, callerSession, contextId, signal, asyncMode, taskId) {
4441
3776
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
3777
+ // A background send books no outbound row: its birth facts have no row to go on.
3778
+ if (taskId !== undefined)
3779
+ localBirths.delete(taskId);
4442
3780
  if (local !== undefined) {
4443
3781
  if (!local.ok)
4444
3782
  throw new Error(local.error);
4445
- return;
3783
+ return local;
4446
3784
  }
4447
3785
  const failures = [];
4448
3786
  const candidates = await directoryPeerCandidates(memoizedCardFetch(), team, failures);
@@ -4453,7 +3791,7 @@ ${message}`;
4453
3791
  for (const candidate of candidates) {
4454
3792
  const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, callerSession, asyncMode, taskId);
4455
3793
  if (result.ok)
4456
- return;
3794
+ return result;
4457
3795
  lastError = result.error;
4458
3796
  if (signal.aborted)
4459
3797
  break;
@@ -4471,7 +3809,6 @@ ${message}`;
4471
3809
  */
4472
3810
  const CANDIDATE_CONCURRENCY = 6;
4473
3811
  /** Concurrent card fetches in one directory sweep (cold-cache burst bound). */
4474
- const SWEEP_CONCURRENCY = 6;
4475
3812
  async function mapBounded(items, limit, task) {
4476
3813
  const results = new Array(items.length);
4477
3814
  let next = 0;
@@ -4485,7 +3822,10 @@ ${message}`;
4485
3822
  await Promise.all(workers);
4486
3823
  return results;
4487
3824
  }
4488
- async function directoryPeerCandidates(fetch, team, failures) {
3825
+ function resolutionPeers() {
3826
+ return config.referralTeamResolution === true ? peerStore.list() : [...new Set([...peerStore.listSeeded(), ...config.delegates.map(delegate => delegate.url)])];
3827
+ }
3828
+ async function directoryPeerCandidates(fetch, team, failures, scopedCanvasCaller) {
4489
3829
  // gh#8 (N9): a team name resolves only to hosts an operator linked —
4490
3830
  // the seeded subset of the peer store (config seeds, eviction-immune)
4491
3831
  // plus the delegate records (config-time operator acts). The
@@ -4494,16 +3834,23 @@ ${message}`;
4494
3834
  // (gh#8: a squatter two gossip hops away captured routes by naming
4495
3835
  // itself after the target). referralTeamResolution restores the legacy
4496
3836
  // 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)])];
3837
+ const peers = resolutionPeers();
4498
3838
  const candidates = [];
4499
3839
  const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
3840
+ // Check after fetching all cards: a seeded peer publishing the same id8
3841
+ // makes the caller handle ambiguous, so do not send a canvas payload.
3842
+ const resolveLocal = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3843
+ const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3844
+ if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
3845
+ failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
3846
+ return [];
3847
+ }
4500
3848
  for (const entry of cards) {
4501
3849
  if (entry.card === undefined)
4502
3850
  continue;
4503
- if (entry.card.team === team || (entry.card.sessionTeams ?? []).some(item => item.team === team))
3851
+ if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
4504
3852
  candidates.push(entry.peer);
4505
3853
  }
4506
- const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
4507
3854
  for (const { peer, outcome } of resolutions) {
4508
3855
  if (outcome.ok) {
4509
3856
  if (!candidates.includes(outcome.url))
@@ -4564,6 +3911,14 @@ ${message}`;
4564
3911
  if (sessionNodes.has(parent) || joinedSessions.has(parent))
4565
3912
  callbackAddress = `${config.team}/${id8(parent)}`;
4566
3913
  }
3914
+ // #114 S3: the submitting session's node team, but only when the
3915
+ // transport names a session this host knows (a node, a join, or a live
3916
+ // root) — an unchecked id would let any submitter mint a seat. Anything
3917
+ // else (absent, unknown, non-string) is attributed to the host label.
3918
+ const parentSession = request.callbackTarget?.parentSessionId;
3919
+ const knownParent = typeof parentSession === 'string' && parentSession !== ''
3920
+ && (sessionNodes.has(parentSession) || joinedSessions.has(parentSession) || liveRoots.has(parentSession));
3921
+ const sender = { kind: 'bridge', id: knownParent ? `${config.team}/${id8(parentSession)}` : session };
4567
3922
  for (const candidate of candidates) {
4568
3923
  // Capability gate per candidate, like a2a_route's loop: a
4569
3924
  // delivery:async submit never dials a peer whose signed card does
@@ -4607,7 +3962,7 @@ ${message}`;
4607
3962
  break;
4608
3963
  continue;
4609
3964
  }
4610
- trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message));
3965
+ trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4611
3966
  const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
4612
3967
  if (result.task_status === 'TASK_STATE_COMPLETED') {
4613
3968
  return { kind: 'completed', text: result.reply, taskId, contextId };
@@ -4712,368 +4067,15 @@ ${message}`;
4712
4067
  },
4713
4068
  };
4714
4069
  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
- }));
4070
+ infoTools.registerStatus();
4071
+ infoTools.registerTasks();
4072
+ infoTools.registerProbe();
5072
4073
  const pendingFinals = new Map();
5073
4074
  const armFlushTimeout = (agentId, waiter) => {
5074
4075
  return ctx.timer.timeout(() => {
5075
4076
  const entries = pendingFinals.get(agentId);
5076
- if (entries === undefined)
4077
+ // An already-answered waiter is gone from the list: never answer twice.
4078
+ if (entries === undefined || !entries.includes(waiter))
5077
4079
  return;
5078
4080
  const kept = entries.filter(entry => entry !== waiter);
5079
4081
  if (kept.length === 0)
@@ -5096,27 +4098,114 @@ ${message}`;
5096
4098
  pendingFinals.delete(agentId);
5097
4099
  return;
5098
4100
  }
4101
+ // #114 S5: each waiter is answered from its OWN floor. The old shared
4102
+ // floor (the minimum sinceEvents) answered every waiter with one text, so
4103
+ // a busy target's other work line settled tasks it had not read yet.
5099
4104
  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--) {
4105
+ const answered = [];
4106
+ // Logs that record turn boundaries bound each waiter to its consuming
4107
+ // turn; a log without any marker falls back to "this idle ends it".
4108
+ const turnMarked = events.some(event => event?.type === 'turn/start' || event?.type === 'turn/end');
4109
+ for (const entry of entries) {
4110
+ if (entry.taskId !== undefined) {
4111
+ entry.consumedAt ??= consumptionIndex(events, entry.taskId, entry.scannedTo ?? entry.sinceEvents);
4112
+ if (entry.consumedAt === undefined) {
4113
+ // Not consumed yet (the steer waits for the running turn's next
4114
+ // step, or the next turn): no final belongs to it — keep waiting.
4115
+ entry.scannedTo = events.length;
4116
+ continue;
4117
+ }
4118
+ // The consuming turn's window: up to its own `turn/end`, never into a
4119
+ // following turn (back-to-back turns can run with no idle between).
4120
+ // Without turn markers, this idle is the turn's end.
4121
+ let end = events.length;
4122
+ let completed = true;
4123
+ if (turnMarked) {
4124
+ const close = turnEndIndex(events, entry.consumedAt + 1);
4125
+ if (close === undefined) {
4126
+ // The consuming turn is still open: wait for its turn/end.
4127
+ continue;
4128
+ }
4129
+ end = close;
4130
+ completed = events[close]?.data?.reason?.kind === 'completed';
4131
+ }
4132
+ // The waiter closes HERE with that turn's last text. A turn that said
4133
+ // nothing, or ended aborted/failed/blocked, did no work this host can
4134
+ // vouch for: close as a placeholder — no receipt, the row stays owed.
4135
+ const reply = latestAssistantText(events, entry.consumedAt + 1, end);
4136
+ answered.push(reply !== '' && completed ? { entry, reply } : { entry, reply: '', placeholder: true });
4137
+ continue;
4138
+ }
4139
+ const reply = latestAssistantText(events, entry.sinceEvents);
4140
+ if (reply !== '')
4141
+ answered.push({ entry, reply });
4142
+ }
4143
+ if (answered.length === 0)
4144
+ return;
4145
+ const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
4146
+ if (kept.length === 0)
4147
+ pendingFinals.delete(agentId);
4148
+ else
4149
+ pendingFinals.set(agentId, kept);
4150
+ for (const { entry, reply, placeholder } of answered) {
4151
+ entry.timeoutDisposer?.();
4152
+ if (placeholder === true)
4153
+ entry.answer('The consuming turn produced no completed reply.', true);
4154
+ else
4155
+ entry.answer(reply);
4156
+ }
4157
+ }
4158
+ /** #114 S5: the index of the first `turn/end` at or after `from`, or undefined while the turn is open. */
4159
+ function turnEndIndex(events, from) {
4160
+ for (let index = from; index < events.length; index++) {
4161
+ if (events[index]?.type === 'turn/end')
4162
+ return index;
4163
+ }
4164
+ return undefined;
4165
+ }
4166
+ /** #114 S5: the steer header both delivery paths write; the id is captured whole, never prefix-matched. */
4167
+ const STEER_HEADER = /^\[A2A direct\] \(task ([^\s)]+)\) /;
4168
+ /**
4169
+ * #114 S5: the index of the user/message that consumed one task's steer —
4170
+ * the first at or after `from` whose first text block opens with the
4171
+ * `[A2A direct] (task <id>)` header for exactly this id.
4172
+ * @param from - first index not yet searched (starts at the pre-steer log length).
4173
+ * @returns the index, or undefined while the steer is still unconsumed.
4174
+ */
4175
+ function consumptionIndex(events, taskId, from) {
4176
+ for (let index = from; index < events.length; index++) {
4177
+ const event = events[index];
4178
+ if (event?.type !== 'user/message')
4179
+ continue;
4180
+ const first = event.data?.content?.find(block => block.type === 'text' && typeof block.text === 'string');
4181
+ if (STEER_HEADER.exec(first?.text ?? '')?.[1] === taskId)
4182
+ return index;
4183
+ }
4184
+ return undefined;
4185
+ }
4186
+ /**
4187
+ * The newest assistant text at or after `floor`: at an idle flush that is
4188
+ * the final of the turn that just ended. Textless messages (tool-only, or
4189
+ * empty/whitespace text) are skipped, so a turn ending on a tool call still
4190
+ * answers with the last thing it said. '' when no message has text.
4191
+ * @param end - exclusive upper bound (default: the whole log).
4192
+ */
4193
+ function latestAssistantText(events, floor, end = events.length) {
4194
+ for (let index = end - 1; index >= floor; index--) {
5103
4195
  const event = events[index];
5104
4196
  if (event === undefined || event.type !== 'assistant/message')
5105
4197
  continue;
5106
4198
  const content = event.data?.message?.content;
5107
4199
  if (content === undefined)
5108
4200
  continue;
5109
- reply = content
4201
+ const text = content
5110
4202
  .filter((block) => block.type === 'text' && typeof block.text === 'string')
5111
4203
  .map(block => block.text)
5112
4204
  .join('\n');
5113
- break;
5114
- }
5115
- if (reply !== '') {
5116
- for (const entry of entries)
5117
- entry.answer(reply);
5118
- pendingFinals.delete(agentId);
4205
+ if (text.trim() !== '')
4206
+ return text;
5119
4207
  }
4208
+ return '';
5120
4209
  }
5121
4210
  // Every inbound route is a direct route (final semantics), so the idle
5122
4211
  // flush listens unconditionally — an empty pendingFinals map makes it a