@nelsonlongxiang/dsh-open-a2a-net 0.7.0 → 0.8.1-preview.1

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -94
  3. package/README_zh.md +64 -66
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/nexusDist/assets/index-BuidhKVr.css +1 -0
  6. package/assets/nexusDist/assets/index-YtYrkDJi.js +9 -0
  7. package/assets/nexusDist/index.html +2 -2
  8. package/cli/a2a.mjs +349 -20
  9. package/cordis.patch.yml +167 -0
  10. package/docs/licenses/dsh-superagent-MIT.txt +21 -0
  11. package/lib/a2a-client.js +12 -4
  12. package/lib/card.js +2 -0
  13. package/lib/cli-deposit.js +75 -4
  14. package/lib/config.js +1 -2
  15. package/lib/direct-forward.js +23 -1
  16. package/lib/faces/admin.js +31 -48
  17. package/lib/faces/wire.js +110 -71
  18. package/lib/followup-admission.js +47 -0
  19. package/lib/followup-delivery.js +19 -2
  20. package/lib/index.js +618 -389
  21. package/lib/node-self.js +1 -1
  22. package/lib/outbound-scope.js +10 -0
  23. package/lib/peer-directory.js +3 -1
  24. package/lib/queue-guard.js +1 -1
  25. package/lib/receipt.js +24 -2
  26. package/lib/skill/SKILL.md +1 -0
  27. package/lib/skill/references/cli.md +193 -21
  28. package/lib/skill/references/collaboration.md +3 -2
  29. package/lib/skill/references/superagent.md +106 -0
  30. package/lib/superagent/a2a-watch.js +587 -0
  31. package/lib/superagent/contract.js +1 -0
  32. package/lib/superagent/host/a2a-source.js +251 -0
  33. package/lib/superagent/host/executor.js +520 -0
  34. package/lib/superagent/host/index.js +1107 -0
  35. package/lib/superagent/host/policy.js +308 -0
  36. package/lib/superagent/host/pr-follow.js +158 -0
  37. package/lib/superagent/host/review-requests.js +77 -0
  38. package/lib/superagent/host/role-tools.js +137 -0
  39. package/lib/superagent/host/store.js +136 -0
  40. package/lib/superagent/host/tools.js +297 -0
  41. package/lib/superagent/source-types.js +22 -0
  42. package/lib/superagent/upstream/dsh-superagent/src/core/assignments.js +160 -0
  43. package/lib/superagent/upstream/dsh-superagent/src/core/engine.js +244 -0
  44. package/lib/superagent/upstream/dsh-superagent/src/core/errors.js +63 -0
  45. package/lib/superagent/upstream/dsh-superagent/src/core/evals.js +381 -0
  46. package/lib/superagent/upstream/dsh-superagent/src/core/ids.js +5 -0
  47. package/lib/superagent/upstream/dsh-superagent/src/core/index.js +9 -0
  48. package/lib/superagent/upstream/dsh-superagent/src/core/intake.js +68 -0
  49. package/lib/superagent/upstream/dsh-superagent/src/core/memory.js +51 -0
  50. package/lib/superagent/upstream/dsh-superagent/src/core/model.js +79 -0
  51. package/lib/superagent/upstream/dsh-superagent/src/core/part.js +71 -0
  52. package/lib/superagent/upstream/dsh-superagent/src/core/ports.js +1 -0
  53. package/lib/superagent/upstream/dsh-superagent/src/core/reasons.js +35 -0
  54. package/lib/superagent/upstream/dsh-superagent/src/core/replies.js +94 -0
  55. package/lib/superagent/upstream/dsh-superagent/src/core/stage.js +27 -0
  56. package/lib/superagent/upstream/dsh-superagent/src/core/tickets.js +487 -0
  57. package/lib/superagent/upstream/snapshot-8c32a9d.js +62 -0
  58. package/lib/task-auth.js +21 -0
  59. package/lib/task-ledger-rows.js +4 -0
  60. package/lib/task-ledger.js +69 -3
  61. package/lib/tools/info.js +13 -7
  62. package/lib/types/a2a-client.d.ts +0 -7
  63. package/lib/types/cli-deposit.d.ts +65 -1
  64. package/lib/types/config.d.ts +6 -20
  65. package/lib/types/direct-forward.d.ts +14 -0
  66. package/lib/types/faces/admin.d.ts +5 -0
  67. package/lib/types/faces/wire.d.ts +20 -57
  68. package/lib/types/followup-admission.d.ts +13 -0
  69. package/lib/types/followup-delivery.d.ts +1 -1
  70. package/lib/types/index.d.ts +0 -2
  71. package/lib/types/outbound-scope.d.ts +1 -0
  72. package/lib/types/queue-guard.d.ts +2 -0
  73. package/lib/types/receipt.d.ts +21 -1
  74. package/lib/types/superagent/a2a-watch.d.ts +227 -0
  75. package/lib/types/superagent/contract.d.ts +36 -0
  76. package/lib/types/superagent/host/a2a-source.d.ts +90 -0
  77. package/lib/types/superagent/host/executor.d.ts +132 -0
  78. package/lib/types/superagent/host/index.d.ts +340 -0
  79. package/lib/types/superagent/host/policy.d.ts +128 -0
  80. package/lib/types/superagent/host/pr-follow.d.ts +51 -0
  81. package/lib/types/superagent/host/review-requests.d.ts +36 -0
  82. package/lib/types/superagent/host/role-tools.d.ts +85 -0
  83. package/lib/types/superagent/host/store.d.ts +61 -0
  84. package/lib/types/superagent/host/tools.d.ts +32 -0
  85. package/lib/types/superagent/source-types.d.ts +218 -0
  86. package/lib/types/superagent/upstream/dsh-superagent/src/core/assignments.d.ts +53 -0
  87. package/lib/types/superagent/upstream/dsh-superagent/src/core/engine.d.ts +191 -0
  88. package/lib/types/superagent/upstream/dsh-superagent/src/core/errors.d.ts +58 -0
  89. package/lib/types/superagent/upstream/dsh-superagent/src/core/evals.d.ts +99 -0
  90. package/lib/types/superagent/upstream/dsh-superagent/src/core/ids.d.ts +1 -0
  91. package/lib/types/superagent/upstream/dsh-superagent/src/core/index.d.ts +9 -0
  92. package/lib/types/superagent/upstream/dsh-superagent/src/core/intake.d.ts +15 -0
  93. package/lib/types/superagent/upstream/dsh-superagent/src/core/memory.d.ts +40 -0
  94. package/lib/types/superagent/upstream/dsh-superagent/src/core/model.d.ts +432 -0
  95. package/lib/types/superagent/upstream/dsh-superagent/src/core/part.d.ts +49 -0
  96. package/lib/types/superagent/upstream/dsh-superagent/src/core/ports.d.ts +217 -0
  97. package/lib/types/superagent/upstream/dsh-superagent/src/core/reasons.d.ts +36 -0
  98. package/lib/types/superagent/upstream/dsh-superagent/src/core/replies.d.ts +55 -0
  99. package/lib/types/superagent/upstream/dsh-superagent/src/core/stage.d.ts +18 -0
  100. package/lib/types/superagent/upstream/dsh-superagent/src/core/tickets.d.ts +151 -0
  101. package/lib/types/superagent/upstream/snapshot-8c32a9d.d.ts +317 -0
  102. package/lib/types/task-auth.d.ts +9 -0
  103. package/lib/types/task-ledger-rows.d.ts +21 -4
  104. package/lib/types/task-ledger.d.ts +9 -0
  105. package/lib/types/types.d.ts +7 -4
  106. package/nexus-stage/scripts/mockhost.py +2 -2
  107. package/nexus-stage/src/main.ts +27 -0
  108. package/nexus-stage/src/planning-card-text.ts +1 -1
  109. package/nexus-stage/src/planning-team-roles.ts +80 -0
  110. package/nexus-stage/src/planning-types.ts +2 -0
  111. package/nexus-stage/src/planning-view.ts +1 -0
  112. package/nexus-stage/src/planning.css +29 -9
  113. package/nexus-stage/src/wb-superagent.ts +518 -0
  114. package/nexus-stage/src/workbench.css +32 -0
  115. package/nexus-stage/src/workbench.ts +25 -3
  116. package/nexus-stage/src/world.ts +1 -1
  117. package/package.json +8 -2
  118. package/assets/nexusDist/assets/index-C7HagaGy.css +0 -1
  119. package/assets/nexusDist/assets/index-CA_YZTWG.js +0 -9
  120. package/assets/stageDist/assets/index-Bj7S2pA1.css +0 -1
  121. package/assets/stageDist/assets/index-C-28k_o5.js +0 -974
  122. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  123. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  124. package/assets/stageDist/index.html +0 -13
  125. package/lib/legacy/teams-bridge-inbound.js +0 -156
  126. package/lib/teams-bridge.js +0 -18
  127. package/lib/types/legacy/teams-bridge-inbound.d.ts +0 -94
  128. package/lib/types/teams-bridge.d.ts +0 -112
package/lib/index.js CHANGED
@@ -46,21 +46,25 @@ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
46
46
  import { TeamMembershipStore } from "./team-store.js";
47
47
  import { PeerStore } from "./peer-store.js";
48
48
  import { SelfReferralFilter } from "./self-suppress.js";
49
- import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
49
+ import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
50
50
  import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
51
+ import { SuperagentHost } from "./superagent/host/index.js";
52
+ import { registerSuperagentTools } from "./superagent/host/tools.js";
53
+ import { RoleTools } from "./superagent/host/role-tools.js";
51
54
  import { ReconciliationReminders } from "./reconciliation-reminders.js";
52
55
  import { REMINDER_SCAN_MS, reminderView } from "./reconciliation-state.js";
53
56
  import { AdmittedBookkeepingError, ADMITTED_BOOKKEEPING_NOTE } from "./admitted-bookkeeping.js";
57
+ import { FollowupAcceptanceUnknown, ACCEPTANCE_UNKNOWN_NOTE, ACCEPTANCE_INSERTED_NOTE } from "./followup-admission.js";
54
58
  import { runReceiptLadder } from "./receipt-ladder.js";
55
59
  import { receiptFailure, retryReceiptDelivery, transientReceiptFailure, receiptRetryWait } from "./receipt-retry.js";
56
- import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
60
+ import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, peerPayloadFingerprint } from "./idempotency-store.js";
57
61
  import { createFloodGuard } from "./flood-guard.js";
58
62
  import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
59
63
  import { resolveZone } from "./zone.js";
60
64
  import { registerA2aNetworkSkill } from "./skill-registration.js";
61
65
  import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
62
66
  import { AuthObserver } from "./auth-observer.js";
63
- import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
67
+ import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization, recheckCallerAdmission } from "./task-auth.js";
64
68
  import { MessageId } from '@deepseek-ai/dsh-llm';
65
69
  import { FollowupDelivery } from "./followup-delivery.js";
66
70
  import { SessionId } from '@deepseek-ai/dsh-session';
@@ -81,16 +85,13 @@ import { registerFileFaces } from "./faces/files.js";
81
85
  import { createFileTools } from "./tools/files.js";
82
86
  import { BlobStore } from "./blob-store.js";
83
87
  import { canvasCandidateMatches, canvasTargetAmbiguous, peerDirectoryRows, routableCallerLabel, sessionCandidateMatches, unroutableCallerNote } from "./peer-directory.js";
84
- import { createTeamsBridgeInbound } from "./legacy/teams-bridge-inbound.js";
85
88
  import { createActTools } from "./tools/act.js";
86
- import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
87
89
  export { A2aClient } from "./a2a-client.js";
88
- export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
89
90
  export const name = 'a2a';
90
91
  export const inject = ['tools', 'timer', 'systemPrompt'];
91
92
  import { Config } from "./config.js";
92
93
  import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
93
- import { forwardReceiptBasis } from "./direct-forward.js";
94
+ import { forwardReceiptBasis, teamReceiptBasis } from "./direct-forward.js";
94
95
  import { NudgeObservability } from "./nudge-observability.js";
95
96
  export { Config };
96
97
  /**
@@ -665,7 +666,7 @@ export function apply(ctx, config) {
665
666
  }
666
667
  /**
667
668
  * Settle one message through the ledger and announce what it correlated
668
- * on `a2a/receipt-resolved` — the seam peer plugins (native-teams)
669
+ * on `a2a/receipt-resolved` — the seam peer plugins
669
670
  * consume to settle their outstanding async submissions. Fence honesty:
670
671
  * the try/catch contains SYNCHRONOUS listener throws (cordis dispatch is
671
672
  * synchronous, so a throw lands here and the routing path survives); an
@@ -696,6 +697,11 @@ export function apply(ctx, config) {
696
697
  // (enforce): that is the verdict — the echo fallback must not judge the
697
698
  // same message a second time.
698
699
  const owedRow = parsed !== null && parsed.taskId !== '' ? taskLedger.find(parsed.taskId) : undefined;
700
+ // p1-mailbox-owes (S33, a): a row owed by a CLI identity on this host (the inbound row an owing deposit keeps, or a
701
+ // same-host caller's local row) is answered by that identity alone — keyed on the row, so it holds after the deposit
702
+ // lapses too. A receipt header from anyone else names it without touching it: no settlement, no echo, no outcome.
703
+ if (owedRow !== undefined && (owedRow.direction === 'inbound' || owedRow.peer === 'local') && CLI_HANDLE_RE.test(owedRow.team) && settler?.caller !== owedRow.team)
704
+ return undefined;
699
705
  const owedByHeader = owedRow !== undefined;
700
706
  // #139 (design §4.4): an enforce refusal of the row the header names is
701
707
  // told to the sender on the wire response, not only in /state.
@@ -703,6 +709,15 @@ export function apply(ctx, config) {
703
709
  // Capture authority while the owed row is still present; successful resolve archives it.
704
710
  const outcomeAllowed = judge?.enforce !== true || (owedRow !== undefined && headerVerdict?.ok === true);
705
711
  const resolved = taskLedger.resolveFromMessage(message, judge);
712
+ // An acknowledgement marks the owed row and settles nothing: no waiter is answered, no outcome is recorded, and the
713
+ // settlement seam is not told (its consumers treat a resolution as the end of the task).
714
+ if (parsed?.envelope?.disposition === 'acknowledged') {
715
+ if (resolved?.acknowledged === true)
716
+ logger.info(`a2a: task ${resolved.taskId} acknowledged by its target; it stays owed until the result`);
717
+ return undefined;
718
+ }
719
+ if (resolved !== undefined)
720
+ pruneReceiptWaiters();
706
721
  if (parsed !== null && parsed.taskId !== '') {
707
722
  const line = parsed.summary.trim();
708
723
  // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
@@ -726,6 +741,7 @@ export function apply(ctx, config) {
726
741
  // settles. Formal receipt headers keep observing; settlementAuth 'off' still judges nothing.
727
742
  const echoed = taskLedger.resolveEcho(message, judge !== undefined && !judge.enforce ? { ...judge, enforce: true } : judge);
728
743
  if (echoed.length > 0) {
744
+ pruneReceiptWaiters();
729
745
  logger.info(`a2a: ${String(echoed.length)} pending task(s) settled by content echo (${echoed.join(', ')})`);
730
746
  }
731
747
  return undefined;
@@ -760,7 +776,12 @@ export function apply(ctx, config) {
760
776
  return true;
761
777
  try {
762
778
  const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
779
+ // An acknowledgement only marks the row it was born with (it stays owed until the result).
780
+ if (settled?.acknowledged === true)
781
+ return true;
763
782
  const line = parsed.summary.trim();
783
+ if (settled !== undefined)
784
+ pruneReceiptWaiters();
764
785
  if (settled !== undefined && line !== '')
765
786
  idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
766
787
  return true;
@@ -790,6 +811,22 @@ export function apply(ctx, config) {
790
811
  // Live top-level agents: the join surface's candidates and the cold-row
791
812
  // complement (apply scope — the direct-route wake below reads it too).
792
813
  const liveRoots = new Map();
814
+ /**
815
+ * The agent preset a live session runs (the preset registry's inspection of its exact revision); undefined when the
816
+ * session is not loaded or the host has no preset registry.
817
+ */
818
+ const sessionPresetOf = (id) => {
819
+ const agent = liveRoots.get(id);
820
+ const presets = ctx.get('agentPresets');
821
+ if (agent === undefined || presets?.inspectCompositions === undefined)
822
+ return undefined;
823
+ try {
824
+ return presets.inspectCompositions(agent.ctx)[0]?.id;
825
+ }
826
+ catch {
827
+ return undefined;
828
+ }
829
+ };
793
830
  // Node identity N2+N3: a joined session that carries a node gets that node's layers on its own scope.
794
831
  const nodeBindings = new NodeBindings(join(home, 'a2a'));
795
832
  const nodeReviews = new NodeReviewQueue(join(home, 'a2a'));
@@ -1676,6 +1713,8 @@ export function apply(ctx, config) {
1676
1713
  nudgeEscalations: () => nudgeObservations.snapshot(),
1677
1714
  ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, canvasMemberForWrite, cliHomeForAdd, cliHomeOptions, 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,
1678
1715
  indexUrl: import.meta.url, enforceRefused,
1716
+ teamCanvasRefusal: (canvas, member) => superagentHost?.canvasMemberRefusal(canvas, member, sessionPresetOf),
1717
+ admissionRecheck: (caller, handle, local) => recheckCallerAdmission(linkRecheckRecords, caller, handle, local),
1679
1718
  get cardCache() { return cardCache; },
1680
1719
  get remoteRowsCache() { return remoteRowsCache; },
1681
1720
  get legacyInboundCount() { return legacyInboundCount; },
@@ -1836,7 +1875,7 @@ export function apply(ctx, config) {
1836
1875
  // gh#152: members are read through the local tiers' lens — one of
1837
1876
  // this host's own handles resolves to the session it names, and
1838
1877
  // the two stored shapes of one session publish as ONE handle.
1839
- ...(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) })) } : {}),
1878
+ ...(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), ...(superagentHost?.a2aAddress !== undefined && superagentHost.settings.canvas === name ? { superagentTeam: true } : {}) })) } : {}),
1840
1879
  ...(named && lanIp !== '' ? { lanIp } : {}),
1841
1880
  ...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
1842
1881
  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)'}`,
@@ -1895,12 +1934,216 @@ export function apply(ctx, config) {
1895
1934
  warn: code => logger.warn('a2a: sourced followup (%s); check ledger persistence and original task', code),
1896
1935
  });
1897
1936
  const followupRelay = (agent, text, sender, localFrom, taskId, callerKey, taskExpected = true) => followupDelivery.deliver(agent, text, sender, localFrom, taskId, callerKey, taskExpected);
1937
+ // superagent design v4, PR-B: superagent's domain engine in this host (off unless config.superagent). The router gets
1938
+ // candidate cards in its own session (host relay, not A2A) and decides only through the superagent_* tools.
1939
+ const superagentHost = config.superagent === true ? new SuperagentHost({
1940
+ home: join(home, 'a2a'), logger,
1941
+ deliverToSession: (id, text) => { const agent = sessionNodes.get(id); if (agent === undefined)
1942
+ return false; followupRelay(agent, text, config.team); return true; },
1943
+ interval: (fn, ms) => ctx.timer.interval(fn, ms),
1944
+ // The team's A2A address (PR-B2b) is a CLI identity the a2a-net CLI runs against this very host.
1945
+ homeUrl: () => superagentHomeUrl,
1946
+ apiKey: () => config.apiKey,
1947
+ // PR-C2: remote members and served teams talk host to host, signed with this host's card key and bound to the body.
1948
+ signedPost: async (baseUrl, path, body) => {
1949
+ const target = baseUrl.replace(/\/$/, '') + path;
1950
+ const text = JSON.stringify(body);
1951
+ const reply = await peerFetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body: text }) }, body: text, signal: AbortSignal.timeout(15_000) });
1952
+ if (!reply.ok) {
1953
+ void reply.body?.cancel().catch(() => { });
1954
+ return { ok: false, status: reply.status, error: `HTTP ${String(reply.status)}` };
1955
+ }
1956
+ try {
1957
+ return JSON.parse(await reply.text());
1958
+ }
1959
+ catch {
1960
+ return { ok: false, error: 'not JSON' };
1961
+ }
1962
+ },
1963
+ handleOf: id => `${config.team}/${id8(id)}`,
1964
+ localSessionOf: handle => localSessionOfHandle(handle),
1965
+ onRolesChanged: () => { syncRoleTools(); },
1966
+ // Design v5: the team is a canvas team this host owns. Its local sessions in canvas order, and its remote session slots
1967
+ // with the key the canvas pinned and the base URL of the linked peer whose signed card carries that key.
1968
+ canvasTeam: name => {
1969
+ if (!canvasStore.hasTeam(name))
1970
+ return undefined;
1971
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
1972
+ const local = canvasLocalMembersOf(name, true).filter(id => !id.includes('/'));
1973
+ const remote = canvasStore.membersOf(name).flatMap(ref => {
1974
+ if (CLI_HANDLE_RE.test(ref) || splitQualifiedMember(ref) === undefined)
1975
+ return [];
1976
+ const key = canvasStore.ownerKeyOf(ref);
1977
+ if (key === undefined || key === ownKey || memberProvenance(ref, key, ownKey) !== 'confirmed-remote')
1978
+ return [];
1979
+ const url = [...cardCache.entries()].find(([, entry]) => entry.card?.publicKey === key)?.[0];
1980
+ return [{ ref, key, url: url !== undefined ? new URL(url).origin : '' }];
1981
+ });
1982
+ return { local, remote };
1983
+ },
1984
+ canvasesOf: id => canvasTeamsOfSession(id),
1985
+ canvasNames: () => canvasStore.list(),
1986
+ presetOf: id => sessionPresetOf(id),
1987
+ cancelTurn: id => {
1988
+ const agent = sessionNodes.get(id);
1989
+ if (agent === undefined || agent.status === 'idle' || typeof agent.cancel !== 'function')
1990
+ return false;
1991
+ agent.cancel({ kind: 'user' }, { keepInbox: false });
1992
+ return true;
1993
+ },
1994
+ memberTurn: id => {
1995
+ const agent = sessionNodes.get(id);
1996
+ if (agent === undefined)
1997
+ return undefined;
1998
+ const jobs = ctx.get('jobs');
1999
+ const quiet = !(jobs?.list?.(id) ?? []).some(job => job.owner === id && (job.status === 'running' || job.status === 'stopping'));
2000
+ const last = [...sessionEventsOf(agent)].reverse().find(event => event?.type === 'assistant/message');
2001
+ return { quiet, text: (last?.data?.message?.content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n').trim() };
2002
+ },
2003
+ teamSend: args => teamCanvasSend(args),
2004
+ }) : undefined;
2005
+ // PR-D: the router and the members call only their role's tools — a host guard, whatever preset the session runs.
2006
+ const superagentRoleTools = new RoleTools({
2007
+ roleOf: id => superagentHost?.roleOf(id),
2008
+ agentOf: id => ctx.get('agents')?.get(SessionId(id)),
2009
+ logger,
2010
+ });
2011
+ function syncRoleTools() { for (const agent of liveRootAgents(ctx.get('agents')))
2012
+ superagentRoleTools.sync(agent); }
2013
+ let superagentHomeUrl;
2014
+ if (superagentHost !== undefined) {
2015
+ whenWebServerSettled(webServer => { superagentHomeUrl = `http://127.0.0.1:${String(webServer.port)}`; });
2016
+ ctx.effect(() => { superagentHost.start(); return () => { void superagentHost.stop(); }; }, 'a2a: superagent engine passes');
2017
+ registerSuperagentTools(ctx, () => superagentHost);
2018
+ // A member's turn ending is the executor's turn-end fact (PR-C1).
2019
+ ctx.on('agent/status', payload => { if (payload.status === 'idle')
2020
+ superagentHost.memberIdle(String(payload.agent.id)); });
2021
+ // A failed turn is retried or fails the ticket for a person (upstream); without this it read as still working.
2022
+ ctx.on('agent/error', ({ agent, error }) => { superagentHost.memberFailed(String(agent.id), error); });
2023
+ // PR-F: an Input counts as read once the member's session takes the message carrying it into its history.
2024
+ ctx.on('agent/inbox/claimed', ({ agent }) => { superagentHost.memberClaimed(String(agent.id)); });
2025
+ ctx.tools.guard(exec => {
2026
+ const refusal = superagentRoleTools.guard(exec.name, exec.agent);
2027
+ if (refusal !== undefined)
2028
+ superagentHost.noteRefusal('superagent-role-tools', { ...(exec.agent !== undefined ? { caller: String(exec.agent.id) } : {}), target: exec.name });
2029
+ return refusal;
2030
+ });
2031
+ ctx.on('agent/created', ({ agent }) => { superagentRoleTools.sync(agent); return undefined; });
2032
+ ctx.on('agent/disposed', ({ agent }) => { superagentRoleTools.forget(String(agent.id)); });
2033
+ syncRoleTools();
2034
+ }
2035
+ /**
2036
+ * The router acts only through superagent tools and takes work only as candidates from the team's Sources, so A2A
2037
+ * neither reaches it nor leaves it (hard rule, every route seam).
2038
+ * @returns the refusal, or undefined when the router is not involved.
2039
+ */
2040
+ function superagentRouterRefusal(callerSessionId, target) {
2041
+ const refusal = superagentRefusalText(callerSessionId, target);
2042
+ // Every caller returns this refusal to the route it refused, so each refused route counts once (PR-F: acceptance item 7).
2043
+ if (refusal !== undefined)
2044
+ superagentHost?.noteRefusal(/^\[([a-z-]+)\]/.exec(refusal)?.[1] ?? 'superagent', { ...(callerSessionId !== undefined ? { caller: callerSessionId } : {}), target });
2045
+ return refusal;
2046
+ }
2047
+ function superagentRefusalText(callerSessionId, target) {
2048
+ if (superagentHost === undefined)
2049
+ return undefined;
2050
+ const callerRole = callerSessionId === undefined ? undefined : superagentHost.roleOf(callerSessionId);
2051
+ if (callerRole === 'router')
2052
+ return '[superagent-router-outbound] the team router does not route A2A; it decides with superagent_route and replies with superagent_reply';
2053
+ if (callerRole === 'member')
2054
+ return '[superagent-member-outbound] a team member does not route A2A; it works on its assigned ticket and reports with a2a_ticket_report';
2055
+ const bare = target.replace(/@[0-9a-f]{8}$/, '');
2056
+ const named = (id) => { const handle = `${config.team}/${id8(id)}`; return target === id || (bare === handle && (target === bare || target.endsWith('@' + ownCanvasFingerprint))); };
2057
+ const router = superagentHost.routerSession;
2058
+ if (router !== undefined && named(router))
2059
+ return '[superagent-router-inbound] the team router takes no A2A work; send requests to the team\'s Sources (the workbench or its A2A address), where the gate admits them';
2060
+ if (superagentHost.settings.members.some(named))
2061
+ return '[superagent-member-inbound] a team member takes work only as assignments from the team\'s executor; send requests to the team\'s Sources';
2062
+ return undefined;
2063
+ }
2064
+ /**
2065
+ * Design v5 (D2): the team's address is its canvas team. A route to the bound canvas — bare, or qualified with this
2066
+ * host's own fingerprint — lands in the team's mailbox (the A2A Source reads it), never in a member session.
2067
+ */
2068
+ function teamMailboxOf(team) {
2069
+ const bound = superagentHost?.settings.canvas;
2070
+ const address = superagentHost?.a2aAddress;
2071
+ if (bound === undefined || address === undefined)
2072
+ return undefined;
2073
+ const split = splitCanvasOwner(team.trim());
2074
+ if (split.owner !== undefined && split.owner !== ownCanvasFingerprint)
2075
+ return undefined;
2076
+ return split.team === `${config.team}/canvas/${bound}` ? address : undefined;
2077
+ }
2078
+ /** Design v5 (D2): a route straight to the team identity's handle is refused (a receipt to it still lands): the address is the canvas. */
2079
+ function teamAddressRefusal(team, message) {
2080
+ const address = superagentHost?.a2aAddress;
2081
+ if (address === undefined || team.trim() !== address || parseReceipt(message) !== null)
2082
+ return undefined;
2083
+ const bound = superagentHost?.settings.canvas;
2084
+ superagentHost?.noteRefusal('superagent-team-address', { target: team });
2085
+ return `[superagent-team-address] ${address} is the team's internal mailbox; send requests to the team's canvas address${bound !== undefined ? ` ${config.team}/canvas/${bound}` : ' (the team is bound to no canvas team yet)'}`;
2086
+ }
2087
+ /**
2088
+ * Design v5 (D2, review R1): the team's own messages to a requester that came through its canvas address — the formal
2089
+ * receipt (result, attention or acknowledgement) and follow-ups in the same conversation — go out from this host's
2090
+ * process face on the canvas team's behalf, with onBehalfOf spelled exactly as the requester addressed it, so the
2091
+ * requester's ledger settles them as host-face. Speaks the a2a-net CLI's `receipt` / `route` JSON contract (stdout, exit
2092
+ * codes 4 gone / 5 another receipt) for the A2A Source; undefined when the task did not come through the canvas.
2093
+ */
2094
+ async function teamCanvasSend(args) {
2095
+ const verb = args[0];
2096
+ if (verb !== 'receipt' && verb !== 'route')
2097
+ return undefined;
2098
+ const dash = args.indexOf('--');
2099
+ const text = dash >= 0 ? args.slice(dash + 1).join(' ') : '';
2100
+ const flagOf = (name) => { const i = args.indexOf(name); return i >= 0 && (dash < 0 || i < dash) ? args[i + 1] : undefined; };
2101
+ const fail = (code, error) => { const e = new Error(error); e.exitCode = code; throw e; };
2102
+ if (verb === 'receipt') {
2103
+ const task = args[1] ?? '', to = flagOf('--to') ?? '';
2104
+ const row = taskLedger.find(task);
2105
+ if (row === undefined || teamMailboxOf(row.team) === undefined || to === '')
2106
+ return undefined;
2107
+ // Review of #442: switches are read before `--` only — the text after it may be anything.
2108
+ const switches = dash >= 0 ? args.slice(0, dash) : args;
2109
+ const acknowledged = switches.includes('--acknowledged'), attention = switches.includes('--attention');
2110
+ const outcome = flagOf('--outcome');
2111
+ const fields = acknowledged ? { idempotencyKey: task, disposition: 'acknowledged', onBehalfOf: row.team }
2112
+ : attention ? { idempotencyKey: task, disposition: 'attention', onBehalfOf: row.team }
2113
+ : { outcome: (outcome ?? 'completed'), idempotencyKey: task, disposition: 'result', onBehalfOf: row.team };
2114
+ const receipt = formatReceipt(task, text.replace(/\s+/g, ' ').trim(), fields);
2115
+ const receiptTask = `${acknowledged ? 'ack' : 'receipt'}-${task}`;
2116
+ try {
2117
+ const out = await dispatchAnywhere(to, receipt, session, row.contextId, new AbortController().signal, true, receiptTask);
2118
+ // This host's own inbound row (a requester on another host or a CLI here): the receipt it sent settles it.
2119
+ if (row.direction === 'inbound' && !acknowledged)
2120
+ settleOwnInbound(receipt, to, { caller: session, proof: 'in-process', ingress: 'local' });
2121
+ return JSON.stringify({ delivered: true, class: 'sent', task_id: receiptTask, team: to, ...(typeof out.context_id === 'string' ? { context_id: out.context_id } : {}) });
2122
+ }
2123
+ catch (error) {
2124
+ const code = error.code;
2125
+ return fail(code === WIRE_ERROR_IDEMPOTENCY_CONFLICT ? 5 : 1, String(error instanceof Error ? error.message : error));
2126
+ }
2127
+ }
2128
+ const to = args[1] ?? '', context = flagOf('--context') ?? '', followTask = flagOf('--task');
2129
+ const origin = taskLedger.list().find(r => r.contextId === context && teamMailboxOf(r.team) !== undefined);
2130
+ if (origin === undefined || to === '')
2131
+ return undefined;
2132
+ try {
2133
+ await dispatchAnywhere(to, text, session, context, new AbortController().signal, true, followTask);
2134
+ return JSON.stringify({ delivered: true, class: 'sent', ...(followTask !== undefined ? { task_id: followTask } : {}), team: to, context_id: context });
2135
+ }
2136
+ catch (error) {
2137
+ return fail(1, String(error instanceof Error ? error.message : error));
2138
+ }
2139
+ }
1898
2140
  ctx.on('agent/inbox/claimed', ({ agent, message }) => followupDelivery.claimed(agent, String(message.id)));
1899
2141
  const withdrawQueuedTask = (taskId) => {
1900
2142
  const controller = ctx.get('sessionController');
1901
2143
  return followupDelivery.withdraw(taskId, controller, id => ctx.get('agents')?.get(SessionId(id)));
1902
2144
  };
1903
2145
  const cancelQueuedTask = async (taskId, text, sender, original) => {
2146
+ pruneReceiptWaiters();
1904
2147
  let record, withdrawFailed = false, removed = false;
1905
2148
  try {
1906
2149
  record = taskLedger.deliveryFor(taskId);
@@ -2026,6 +2269,9 @@ export function apply(ctx, config) {
2026
2269
  }
2027
2270
  };
2028
2271
  function armAsyncNudge(agent, team, taskId, delayMs) {
2272
+ // asyncNudgeDelayMs 0 (or less) turns the nudge off, as the config schema and README promise (#453).
2273
+ if (!(delayMs > 0))
2274
+ return;
2029
2275
  const key = String(agent.id);
2030
2276
  const spent = nudgeBudget.get(key);
2031
2277
  if (spent !== undefined && spent > 1)
@@ -2127,6 +2373,13 @@ export function apply(ctx, config) {
2127
2373
  // target produced after consuming `[A2A direct] (task <id>)` answers it
2128
2374
  // (flushFinals). A busy target's other work line can no longer settle
2129
2375
  // a task it has not read yet (the #61 direct-bb27ec94 shape).
2376
+ // Defensive only: no current normal replay/reentry path reaches a second
2377
+ // registration. Both wire sites follow the idempotency claim; local tools
2378
+ // mint an id per call, background notices mint one, and receipt retries keep
2379
+ // their id but exit above (receipts owe no receipts). No present-path mutation
2380
+ // coverage claim: this check guards future/internal callers, not replay admission.
2381
+ if ((pendingFinals.get(String(target.id)) ?? []).some(entry => entry.purpose === 'receipt' && entry.taskId === taskId))
2382
+ return;
2130
2383
  registerFinalWaiter(target, (finalText, placeholder) => {
2131
2384
  // W7 slice-2: a host-authored placeholder (flush timeout, dead
2132
2385
  // session) must not synthesize a COMPLETION receipt — a wedged
@@ -2173,7 +2426,9 @@ export function apply(ctx, config) {
2173
2426
  /* Legacy permanent-failure ladder remains the owner-settlement policy. */
2174
2427
  const ladder = () => runReceiptLadder({
2175
2428
  deliver: deliverReceipt,
2176
- transient: transientReceiptFailure,
2429
+ // Unknown admission must stop the owner lane; unlike a transport transient,
2430
+ // it is not retryable by retryReceiptDelivery and retains the original debt.
2431
+ transient: error => error instanceof FollowupAcceptanceUnknown || transientReceiptFailure(error),
2177
2432
  ownerTeam: config.team,
2178
2433
  log: (stage, error) => {
2179
2434
  // N2 (observability): throttled per task to avoid ping-pong noise.
@@ -2190,10 +2445,10 @@ export function apply(ctx, config) {
2190
2445
  notify: async () => { await dispatchAnywhere(config.team, '[A2A receipt delivery notice] ref ' + nudgeRef(taskId) + '; delivery to the original caller is temporarily unconfirmed; host retry is responsible. This notice is not a receipt and does not settle work.', session, undefined, new AbortController().signal, true); },
2191
2446
  pending: () => row === undefined || taskLedger.isPending(taskId), wait, log: () => { logger.warn('a2a: receipt delivery temporarily unconfirmed ref=%s', nudgeRef(taskId)); },
2192
2447
  }).catch(() => { });
2193
- }, { taskId, steerFloor });
2448
+ }, { taskId, steerFloor }, 'receipt');
2194
2449
  }
2195
2450
  /**
2196
- * Register one final-reply waiter for the agent and arm its flush timeout.
2451
+ * Register one attributed waiter: HTTP has a flush window, receipt tracking the original task TTL.
2197
2452
  * @param agent - the agent whose next assistant message answers the waiter.
2198
2453
  * @param answer - how the waiter's reply is delivered.
2199
2454
  * @param attribution - #114 S5: attribute by consumption — only the turn
@@ -2203,14 +2458,20 @@ export function apply(ctx, config) {
2203
2458
  * anchors it.
2204
2459
  * @returns the registered waiter (for callers that may retract it).
2205
2460
  */
2206
- function registerFinalWaiter(agent, answer, attribution) {
2461
+ function registerFinalWaiter(agent, answer, attribution, purpose = 'http') {
2207
2462
  const key = String(agent.id);
2208
2463
  const waiter = {
2209
- answer,
2464
+ answer, purpose,
2210
2465
  sinceEvents: sessionEventsOf(agent).length,
2211
2466
  ...(attribution !== undefined && attribution.taskId !== '' ? { taskId: attribution.taskId, scannedTo: attribution.steerFloor } : {}),
2212
2467
  };
2213
- waiter.timeoutDisposer = armFlushTimeout(key, waiter);
2468
+ if (purpose === 'receipt') {
2469
+ const row = attribution === undefined ? undefined : taskLedger.find(attribution.taskId);
2470
+ const remaining = Math.max(0, (row?.startedAt ?? Date.now()) + (config.taskStaleTtlMs ?? TASK_STALE_TTL_MS) - Date.now());
2471
+ waiter.timeoutDisposer = ctx.timer.timeout(() => removeFinalWaiter(key, waiter), remaining);
2472
+ }
2473
+ else
2474
+ waiter.timeoutDisposer = armFlushTimeout(key, waiter);
2214
2475
  pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
2215
2476
  return waiter;
2216
2477
  }
@@ -2232,22 +2493,6 @@ export function apply(ctx, config) {
2232
2493
  if (wake.kind === 'flight') {
2233
2494
  return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2234
2495
  }
2235
- // Native-teams inbound bridge (opt-in): a registry team claims the
2236
- // handle and dispatches through its authoritative seam. Checked before
2237
- // the bare-team fallback — a team named exactly like this node's team
2238
- // resolves to the registry's claim, not the initiator redirect.
2239
- if (config.nativeTeamsInbound === true) {
2240
- const prepared = await nativeTeamsPrepare(team);
2241
- if (prepared.ok) {
2242
- const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId, proofClause ?? '未验证(in-process)', undefined, buildHeader === undefined ? undefined : (_team, _taskId, from) => buildHeader(from));
2243
- if (bridged.ok) {
2244
- if (bridged.settled)
2245
- return { ok: true, reply: bridged.reply };
2246
- return { ok: true, reply: bridged.reply, status: 'TASK_STATE_DELIVERED', bridge: 'native-teams' };
2247
- }
2248
- return { ok: false, error: bridged.error };
2249
- }
2250
- }
2251
2496
  // Only the bare process team falls back to the live (initiator) agent.
2252
2497
  // A session-node-shaped team that misses is an error, never a silent
2253
2498
  // redirect: a mistyped or stale short id must not reach another session
@@ -2275,6 +2520,10 @@ export function apply(ctx, config) {
2275
2520
  * @returns the agent's final reply, or the explicit failure.
2276
2521
  */
2277
2522
  function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
2523
+ // superagent v4: whatever address resolved to it (a canvas team, the node team), the router takes no A2A work.
2524
+ if (superagentHost?.roleOf(String(agent.id)) !== undefined) {
2525
+ return Promise.resolve({ ok: false, error: superagentRouterRefusal(undefined, String(agent.id)) ?? 'superagent team sessions take no A2A work' });
2526
+ }
2278
2527
  return new Promise((resolve) => {
2279
2528
  // #306: synchronous replies use the same reading-turn attribution as automatic receipts.
2280
2529
  // Registering and steering are synchronous, so this is the exact pre-steer floor.
@@ -2309,8 +2558,13 @@ export function apply(ctx, config) {
2309
2558
  const text = `${headerLine}\n\n${message}`;
2310
2559
  try {
2311
2560
  const floor = sessionEventsOf(agent).length;
2312
- followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
2313
- onSteered?.(agent, floor);
2561
+ const acceptance = followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
2562
+ onSteered?.(agent, floor, acceptance);
2563
+ if (acceptance === 'inserted') {
2564
+ removeFinalWaiter(String(agent.id), waiter);
2565
+ resolve({ ok: true, reply: ACCEPTANCE_INSERTED_NOTE, acceptance, placeholder: true });
2566
+ return;
2567
+ }
2314
2568
  admissionFinished = true;
2315
2569
  if (earlyReply)
2316
2570
  resolve(earlyReply);
@@ -2332,15 +2586,14 @@ export function apply(ctx, config) {
2332
2586
  resolve({ ok: true, reply: ADMITTED_BOOKKEEPING_NOTE, receiverBookkeeping: 'failed', placeholder: true });
2333
2587
  return;
2334
2588
  }
2589
+ if (error instanceof FollowupAcceptanceUnknown) {
2590
+ resolve({ ok: false, error: ACCEPTANCE_UNKNOWN_NOTE, acceptance: 'unknown' });
2591
+ return;
2592
+ }
2335
2593
  resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
2336
2594
  }
2337
2595
  });
2338
2596
  }
2339
- // gh#77 G-in (S23 repayment): the native-teams inbound bridge moved verbatim
2340
- // to src/legacy/teams-bridge-inbound.ts — the registry probe, claim memo,
2341
- // prepare and the bounded round. The outbound bridgeFace stays here until
2342
- // the G-out slice (it submits through the dispatch trunk).
2343
- const { teamsRegistry, nativeTeamsClaims, nativeTeamsPrepare, nativeTeamsRound } = createTeamsBridgeInbound({ ctx, config, liveAgent, fromClause, sanitizeFromClause, proofHeader: hostDirectHeader, taint: agent => { markLineage(agent, 'native-teams'); } });
2344
2597
  /* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md,
2345
2598
  src/origin-claim.ts): the caller's card key signs the envelope; the
2346
2599
  bound form must name one of OUR origins and the route it hits. ts skew
@@ -2608,6 +2861,21 @@ export function apply(ctx, config) {
2608
2861
  cliDeposited: handle => cliDeposits.get(handle) !== undefined,
2609
2862
  cliHomeOf: handle => canvasStore.cliHomeOf(handle),
2610
2863
  };
2864
+ /**
2865
+ * v5-B2: an identity's `admission` query rechecks a caller it already took (its key was verified when the delivery came
2866
+ * in): only that the operator's link still stands. A confirmed canvas remote reference pinned with its key is that link
2867
+ * for the bare handle too — the delivery-time records match a qualified reference only under the signer's key, which a
2868
+ * recheck does not carry.
2869
+ */
2870
+ const linkRecheckRecords = {
2871
+ ...admissionRecords,
2872
+ canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name).flatMap(ref => {
2873
+ const qualified = splitQualifiedMember(ref);
2874
+ if (qualified === undefined)
2875
+ return [ref];
2876
+ return memberProvenance(ref, canvasStore.ownerKeyOf(ref), encodePublicKey(createPublicKey(nodePrivateKey))) === 'confirmed-remote' ? [ref, qualified.handle] : [ref];
2877
+ }),
2878
+ };
2611
2879
  /** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
2612
2880
  function sameZoneSharedTeamRefusal(caller, targetTeam) {
2613
2881
  return refusalOf(sameZoneAdmission(admissionRecords, caller, targetTeam));
@@ -2731,38 +2999,45 @@ export function apply(ctx, config) {
2731
2999
  identical semantics — artifact existence is the only gate (archived
2732
3000
  and never-joined sessions stay readable), caps mirror
2733
3001
  session-transcript.ts. */
3002
+ /** `dshHome` may move only the plugin's own state, with the host still writing session logs under its own home. */
3003
+ function sessionLogRoots() {
3004
+ const own = join(home, 'sessions');
3005
+ const root = existsSync(own) ? own : join(resolveDshHome(), 'sessions');
3006
+ return existsSync(root) ? [root] : [];
3007
+ }
2734
3008
  function scanSessionLogList() {
2735
3009
  const rows = [];
2736
- const sessionsRoot = join(home, 'sessions');
2737
- if (!existsSync(sessionsRoot))
2738
- return rows;
2739
- for (const bucket of readdirSync(sessionsRoot, { withFileTypes: true })) {
2740
- if (!bucket.isDirectory())
2741
- continue;
2742
- for (const child of readdirSync(join(sessionsRoot, bucket.name), { withFileTypes: true })) {
2743
- if (!child.isDirectory())
2744
- continue;
2745
- const dir = join(sessionsRoot, bucket.name, child.name);
2746
- const files = readdirSync(dir).filter(name => name.startsWith('session.') && (name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')));
2747
- if (files.length === 0)
3010
+ const seen = new Set();
3011
+ for (const sessionsRoot of sessionLogRoots()) {
3012
+ for (const bucket of readdirSync(sessionsRoot, { withFileTypes: true })) {
3013
+ if (!bucket.isDirectory())
2748
3014
  continue;
2749
- let newest;
2750
- for (const name of files) {
2751
- const st = statSync(join(dir, name));
2752
- if (newest === undefined || st.mtimeMs > newest.mtimeMs)
2753
- newest = { mtimeMs: st.mtimeMs, sizeBytes: st.size };
3015
+ for (const child of readdirSync(join(sessionsRoot, bucket.name), { withFileTypes: true })) {
3016
+ if (!child.isDirectory() || seen.has(child.name))
3017
+ continue;
3018
+ const dir = join(sessionsRoot, bucket.name, child.name);
3019
+ const files = readdirSync(dir).filter(name => name.startsWith('session.') && (name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')));
3020
+ if (files.length === 0)
3021
+ continue;
3022
+ let newest;
3023
+ for (const name of files) {
3024
+ const st = statSync(join(dir, name));
3025
+ if (newest === undefined || st.mtimeMs > newest.mtimeMs)
3026
+ newest = { mtimeMs: st.mtimeMs, sizeBytes: st.size };
3027
+ }
3028
+ if (newest !== undefined) {
3029
+ seen.add(child.name);
3030
+ rows.push({ id: child.name, ...newest });
3031
+ }
2754
3032
  }
2755
- if (newest !== undefined)
2756
- rows.push({ id: child.name, ...newest });
2757
3033
  }
2758
3034
  }
2759
3035
  rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
2760
3036
  return rows;
2761
3037
  }
2762
3038
  function readSessionLogTail(id, limit) {
2763
- const sessionsRoot = join(home, 'sessions');
2764
3039
  let logPath;
2765
- if (existsSync(sessionsRoot)) {
3040
+ search: for (const sessionsRoot of sessionLogRoots()) {
2766
3041
  for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
2767
3042
  if (!entry.isDirectory())
2768
3043
  continue;
@@ -2777,7 +3052,7 @@ export function apply(ctx, config) {
2777
3052
  const rank = (name) => generation(name) * 2 + (name.endsWith('.zstd') ? 1 : 0);
2778
3053
  files.sort((a, b) => rank(b) - rank(a));
2779
3054
  logPath = files[0] !== undefined ? join(candidate, files[0]) : undefined;
2780
- break;
3055
+ break search;
2781
3056
  }
2782
3057
  }
2783
3058
  if (logPath === undefined)
@@ -2795,6 +3070,12 @@ export function apply(ctx, config) {
2795
3070
  config,
2796
3071
  cliDeposits,
2797
3072
  canvasStore, canvasMemberForWrite,
3073
+ teamCanvasRefusal: (canvas, member) => superagentHost?.canvasMemberRefusal(canvas, member, sessionPresetOf),
3074
+ teamCanvasBound: (canvas) => superagentHost?.settings.canvas === canvas,
3075
+ superagentInbound: team => superagentRouterRefusal(undefined, team),
3076
+ superagentIntake: team => superagentHost?.a2aAddress === team,
3077
+ teamMailboxOf: team => teamMailboxOf(team),
3078
+ teamAddressRefusal: (team, message) => teamAddressRefusal(team, message),
2798
3079
  canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
2799
3080
  sessionNodes,
2800
3081
  joinedSessions,
@@ -2803,6 +3084,7 @@ export function apply(ctx, config) {
2803
3084
  peerKeyAdmits,
2804
3085
  remoteCliProof,
2805
3086
  forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
3087
+ teamReceiptBasis: (taskId, caller, target, onBehalfOf, claim) => teamReceiptBasis(taskLedger.find(taskId), caller, target, onBehalfOf, claim),
2806
3088
  forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
2807
3089
  diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
2808
3090
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
@@ -2852,11 +3134,162 @@ export function apply(ctx, config) {
2852
3134
  noWakeFaceText,
2853
3135
  archivedColdMatchText,
2854
3136
  noColdMatchText,
2855
- nativeTeamsPrepare,
2856
- nativeTeamsRound,
2857
3137
  routeIntoAgent,
2858
3138
  });
2859
3139
  });
3140
+ if (superagentHost !== undefined)
3141
+ whenWebServerSettled((webServer) => {
3142
+ // superagent design v4, PR-B: the owner's workbench face of the engine (owner control route only).
3143
+ // GET: the engine state; POST: settings (router, allowed senders, Sources, pause), request (the workbench Source),
3144
+ // stop / retry a ticket (upstream's people commands).
3145
+ ctx.effect(() => webServer.register({
3146
+ kind: 'exact',
3147
+ path: '/__dsh_a2a/superagent',
3148
+ handler: controlRoute((req, res) => {
3149
+ const send = (status, value) => { const payload = JSON.stringify(value); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }); res.end(payload); };
3150
+ if (req.method === 'GET') {
3151
+ send(200, superagentHost.state());
3152
+ return;
3153
+ }
3154
+ readJsonBody(req, res, (body) => {
3155
+ const fields = body;
3156
+ try {
3157
+ if (fields.action === 'settings') {
3158
+ send(200, superagentHost.saveSettings(fields.settings, id => joinedSessions.has(id) && archivedSessionFilter()?.(id) !== true, sessionPresetOf));
3159
+ }
3160
+ else if (fields.action === 'request') {
3161
+ send(200, superagentHost.request(typeof fields.text === 'string' ? fields.text : '', typeof fields.thread === 'string' ? fields.thread : undefined, fields.via === 'cli' ? 'cli' : undefined));
3162
+ }
3163
+ else if (fields.action === 'answer') {
3164
+ send(200, superagentHost.answer(typeof fields.ticket_id === 'string' ? fields.ticket_id : '', typeof fields.text === 'string' ? fields.text : ''));
3165
+ }
3166
+ else if (fields.action === 'add-remote-member' || fields.action === 'remove-remote-member') {
3167
+ // Design v5: a remote member is a remote slot of the bound canvas team — added on the canvas, which pins its key.
3168
+ send(200, { ok: false, error: 'the team is a canvas team: add or remove a remote member on the canvas (/__dsh_a2a/canvas add-member / remove-member)' });
3169
+ }
3170
+ else if (fields.action === 'serve-team') {
3171
+ // Design v5 (D4): a team is served by its canvas id, which names its owner by key fingerprint.
3172
+ send(200, { ok: false, error: "serve a team by its canvas id: POST { action: 'serve', team: '<zone>/canvas/<name>@<fp8>', members: [<local session ids>] } (team: null leaves)" });
3173
+ }
3174
+ else if (fields.action === 'serve') {
3175
+ // Design v5 (D4): serve another host's team — the canvas team `<zone>/canvas/<name>@<fp8>`. The fingerprint picks
3176
+ // the linked peer whose signed card key it names and which advertises that canvas; that key is pinned, so only it
3177
+ // may assign these sessions work. null leaves. The member host's owner consents here; the team host cannot
3178
+ // take another host's sessions on its own (U17).
3179
+ if (fields.team === null) {
3180
+ send(200, superagentHost.serveTeam(undefined, [], () => false));
3181
+ return;
3182
+ }
3183
+ const team = typeof fields.team === 'string' ? fields.team.trim() : '';
3184
+ const members = Array.isArray(fields.members) ? fields.members.filter((m) => typeof m === 'string') : [];
3185
+ void (async () => {
3186
+ const split = splitCanvasOwner(team);
3187
+ if (split.owner === undefined || !/^[^/\s]+\/canvas\/[^/\s]+$/.test(split.team)) {
3188
+ send(200, { ok: false, error: 'team must be a canvas id with its owner: <zone>/canvas/<name>@<fp8>' });
3189
+ return;
3190
+ }
3191
+ if (split.owner === ownCanvasFingerprint) {
3192
+ send(200, { ok: false, error: "that canvas is this host's own: its sessions join it as members on the canvas" });
3193
+ return;
3194
+ }
3195
+ let found;
3196
+ for (const url of new Set([...peerStore.list(), ...cardCache.keys()])) {
3197
+ const card = await fetchPeerCard(url).catch(() => undefined);
3198
+ if (card === undefined || !verifyCard(card, Date.now()).ok || typeof card.publicKey !== 'string' || canvasOwnerFingerprint(card.publicKey) !== split.owner)
3199
+ continue;
3200
+ if (!(card.canvasOrg ?? []).some(entry => entry.team === split.team && entry.team === card.team + '/canvas/' + entry.name))
3201
+ continue;
3202
+ found = { url, card };
3203
+ break;
3204
+ }
3205
+ if (found === undefined) {
3206
+ send(200, { ok: false, error: `no linked peer's signed card is the owner of ${team} (key fingerprint ${split.owner}) and advertises it` });
3207
+ return;
3208
+ }
3209
+ // The link judgement reads the cached card: cache the verified one first, as the canvas member proof does.
3210
+ cardCache.set(found.url, { at: Date.now(), card: found.card });
3211
+ if (peerAdmissionLinkState(found.url).link !== 'linked') {
3212
+ send(200, { ok: false, error: 'the team host must be a linked peer' });
3213
+ return;
3214
+ }
3215
+ send(200, superagentHost.serveTeam({ url: new URL(found.url).origin, key: found.card.publicKey, canvas: team }, members, id => joinedSessions.has(id) && archivedSessionFilter()?.(id) !== true, sessionPresetOf));
3216
+ })().catch(error => send(200, { ok: false, error: String(error) }));
3217
+ }
3218
+ else if (fields.action === 'reevaluate') {
3219
+ // Design superagent-reevaluate-note D1: the owner asks for a fresh judgement of a stored message.
3220
+ send(200, superagentHost.reevaluate(typeof fields.trigger_id === 'string' ? fields.trigger_id : ''));
3221
+ }
3222
+ else if (fields.action === 'stop' || fields.action === 'retry') {
3223
+ const ticketId = typeof fields.ticket_id === 'string' ? fields.ticket_id : '';
3224
+ const ticket = fields.action === 'stop'
3225
+ ? superagentHost.engine.stopTicket(ticketId, typeof fields.reason === 'string' ? fields.reason : undefined)
3226
+ : superagentHost.engine.retryTicket(ticketId);
3227
+ superagentHost.pass();
3228
+ send(200, { ok: true, ticket });
3229
+ }
3230
+ else
3231
+ send(200, { ok: false, error: 'action must be settings, request, answer, serve, stop, retry or reevaluate' });
3232
+ }
3233
+ catch (error) {
3234
+ send(200, { ok: false, error: error instanceof Error ? error.message : String(error) });
3235
+ }
3236
+ }, 64_000);
3237
+ }),
3238
+ }), 'a2a: superagent control route');
3239
+ // PR-C2: the two host-to-host faces. Each takes a signed, body-bound OriginClaim and judges the signer's card key against
3240
+ // the key the owner pinned: the team host's key on a member host, the member host's key on the team host.
3241
+ const signedFace = (path, judge, label) => {
3242
+ ctx.effect(() => webServer.register({
3243
+ kind: 'exact',
3244
+ path,
3245
+ handler: (req, res) => {
3246
+ const send = (value) => { const payload = JSON.stringify(value); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }); res.end(payload); };
3247
+ if (req.method !== 'POST') {
3248
+ send({ ok: false, error: 'POST required' });
3249
+ return;
3250
+ }
3251
+ const chunks = [];
3252
+ let size = 0;
3253
+ req.on('data', (chunk) => { size += chunk.length; if (size <= 64 * 1024)
3254
+ chunks.push(chunk); });
3255
+ req.on('end', () => {
3256
+ void (async () => {
3257
+ if (size > 64 * 1024) {
3258
+ send({ ok: false, error: 'payload too large' });
3259
+ return;
3260
+ }
3261
+ const rawBody = Buffer.concat(chunks);
3262
+ let body;
3263
+ try {
3264
+ body = JSON.parse(rawBody.toString('utf8'));
3265
+ }
3266
+ catch {
3267
+ send({ ok: false, error: 'not JSON' });
3268
+ return;
3269
+ }
3270
+ const claim = await verifyOriginClaim(req, { rawBody });
3271
+ if (!claim.ok || !claim.bound || claim.bodyBound !== true || typeof claim.card.publicKey !== 'string') {
3272
+ recordActivity('in', path, String(req.headers['x-a2a-caller'] ?? 'unknown'), false);
3273
+ superagentHost.noteRefusal('superagent-untrusted-origin', { caller: String(req.headers['x-a2a-caller'] ?? 'unknown'), target: path });
3274
+ send({ ok: false, code: 'untrusted-origin', error: `a signed, body-bound OriginClaim is required (${claim.ok ? 'unbound' : claim.reason})` });
3275
+ return;
3276
+ }
3277
+ const out = judge(body, { caller: claim.caller, key: claim.card.publicKey });
3278
+ recordActivity('in', path, claim.caller, out.ok === true);
3279
+ // A signer that is not the pinned key (an impostor sharing a handle) is refused by identity; a malformed body is not counted.
3280
+ if (out.ok !== true && ['not-signer', 'not-team', 'not-member'].includes(String(out.code)))
3281
+ superagentHost.noteRefusal(`superagent-${String(out.code)}`, { caller: claim.caller, target: path });
3282
+ send(out);
3283
+ })().catch(error => send({ ok: false, error: String(error) }));
3284
+ });
3285
+ },
3286
+ }), label);
3287
+ };
3288
+ signedFace('/a2a/superagent-member', (body, claim) => superagentHost.assignedWork(body.op, body.member, claim.key, body.text), 'a2a: superagent member face');
3289
+ signedFace('/a2a/superagent-team', (body, claim) => typeof body.member === 'string' && claimCovers(body.member, claim.caller)
3290
+ ? superagentHost.remoteEvent(body.op, body.member, claim.key, body.report)
3291
+ : { ok: false, code: 'not-signer', error: 'the signer does not speak for that member' }, 'a2a: superagent team face');
3292
+ });
2860
3293
  whenWebServerSettled((webServer) => {
2861
3294
  // Task-ledger control: explicit caller-side abandonment of one owed
2862
3295
  // async task — the orchestrator's give-up path (work-order P1a).
@@ -2873,6 +3306,7 @@ export function apply(ctx, config) {
2873
3306
  const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
2874
3307
  const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
2875
3308
  const result = taskLedger.abandon(taskId, reason);
3309
+ pruneReceiptWaiters();
2876
3310
  const payload = JSON.stringify(result);
2877
3311
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2878
3312
  res.end(payload);
@@ -3075,6 +3509,9 @@ export function apply(ctx, config) {
3075
3509
  // The cache is still empty here, so the observer skips cache-resting rows
3076
3510
  // on 'boot' (#138 R1); the first lazy read rechecks them.
3077
3511
  ctx.effect(() => canvasStore.onChange(() => observeAuthorization('canvas')), 'a2a: S6 canvas observer');
3512
+ // Design v5: the team's pool is its canvas team's; a canvas change is a pool change.
3513
+ if (superagentHost !== undefined)
3514
+ ctx.effect(() => canvasStore.onChange(() => { superagentHost.poolChanged(); }), 'a2a: superagent team canvas');
3078
3515
  ctx.effect(() => joinedSessions.onChange(() => observeAuthorization('joined')), 'a2a: S6 joined observer');
3079
3516
  ctx.effect(() => teamMemberships.onChange(() => observeAuthorization('roster')), 'a2a: S6 roster observer');
3080
3517
  observeAuthorization('boot');
@@ -3449,19 +3886,6 @@ export function apply(ctx, config) {
3449
3886
  origin: localOrigin,
3450
3887
  };
3451
3888
  }),
3452
- // Native-teams registry rows (opt-in bridge): local teams this node
3453
- // can dispatch inbound. They list only when the operator opted in —
3454
- // registry presence alone is never network exposure.
3455
- ...(config.nativeTeamsInbound === true
3456
- ? (teamsRegistry()?.listTeams() ?? []).map(entry => ({
3457
- team: entry.name,
3458
- session,
3459
- name: entry.name,
3460
- description: entry.description,
3461
- local: true,
3462
- origin: localOrigin,
3463
- }))
3464
- : []),
3465
3889
  ];
3466
3890
  const fetch = memoizedCardFetch();
3467
3891
  // Collect per peer in store order (concurrent fetches, ordered merge):
@@ -3541,19 +3965,19 @@ export function apply(ctx, config) {
3541
3965
  if (!result.ok)
3542
3966
  return;
3543
3967
  birth = { ...birth, ...(typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}) };
3544
- // Native-teams bridge rounds emit no A2A receipt in this slice: booking
3545
- // them as receipt-owed would fill the owed book with rows no receipt can
3968
+ // A peer still running the retired native-teams bridge (0.7.0 and older) marks those rounds: they emit no A2A
3969
+ // receipt, so booking them as receipt-owed would fill the owed book with rows no receipt can
3546
3970
  // ever settle (evicting genuinely receivable peer rows past TASK_CAP).
3547
3971
  if (result.bridge !== undefined)
3548
3972
  return;
3549
- if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed')
3973
+ if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed' && !(result.task_status === 'TASK_STATE_COMPLETED' && result.replyFinal !== true && result.receipt !== 'mailbox'))
3550
3974
  return;
3551
3975
  // Fix ② (ghost-debt stop): a delivered async dispatch with no routable
3552
3976
  // callback address can never be settled — register it receiptless
3553
3977
  // (visible, non-debt; reconcile via context_id) instead of stranding a
3554
3978
  // row whose only exit was the dead-letter sweep (57 dead letters, half
3555
3979
  // this shape).
3556
- if (callbackAddress === undefined) {
3980
+ if (callbackAddress === undefined || result.receipt === 'none') {
3557
3981
  taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
3558
3982
  return;
3559
3983
  }
@@ -3701,6 +4125,13 @@ export function apply(ctx, config) {
3701
4125
  const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
3702
4126
  if (ownerQualified.owner !== undefined)
3703
4127
  args = { ...args, team: ownerQualified.team };
4128
+ {
4129
+ const refusal = superagentRouterRefusal(agent === undefined ? undefined : String(agent.id), String(args.team ?? '').trim());
4130
+ if (refusal !== undefined) {
4131
+ recordActivity('out', String(args.team ?? ''), agent !== undefined ? sessionTeamOf(agent) : 'local', false);
4132
+ return { ok: false, error: refusal, code: -32000 };
4133
+ }
4134
+ }
3704
4135
  // A receipt may use only its own frozen return lane; local answer state is not delivery proof.
3705
4136
  const answer = parseReceipt(args.message);
3706
4137
  const receiptRow = answer === null ? undefined : taskLedger.find(answer.taskId);
@@ -3739,7 +4170,9 @@ export function apply(ctx, config) {
3739
4170
  initiator = ctx.get('agents')?.requireInitiator() === agent;
3740
4171
  }
3741
4172
  catch { /* no initiator in this fiber */ }
3742
- if (!initiator) {
4173
+ // Design v5 (Q1): the bound team canvas takes requests from any session of this host; the team's Policy decides
4174
+ // whether it works on them, so team scope does not gate the route.
4175
+ if (!initiator && teamMailboxOf(String(args.team ?? '')) === undefined) {
3743
4176
  const callerHandle = `${config.team}/${id8(scopeCaller)}`;
3744
4177
  scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3745
4178
  const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
@@ -3802,6 +4235,8 @@ export function apply(ctx, config) {
3802
4235
  // gh#198: host-built delivery notes, or a reply from a clean local session (resolved as routeIntoAgent does), are this host's alone.
3803
4236
  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);
3804
4237
  }
4238
+ if (outcome.acceptance === 'unknown')
4239
+ return localRoute(outcome);
3805
4240
  failures.push(`local: ${outcome.error}`);
3806
4241
  }
3807
4242
  // Failover seam for canvas teams: after the local tiers miss, the
@@ -3848,6 +4283,8 @@ export function apply(ctx, config) {
3848
4283
  peerAsync = card !== undefined && caps?.async === true;
3849
4284
  }
3850
4285
  const result = await dispatchPeerCandidate(candidate, target, args.message, args.context_id, signal, callerSession, peerAsync, taskId, callbackAddress);
4286
+ if (!result.ok && result.acceptance === 'unknown')
4287
+ return result;
3851
4288
  if (result.ok || signal.aborted) {
3852
4289
  // A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
3853
4290
  if (result.ok && routedReceipt) {
@@ -3897,11 +4334,18 @@ export function apply(ctx, config) {
3897
4334
  // the cheapest path. The initiator face (bare node team) and
3898
4335
  // foreign-zone labels are NOT gated here (F1b host-face rules, R-C
3899
4336
  // pending) — only the joined face closes.
3900
- const joinedRefusal = joinedFaceAdmissionRefusal(callerSession, team);
4337
+ // Design v5 (D2, Q1): a route to the bound team canvas is a route to the team's mailbox, admitted as one.
4338
+ const mailTeam = teamMailboxOf(team) ?? team;
4339
+ const joinedRefusal = joinedFaceAdmissionRefusal(callerSession, mailTeam);
3901
4340
  if (joinedRefusal !== undefined) {
3902
4341
  recordActivity('out', team, callerSession ?? 'local', false);
3903
4342
  return { ok: false, error: joinedRefusal, code: -32000 };
3904
4343
  }
4344
+ const routerRefusal = superagentRouterRefusal(undefined, team) ?? teamAddressRefusal(team, message);
4345
+ if (routerRefusal !== undefined) {
4346
+ recordActivity('out', team, callerSession ?? 'local', false);
4347
+ return { ok: false, error: routerRefusal, code: -32000 };
4348
+ }
3905
4349
  // #114 S5: the same header-safe id rule the wire face enforces.
3906
4350
  const idRefusal = taskIdFromCaller === undefined ? undefined : taskIdRefusal(taskIdFromCaller);
3907
4351
  if (idRefusal !== undefined) {
@@ -3909,7 +4353,9 @@ export function apply(ctx, config) {
3909
4353
  return { ok: false, error: idRefusal, code: -32000 };
3910
4354
  }
3911
4355
  // gh#224: a full target queue refuses a same-host delivery as the wire face does (receipts, CLI mailboxes pass).
3912
- const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
4356
+ // p1-mailbox-owes (S33): a deposit that declares it owes receipts is under the cap like a session.
4357
+ // superagent v4: a request to the team's A2A address is a Trigger; the engine's worker slots govern it, not this cap.
4358
+ const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, (cliDeposits.get(mailTeam) !== undefined && !cliDeposits.owes(mailTeam)) || superagentHost?.a2aAddress === mailTeam, ownTask);
3913
4359
  if (queueRefusal !== undefined) {
3914
4360
  recordActivity('out', team, callerSession ?? 'local', false);
3915
4361
  return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
@@ -3934,22 +4380,36 @@ export function apply(ctx, config) {
3934
4380
  // polls while alive; receipts and messages never wake anything).
3935
4381
  // The settler of any receipt on this path: this host's own caller (in-process).
3936
4382
  const localSettler = { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' };
3937
- const cliRow = cliDeposits.get(team);
4383
+ const cliRow = cliDeposits.get(mailTeam);
3938
4384
  if (cliRow !== undefined) {
3939
4385
  // #114 S4 (design §4.5): a receipt dropped into a local CLI mailbox is
3940
4386
  // exactly the settlement a CLI-originated task waits for — settle it,
3941
4387
  // as the wire face settles before ITS mailbox split.
3942
4388
  settleAndAnnounce(message, localSettler);
3943
- cliDeposits.mail(team, callerSession ?? 'unknown', message);
4389
+ const answered = parseReceipt(message);
4390
+ const disposition = answered?.envelope?.disposition;
4391
+ // p1-mail-admission: the same-host admission this delivery passed (a joined face's rule, else local-exempt).
4392
+ const localAdmission = localCandidateAdmission(admissionRecords, callerSession, mailTeam);
4393
+ const evicted = cliDeposits.mail(mailTeam, callerSession ?? 'unknown', message, undefined, { taskId: taskIdFromCaller, contextId, proof: 'in-process', ...(answered !== null ? { receiptFor: answered.taskId } : {}), ...(disposition !== undefined ? { disposition } : {}),
4394
+ ...(localAdmission.ok ? { admission: localAdmission.rule } : {}) });
4395
+ // p1-mailbox-owes (D-5): a row the cap pushed out takes its owed inbound row with it.
4396
+ for (const gone of evicted)
4397
+ if (gone.task_id !== undefined)
4398
+ taskLedger.evictInbound(gone.task_id, taskLedger.find(gone.task_id)?.team ?? mailTeam);
3944
4399
  recordActivity('in', team, callerSession ?? 'peer', true);
3945
- logger.info(`a2a: cli mailbox delivery for ${team} (from ${callerSession ?? 'unknown'})`);
3946
- return { ok: true, team, reply: `delivered to the mailbox of ${team} — the CLI polls while online`, task_id: taskIdFromCaller ?? '', context_id: contextId ?? '', task_status: 'TASK_STATE_COMPLETED' };
4400
+ logger.info(`a2a: cli mailbox delivery for ${mailTeam} (from ${callerSession ?? 'unknown'}${mailTeam !== team ? `, addressed to ${team}` : ''})`);
4401
+ // Design v5 (R5): the team works asynchronously — a sync route to it answers at once, delivered.
4402
+ if (mailTeam !== team && answered === null && taskIdFromCaller !== undefined)
4403
+ return { ok: true, team, reply: `delivered to the superagent team ${team} — it works asynchronously; its result arrives as this task's receipt`, task_id: taskIdFromCaller, context_id: contextId ?? '', task_status: 'TASK_STATE_DELIVERED', receipt: 'mailbox' };
4404
+ if (answered === null && taskIdFromCaller !== undefined && cliDeposits.owes(team)) {
4405
+ // p1-mailbox-owes (S33, D-2): delivered, not final. On one host the caller and the target share this ledger: the
4406
+ // caller's own owed row (peer 'local', booked by trackOwedTask) is the one the per-target cap counts, as for a
4407
+ // same-host session, so no separate inbound row is kept.
4408
+ return { ok: true, team, reply: `delivered to the mailbox of ${team} — it owes a receipt`, task_id: taskIdFromCaller, context_id: contextId ?? '', task_status: 'TASK_STATE_DELIVERED', receipt: 'mailbox' };
4409
+ }
4410
+ // S29 / #404: a plain mailbox is final for the caller, as on the wire face (receipt 'mailbox'), so no debt is booked.
4411
+ return { ok: true, team, reply: `delivered to the mailbox of ${team} — the CLI polls while online`, task_id: taskIdFromCaller ?? '', context_id: contextId ?? '', task_status: 'TASK_STATE_COMPLETED', receipt: 'mailbox' };
3947
4412
  }
3948
- // The native-teams claim probe is an await: pay it only when the four
3949
- // cheap synchronous locality checks missed (a registry team is a local
3950
- // candidate even though no session node backs it).
3951
- if (!teamIsLocal && team !== config.team)
3952
- teamIsLocal = await nativeTeamsClaims(team);
3953
4413
  if (!teamIsLocal || webServer === undefined)
3954
4414
  return undefined;
3955
4415
  // A receipt relayed between same-host sessions (the answering session
@@ -3971,42 +4431,7 @@ export function apply(ctx, config) {
3971
4431
  ? Promise.resolve(agent)
3972
4432
  : wake?.kind === 'flight' ? wake.flight : undefined;
3973
4433
  if (woken === undefined) {
3974
- // Bridge BEFORE the bare-team fallback — claim beats the initiator
3975
- // redirect, the same rule routeIntoAgent follows (a bare team name
3976
- // claimed by the registry must not fork its destination by wait
3977
- // semantics). Prepare-first: claim, seam, and parent initiator are
3978
- // everything that can fail fast, so a phantom delivery never answers
3979
- // success. The fire is truly detached (no caller signal — the turn
3980
- // ending must not silently retract an already-announced delivery);
3981
- // rounds emit no receipt in this slice (the `bridge` marker keeps
3982
- // the caller's ledger honest).
3983
- const prepared = await nativeTeamsPrepare(team);
3984
- if (prepared.ok) {
3985
- endRoute(flight);
3986
- recordActivity('out', team, 'local', true);
3987
- void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', undefined, hostDirectHeader)
3988
- .then(outcome => {
3989
- if (!outcome.ok) {
3990
- recordActivity('out', team, 'local', false);
3991
- logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
3992
- }
3993
- });
3994
- return {
3995
- ok: true,
3996
- team,
3997
- reply: `Delivered to ${team} (async native-teams round dispatched). The round settles through the team's own routing chain; it routes no A2A receipt in this slice — reconcile via context_id follow-ups.`,
3998
- task_id: taskId,
3999
- context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
4000
- task_status: 'TASK_STATE_DELIVERED',
4001
- bridge: 'native-teams',
4002
- };
4003
- }
4004
- if (prepared.reason === 'error') {
4005
- endRoute(flight);
4006
- recordActivity('out', team, 'local', false);
4007
- return { ok: false, error: prepared.error, code: -32000 };
4008
- }
4009
- // Claim declined: the bare process team still steers the live initiator.
4434
+ // The bare process team still steers the live initiator.
4010
4435
  const live = team === config.team ? liveAgent() : undefined;
4011
4436
  if (live !== undefined && callerIsAgent(live, callerSession ?? session)) {
4012
4437
  // Self-route refusal (same rule as the sync faces): the initiator
@@ -4024,6 +4449,12 @@ export function apply(ctx, config) {
4024
4449
  }
4025
4450
  try {
4026
4451
  const target = await woken;
4452
+ // superagent v4: whatever address resolved to it (a canvas team, the node team), the router takes no A2A work.
4453
+ if (superagentHost?.roleOf(String(target.id)) !== undefined) {
4454
+ endRoute(flight);
4455
+ recordActivity('out', team, callerSession ?? 'local', false);
4456
+ return { ok: false, error: superagentRouterRefusal(undefined, String(target.id)) ?? 'superagent team sessions take no A2A work', code: -32000 };
4457
+ }
4027
4458
  // N1 (review seat 7e4cf94a): dashed composite labels are NOT routable;
4028
4459
  // accept only <zone>/<short-id> shapes so envelopes stop bouncing -32004.
4029
4460
  // An explicit callback address (P2) wins over the caller-label
@@ -4035,8 +4466,9 @@ export function apply(ctx, config) {
4035
4466
  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>" }.)`;
4036
4467
  // #114 S5: the consumption search starts at the pre-steer log length.
4037
4468
  const steerFloor = sessionEventsOf(target).length;
4038
- followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
4039
- armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
4469
+ const acceptance = followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
4470
+ if (acceptance !== 'inserted')
4471
+ armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
4040
4472
  // #139: the caller's outbound row is born with this admission and the member that received it.
4041
4473
  if (taskIdFromCaller !== undefined)
4042
4474
  localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target), String(target.id)));
@@ -4045,76 +4477,22 @@ export function apply(ctx, config) {
4045
4477
  return {
4046
4478
  ok: true,
4047
4479
  team,
4048
- reply: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.${routable ? '' : unroutableCallerNote(String(callerSession ?? session))}`,
4480
+ ...(acceptance === 'inserted' ? { acceptance, consumed: false } : {}),
4481
+ reply: acceptance === 'inserted' ? ACCEPTANCE_INSERTED_NOTE : `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.${routable ? '' : unroutableCallerNote(String(callerSession ?? session))}`,
4049
4482
  task_id: taskId,
4050
4483
  context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
4051
4484
  task_status: 'TASK_STATE_DELIVERED',
4052
- consumed: taskLedger.deliveryFor(taskId)?.state === 'claimed',
4485
+ consumed: acceptance === 'inserted' ? false : taskLedger.deliveryFor(taskId)?.state === 'claimed',
4053
4486
  };
4054
4487
  }
4055
4488
  catch (error) {
4056
4489
  endRoute(flight);
4057
4490
  recordActivity('out', team, 'local', false);
4491
+ if (error instanceof FollowupAcceptanceUnknown)
4492
+ return { ok: false, error: ACCEPTANCE_UNKNOWN_NOTE, acceptance: 'unknown', task_id: taskId, code: -32000 };
4058
4493
  return { ok: false, error: `waking or steering the session for team "${team}" failed: ${String(error)}`, code: -32000 };
4059
4494
  }
4060
4495
  }
4061
- if (!asyncMode) {
4062
- // Bridge only on a steer-path miss, matching the documented chain
4063
- // (session node → canvas → cold wake → native-teams claim → bare): a
4064
- // registry claim must never shadow a live local session team. The
4065
- // caller's abort cancels the round through its own signal; the 180s
4066
- // deadline restores the bound the steer machinery observes.
4067
- // Issue #41: same initiator exemption on the sync path — the canvas
4068
- // live/cold tiers skip the dispatching session before steerable is
4069
- // decided, so a self-member falls through to remote members.
4070
- const steerable = resolveAgentForTeam(team) !== undefined
4071
- || canvasLiveAgent(canvasName, callerNodeId) !== undefined
4072
- || joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
4073
- || wakeColdTeam(team, callerNodeId).kind === 'flight';
4074
- if (!steerable && team !== config.team) {
4075
- const prepared = await nativeTeamsPrepare(team);
4076
- if (prepared.ok) {
4077
- const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', signal, hostDirectHeader);
4078
- endRoute(flight);
4079
- if (!outcome.ok) {
4080
- // The aborted-wait shape is earned only by an actual round
4081
- // cancellation (the caller's abort reached the seam). A
4082
- // pre-dispatch abort means the message never left the node, and
4083
- // a failed round means it never answered — both answer the
4084
- // honest error instead of claiming a delivery.
4085
- if (outcome.aborted === true) {
4086
- recordActivity('out', team, 'local', true);
4087
- return {
4088
- ok: true,
4089
- team,
4090
- reply: `The message was delivered to ${team} (native-teams round), but the wait was aborted (the round was cancelled with it).`,
4091
- task_id: taskId,
4092
- context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
4093
- task_status: 'TASK_STATE_ABORTED_WAIT',
4094
- bridge: 'native-teams',
4095
- };
4096
- }
4097
- recordActivity('out', team, 'local', false);
4098
- return { ok: false, error: outcome.error, code: -32000 };
4099
- }
4100
- recordActivity('out', team, 'local', true);
4101
- return {
4102
- ok: true,
4103
- team,
4104
- reply: outcome.reply,
4105
- task_id: taskId,
4106
- context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
4107
- task_status: outcome.settled ? 'TASK_STATE_COMPLETED' : 'TASK_STATE_DELIVERED',
4108
- ...(outcome.settled ? {} : { bridge: 'native-teams' }),
4109
- };
4110
- }
4111
- if (prepared.reason === 'error') {
4112
- endRoute(flight);
4113
- recordActivity('out', team, 'local', false);
4114
- return { ok: false, error: prepared.error, code: -32000 };
4115
- }
4116
- }
4117
- }
4118
4496
  const wait = routeIntoAgent(team, message, callerSession ?? session, taskId, undefined, undefined, originAgentId);
4119
4497
  const aborted = new Promise((_, reject) => {
4120
4498
  signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
@@ -4167,14 +4545,16 @@ export function apply(ctx, config) {
4167
4545
  return {
4168
4546
  ok: true,
4169
4547
  team,
4170
- reply: outcome.reply,
4548
+ reply: outcome.placeholder === true ? '已送达、未得到最终答复,请按 task id 对账。 ' + outcome.reply : outcome.reply,
4171
4549
  task_id: taskId,
4172
4550
  context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
4173
- task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
4174
- ...(outcome.bridge !== undefined ? { bridge: outcome.bridge } : {}),
4551
+ task_status: outcome.placeholder === true || outcome.ownTurn !== true && outcome.status === undefined ? 'TASK_STATE_DELIVERED' : outcome.status ?? 'TASK_STATE_COMPLETED',
4552
+ ...(outcome.acceptance === 'inserted' ? { acceptance: 'inserted', consumed: false } : {}),
4553
+ ...(outcome.ownTurn === true && outcome.placeholder !== true ? { replyFinal: true } : {}),
4554
+ ...(outcome.placeholder === true || outcome.ownTurn !== true ? { receipt: 'none' } : {}),
4175
4555
  };
4176
4556
  }
4177
- return { ok: false, error: outcome.error, code: -32000 };
4557
+ return { ok: false, error: outcome.error, code: -32000, ...(outcome.acceptance === 'unknown' ? { acceptance: 'unknown', task_id: taskId } : {}) };
4178
4558
  }
4179
4559
  /**
4180
4560
  * The route dispatcher's peer half: one candidate dial with the in-flight
@@ -4231,8 +4611,11 @@ export function apply(ctx, config) {
4231
4611
  if (home === undefined)
4232
4612
  throw new Error(`CLI receipt home is not pinned for ${team}`);
4233
4613
  const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
4234
- if (!delivered.ok)
4614
+ if (!delivered.ok) {
4615
+ if (delivered.acceptance === 'unknown')
4616
+ throw new FollowupAcceptanceUnknown();
4235
4617
  throw receiptFailure(delivered);
4618
+ }
4236
4619
  return delivered;
4237
4620
  }
4238
4621
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
@@ -4240,8 +4623,11 @@ export function apply(ctx, config) {
4240
4623
  if (taskId !== undefined)
4241
4624
  localBirths.delete(taskId);
4242
4625
  if (local !== undefined) {
4243
- if (!local.ok)
4626
+ if (!local.ok) {
4627
+ if (local.acceptance === 'unknown')
4628
+ throw new FollowupAcceptanceUnknown();
4244
4629
  throw receiptFailure(local);
4630
+ }
4245
4631
  return local;
4246
4632
  }
4247
4633
  if (cliReceipt)
@@ -4257,6 +4643,8 @@ export function apply(ctx, config) {
4257
4643
  const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
4258
4644
  if (result.ok)
4259
4645
  return result;
4646
+ if (result.acceptance === 'unknown')
4647
+ throw new FollowupAcceptanceUnknown();
4260
4648
  lastError = result.error;
4261
4649
  const failure = receiptFailure(result);
4262
4650
  if (receiptError === undefined || failure.transient)
@@ -4362,200 +4750,37 @@ export function apply(ctx, config) {
4362
4750
  }
4363
4751
  return candidates;
4364
4752
  }
4365
- // ── Outbound transport face: the `nativeTeamsA2a` bridge ─────────────
4366
- //
4367
- // Contract: @nelsonlongxiang/dsh-native-teams/src/a2a-face.ts (0.14.0,
4368
- // structural mirror in src/teams-bridge.ts). This plugin mounts the
4369
- // implementation under the face's frozen service key — protocol
4370
- // primitives live on the protocol face (three-party consensus), and
4371
- // native-teams probes it presence-guarded (unmounted ⇒ local-only
4372
- // routing, never a crash). The face ships only what this plugin already
4373
- // does for a2a_route: resolve via the ONE directory walk (same matcher
4374
- // submit uses — no drift-prone double implementation), submit via the
4375
- // direct-route dispatcher (per-candidate async gate, failover included),
4376
- // cancel via the cooperative stop-notice. It exposes nothing new — only
4377
- // teams the tracked peer network already publishes resolve here.
4378
- const bridgeFace = {
4379
- resolve: async (handle) => {
4380
- const failures = [];
4381
- const candidates = await directoryPeerCandidates(memoizedCardFetch(), handle, failures);
4382
- if (candidates.length === 0)
4383
- return undefined;
4384
- return { kind: 'node', hops: 1, url: candidates[0] };
4385
- },
4386
- submit: async (request, signal) => {
4387
- const abort = signal ?? new AbortController().signal;
4388
- const failures = [];
4389
- const fetch = memoizedCardFetch();
4390
- const candidates = await directoryPeerCandidates(fetch, request.handle, failures);
4391
- if (candidates.length === 0) {
4392
- throw new Error(`team "${request.handle}" is not published by any tracked peer${failures.length === 0 ? '' : ` (unresolved delegations: ${failures.join('; ')})`}`);
4393
- }
4394
- // The caller-born task id (B3): the orchestrator's dedup key IS the
4395
- // wire task id, so the peer's echo correlates with the caller's ledger.
4396
- const taskId = request.idempotencyKey !== undefined && request.idempotencyKey !== ''
4397
- ? request.idempotencyKey
4398
- : `direct-${Math.random().toString(16).slice(2, 10)}`;
4399
- // P2 receipt-callback: the receipt routes back to the submitting
4400
- // session's own node team, but ONLY when that session is a joined
4401
- // node. Without a joined parent there is no routable address that
4402
- // means "this session" on the peer — omitting the callback keeps the
4403
- // P1 behavior (the caller label cannot resolve on the peer, so the
4404
- // receipt is lost honestly); falling back to `config.team` would be
4405
- // actively harmful: a same-named peer resolves it LOCAL-first and the
4406
- // receipt steers the peer's own initiator, never leaving that host.
4407
- let callbackAddress;
4408
- if (request.callbackTarget) {
4409
- const parent = request.callbackTarget.parentSessionId;
4410
- if (sessionNodes.has(parent) || joinedSessions.has(parent))
4411
- callbackAddress = `${config.team}/${id8(parent)}`;
4412
- }
4413
- // #114 S3: the submitting session's node team, but only when the
4414
- // transport names a session this host knows (a node, a join, or a live
4415
- // root) — an unchecked id would let any submitter mint a seat. Anything
4416
- // else (absent, unknown, non-string) is attributed to the host label.
4417
- const parentSession = request.callbackTarget?.parentSessionId;
4418
- const knownParent = typeof parentSession === 'string' && parentSession !== ''
4419
- && (sessionNodes.has(parentSession) || joinedSessions.has(parentSession) || liveRoots.has(parentSession));
4420
- const sender = { kind: 'bridge', id: knownParent ? `${config.team}/${id8(parentSession)}` : session };
4421
- for (const candidate of candidates) {
4422
- // Capability gate per candidate, like a2a_route's loop: a
4423
- // delivery:async submit never dials a peer whose signed card does
4424
- // not declare async — failover must not silently degrade into the
4425
- // minutes-long blocking wait the gate exists to prevent.
4426
- let peerAsync = false;
4427
- if (request.delivery === 'async') {
4428
- const card = await fetch(candidate);
4429
- peerAsync = card?.capabilities?.async === true;
4430
- }
4431
- const result = await dispatchPeerCandidate(candidate, splitQualifiedMember(request.handle)?.handle ?? splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4432
- if (!result.ok) {
4433
- // Idempotency verdicts (B3) are terminal, not failover: a replay
4434
- // says the prior attempt at THIS peer stays authoritative —
4435
- // re-dispatching the same dedup key at another peer would execute
4436
- // the task twice; a conflict is a caller bug (same key, different
4437
- // payload) and must surface as a failure, not a redirect.
4438
- if (result.code === WIRE_ERROR_REPLAY_REJECTED) {
4439
- // async: acceptance IS the honest outcome — the submission is
4440
- // fire-and-forget and the prior attempt settles via the receipt
4441
- // contract.
4442
- if (request.delivery === 'async') {
4443
- return { kind: 'accepted', taskId, acceptedAt: new Date().toISOString(), contextId: request.contextId ?? '' };
4444
- }
4445
- // sync (W7 recovery table, S1): a sync caller cannot consume an
4446
- // acceptance — its verdict contract needs round text, and the
4447
- // handle-ack prose would settle a null verdict as a normal node.
4448
- // Throw instead, and the frozen -32003 literal MUST ride the
4449
- // message: structured codes have no survival channel through the
4450
- // downstream submitFailedError wrap (message-only), so the
4451
- // graph-loop classifier's probeText match on the code value is
4452
- // the only programmatic channel (adjudicated exception to
4453
- // "message 禁止下游解析").
4454
- throw new Error(`A2A submit for "${request.handle}" replayed at the peer's idempotency ledger (task ${taskId} duplicate within the idempotency window, -32003: the prior attempt stays authoritative — fail-closed; outcome retrieval needs a query face)`);
4455
- }
4456
- if (result.code === WIRE_ERROR_IDEMPOTENCY_CONFLICT) {
4457
- // -32002 literal on the message: same classification channel as the sync replay above.
4458
- throw new Error(`A2A submit for "${request.handle}" conflicts at the peer's idempotency ledger (task ${taskId} reused with a different payload, -32002)`);
4459
- }
4460
- if (abort.aborted)
4461
- break;
4462
- continue;
4463
- }
4464
- trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4465
- const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
4466
- if (result.task_status === 'TASK_STATE_COMPLETED') {
4467
- return { kind: 'completed', text: result.reply, taskId, contextId };
4468
- }
4469
- // DELIVERED / ABORTED_WAIT: the message is in and the receipt
4470
- // contract carries the outcome — the face maps both to the
4471
- // accepted shape (the submission stays visible in a2a_tasks).
4472
- return { kind: 'accepted', taskId, acceptedAt: new Date().toISOString(), contextId };
4473
- }
4474
- throw new Error(`A2A submit for "${request.handle}" failed on every candidate`);
4475
- },
4476
- queryOutcome: async (request, signal) => {
4477
- const abort = signal ?? new AbortController().signal;
4478
- if (request.taskId === '')
4479
- return undefined;
4480
- const failures = [];
4481
- const fetch = memoizedCardFetch();
4482
- const candidates = await directoryPeerCandidates(fetch, request.handle, failures);
4483
- // No candidate to ask: no increment of information — undefined, never
4484
- // a synthetic verdict.
4485
- if (candidates.length === 0)
4486
- return undefined;
4487
- let mismatch = false;
4488
- let answeredUnknown = false;
4489
- for (const candidate of candidates) {
4490
- // A caller cancel mid-fan-out ends the probe: answers collected
4491
- // before the abort must not aggregate into a verdict the caller
4492
- // already walked away from.
4493
- if (abort.aborted)
4494
- return undefined;
4495
- // Recompute the fingerprint EXACTLY as the submit pass computed it:
4496
- // the same shared implementation, and the same per-candidate async
4497
- // gate (delivery async × that peer's declared capability). Sync
4498
- // delivery — every graph-loop dispatch — is noWait:false throughout.
4499
- let peerAsync = false;
4500
- if (request.delivery === 'async') {
4501
- const card = await fetch(candidate);
4502
- peerAsync = card?.capabilities?.async === true;
4503
- }
4504
- const fingerprint = peerPayloadFingerprint({ caller: session, message: request.message, noWait: request.delivery === 'async' && peerAsync, team: request.handle });
4505
- const answer = await client.queryOutcome(candidate, request.taskId, fingerprint, abort);
4506
- if (answer === undefined)
4507
- continue; // transport miss — probe the next candidate (read-only fan-out)
4508
- if (!answer.found) {
4509
- if (answer.reason === 'payload-mismatch')
4510
- mismatch = true;
4511
- else
4512
- answeredUnknown = true;
4513
- continue;
4514
- }
4515
- return answer;
4516
- }
4517
- // A mismatch anywhere wins the aggregation: the key exists at a peer
4518
- // with a different payload — the most diagnostic negative there is.
4519
- if (mismatch)
4520
- return { found: false, reason: 'payload-mismatch' };
4521
- if (answeredUnknown)
4522
- return { found: false, reason: 'unknown-task' };
4523
- return undefined;
4524
- },
4525
- cancel: async (ref, reason) => {
4526
- const taskId = ref.taskId;
4527
- if (!taskId)
4528
- return false;
4529
- const row = taskLedger.list().find(entry => entry.taskId === taskId);
4530
- if (!row)
4531
- return false;
4532
- // Caller bookkeeping has priority. Notification compatibility never gates cancellation.
4533
- const cleared = taskLedger.cancel(taskId, reason).outcome === 'cleared';
4534
- if (!cleared)
4535
- return false;
4536
- try {
4537
- const notice = cancelNoticeText(taskId, reason);
4538
- if (row.peer === 'local' || row.direction === 'inbound')
4539
- await cancelQueuedTask(taskId, notice, session, row);
4540
- else {
4541
- const card = await cachedCardFetch(row.peer);
4542
- const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
4543
- const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
4544
- if (!result.ok)
4545
- throw Error(result.error);
4546
- }
4547
- }
4548
- catch (error) {
4549
- logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
4550
- }
4551
- return true;
4552
- },
4553
- };
4554
- ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
4555
4753
  infoTools.registerStatus();
4556
4754
  infoTools.registerTasks();
4557
4755
  infoTools.registerProbe();
4558
4756
  const pendingFinals = new Map();
4757
+ function removeFinalWaiter(agentId, entry) {
4758
+ const kept = (pendingFinals.get(agentId) ?? []).filter(other => other !== entry);
4759
+ if (kept.length)
4760
+ pendingFinals.set(agentId, kept);
4761
+ else
4762
+ pendingFinals.delete(agentId);
4763
+ entry.timeoutDisposer?.();
4764
+ }
4765
+ function pruneReceiptWaiters() {
4766
+ for (const [id, entries] of pendingFinals) {
4767
+ for (const entry of entries) {
4768
+ if (entry.purpose === 'receipt' && entry.taskId !== undefined && !taskLedger.isPending(entry.taskId))
4769
+ removeFinalWaiter(id, entry);
4770
+ }
4771
+ }
4772
+ }
4773
+ ctx.effect(() => {
4774
+ // Also cover task eviction/expiry independently of session idle events.
4775
+ const stop = ctx.timer.interval(pruneReceiptWaiters, 1000);
4776
+ return () => {
4777
+ stop();
4778
+ for (const entries of pendingFinals.values())
4779
+ for (const entry of entries)
4780
+ entry.timeoutDisposer?.();
4781
+ pendingFinals.clear();
4782
+ };
4783
+ }, 'a2a: final waiter lifecycle');
4559
4784
  function answerFinalSafely(entry, text, placeholder) {
4560
4785
  try {
4561
4786
  entry.answer(text, placeholder);
@@ -4581,16 +4806,17 @@ export function apply(ctx, config) {
4581
4806
  }, config.flushTimeoutMs);
4582
4807
  };
4583
4808
  function flushFinals(agentId) {
4809
+ pruneReceiptWaiters();
4584
4810
  const entries = pendingFinals.get(agentId);
4585
4811
  if (entries === undefined || entries.length === 0)
4586
4812
  return;
4587
4813
  const agent = ctx.get('agents')?.get(SessionId(agentId));
4588
4814
  if (agent === undefined) {
4589
- for (const entry of entries) {
4590
- entry.timeoutDisposer?.();
4591
- answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
4592
- }
4593
- pendingFinals.delete(agentId);
4815
+ for (const entry of entries)
4816
+ if (entry.purpose === 'http') {
4817
+ removeFinalWaiter(agentId, entry);
4818
+ answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
4819
+ }
4594
4820
  return;
4595
4821
  }
4596
4822
  // #114 S5: each waiter is answered from its OWN floor. The old shared
@@ -4628,6 +4854,9 @@ export function apply(ctx, config) {
4628
4854
  if (answered.length === 0)
4629
4855
  return;
4630
4856
  for (const { entry, reply, placeholder } of answered) {
4857
+ // A closed empty/failed reading has no later attributable final: remove it,
4858
+ // invoke the existing placeholder no-op, and dispose its timer. HTTP expiry
4859
+ // itself does not enter this receipt path (receipt uses the original TTL).
4631
4860
  // Remove one callback before invoking (it may synchronously re-enter), never the whole batch.
4632
4861
  const current = pendingFinals.get(agentId) ?? [];
4633
4862
  if (!current.includes(entry))