@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.39

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 (41) hide show
  1. package/README.md +340 -335
  2. package/README_zh.md +241 -236
  3. package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
  4. package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
  5. package/cli/a2a.mjs +12 -4
  6. package/cli/serve.mjs +132 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-id.js +27 -0
  9. package/lib/canvas-store.js +7 -1
  10. package/lib/client.js +63 -63
  11. package/lib/config.js +125 -0
  12. package/lib/direct-forward.js +64 -18
  13. package/lib/faces/admin.js +4 -1
  14. package/lib/faces/wire.js +24 -6
  15. package/lib/flood-guard.js +3 -1
  16. package/lib/index.js +43 -145
  17. package/lib/live-roots.js +39 -0
  18. package/lib/origin-claim.js +3 -2
  19. package/lib/queue-guard.js +3 -1
  20. package/lib/skill/references/cli.md +214 -203
  21. package/lib/skill/references/routing.md +4 -0
  22. package/lib/task-auth.js +14 -10
  23. package/lib/types/canvas-id.d.ts +9 -0
  24. package/lib/types/config.d.ts +278 -0
  25. package/lib/types/direct-forward.d.ts +25 -3
  26. package/lib/types/faces/admin.d.ts +2 -0
  27. package/lib/types/faces/wire.d.ts +7 -0
  28. package/lib/types/index.d.ts +3 -277
  29. package/lib/types/live-roots.d.ts +35 -0
  30. package/lib/types/origin-claim.d.ts +1 -0
  31. package/lib/types/queue-guard.d.ts +2 -0
  32. package/lib/types/task-auth.d.ts +3 -0
  33. package/lib/types/types.d.ts +2 -0
  34. package/nexus-stage/package-lock.json +2262 -2262
  35. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  36. package/nexus-stage/src/edges.ts +72 -72
  37. package/nexus-stage/src/layout-wire.ts +158 -158
  38. package/nexus-stage/src/seat.ts +35 -35
  39. package/nexus-stage/src/stage-name.ts +13 -13
  40. package/nexus-stage/src/tokens.ts +31 -31
  41. package/package.json +218 -218
@@ -2,10 +2,29 @@
2
2
  import { DEFAULT_HOP_BUDGET, guardHeader, mintGuard } from "./recursion-guard.js";
3
3
  import { briefOf } from "./task-ledger.js";
4
4
  import { parseReceipt } from "./receipt.js";
5
+ import { splitCanvasOwner } from "./canvas-id.js";
6
+ import { claimCovers } from "./task-auth.js";
7
+ /** An early receipt waits at most this long for its forward row to be born (#298). */
8
+ export const BIRTH_WAIT_CAP_MS = 60_000;
5
9
  /** Birth barrier exists only for eligible, actively dispatched tasks; receipts never reserve a slot. */
6
10
  export class ForwardBirths {
11
+ waitCapMs;
7
12
  pending = new Map();
8
- wait(taskId) { return this.pending.get(taskId); }
13
+ constructor(waitCapMs = BIRTH_WAIT_CAP_MS) {
14
+ this.waitCapMs = waitCapMs;
15
+ }
16
+ /**
17
+ * Resolves when the birth is released or after the cap, whichever is first. After the cap the receipt
18
+ * is judged by the normal rules against whatever row exists then — never accepted in advance (#298).
19
+ */
20
+ wait(taskId) {
21
+ const birth = this.pending.get(taskId);
22
+ if (birth === undefined)
23
+ return undefined;
24
+ let timer;
25
+ const cap = new Promise(resolve => { timer = setTimeout(resolve, this.waitCapMs); timer.unref?.(); });
26
+ return Promise.race([birth, cap]).finally(() => clearTimeout(timer));
27
+ }
9
28
  begin(taskId) {
10
29
  if (this.pending.size >= 64 || this.pending.has(taskId))
11
30
  return undefined;
@@ -16,29 +35,33 @@ export class ForwardBirths {
16
35
  }
17
36
  export function createDirectForward(d) {
18
37
  return async (request) => {
19
- const refuse = (why) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why });
38
+ const refuse = (why, next) => ({ ok: false, code: -32000, task_id: request.taskId, error: 'not forwarded: ' + why, ...(next !== undefined ? { next } : {}) });
39
+ const publish = 'run `a2a-net publish` to renew this CLI deposit on its home host, then send again with a new task id';
20
40
  if (!d.enabled())
21
- return refuse('forwardDirect is off');
41
+ return refuse('forwardDirect is off', 'ask this host\'s operator to set forwardDirect: true (the default since 0.6.38)');
22
42
  if (d.isolated())
23
- return refuse('host is isolated');
43
+ return refuse('host is isolated', 'this host forwards nothing while isolated; deposit the CLI on another home host');
24
44
  if (request.guarded)
25
45
  return refuse('an inbound X-A2A-Guard is present; already forwarded');
26
46
  if (!request.localCliProof || !d.deposited(request.caller))
27
- return refuse('caller is not a proven online local CLI deposit');
28
- const scope = await d.scope(request.caller, request.team);
47
+ return refuse('caller is not a proven online local CLI deposit', publish);
48
+ // #282 S2: candidates honour an owner-qualified id; scope, the peer, the receivers and the ledger see the plain team.
49
+ const plainTeam = splitCanvasOwner(request.team).team;
50
+ const scope = await d.scope(request.caller, plainTeam);
29
51
  if (!scope.ok)
30
- return refuse('target is outside sender scope: ' + scope.error);
52
+ return refuse('target is outside sender scope: ' + scope.error, `ask this host's operator to link ${request.team} here: drag it (or one of its members) into a canvas team on this host`);
31
53
  const failures = [];
32
54
  const directory = await d.candidates(request.team, failures, request.caller);
33
55
  const guard = guardHeader({ ...mintGuard(), hopBudget: DEFAULT_HOP_BUDGET - 1 });
34
56
  for (const url of directory.urls) {
35
57
  if (!d.deposited(request.caller))
36
- return refuse('local CLI deposit expired before dispatch');
58
+ return refuse('local CLI deposit expired before dispatch', publish);
37
59
  const peerCard = await directory.card(url);
38
- const receivers = request.team.includes('/canvas/') ? peerCard?.canvasOrg?.find(row => row.team === request.team)?.members.filter(member => member !== request.caller) : undefined;
39
- if (request.team.includes('/canvas/')) {
40
- if (receivers === undefined || receivers.length === 0 || receivers.some(member => !d.memberLinked(member))) {
41
- failures.push(url + ': forwarding a canvas requires a direct operator link to every receiving member for its receipt return path');
60
+ const receivers = plainTeam.includes('/canvas/') ? peerCard?.canvasOrg?.find(row => row.team === plainTeam)?.members.filter(member => member !== request.caller) : undefined;
61
+ if (plainTeam.includes('/canvas/')) {
62
+ // #282 S3 (same team authorizes): the owner's signed canvas is the return path; receipts settle only from these frozen receivers on this peer key (#274).
63
+ if (receivers === undefined || receivers.length === 0) {
64
+ failures.push(url + ': the owner card lists no receiving member of this canvas');
42
65
  continue;
43
66
  }
44
67
  }
@@ -46,12 +69,12 @@ export function createDirectForward(d) {
46
69
  const receipt = parseReceipt(request.message) !== null;
47
70
  const release = receipt ? () => { } : d.births.begin(request.taskId);
48
71
  if (release === undefined)
49
- return refuse('forward receipt correlation capacity is full');
72
+ return refuse('forward receipt correlation capacity is full', 'wait for in-flight forwards to settle, then send again with a new task id');
50
73
  try {
51
74
  // The same selected-peer snapshot supplies both return-link gate and settlement birth.
52
- const answer = await d.dispatch(url, request, guard);
75
+ const answer = await d.dispatch(url, plainTeam === request.team ? request : { ...request, team: plainTeam }, guard);
53
76
  const result = !answer.ok && answer.ownBudgetExhausted === true ? { ...answer, code: -32005 } : answer;
54
- d.activity(request.team, url, result.ok);
77
+ d.activity(plainTeam, url, result.ok);
55
78
  if (result.ok || result.code === -32005) {
56
79
  // A receipt owes no receipt; native bridge rounds have no A2A receipt contract.
57
80
  // Match routeTrunk/trackOwedTask rather than manufacturing an unpayable debt.
@@ -63,9 +86,9 @@ export function createDirectForward(d) {
63
86
  const birth = { forwarded: true, ...(request.auth !== undefined ? { auth: request.auth } : {}), ...(peerCard?.publicKey ? { peerKey: peerCard.publicKey } : {}), ...(peerCard?.session ? { peerHost: peerCard.session } : {}), ...(deliveredTo !== undefined ? { deliveredTo } : {}), ...(receivers !== undefined ? { receiverMembers: receivers } : {}) };
64
87
  // Exactly one outbound row. A completed sync response is receiptless bookkeeping.
65
88
  if (result.ok && (result.task_status === 'TASK_STATE_COMPLETED' || result.receipt === 'none'))
66
- d.ledger.trackReceiptless(request.taskId, request.team, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
89
+ d.ledger.trackReceiptless(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
67
90
  else
68
- d.ledger.track(request.taskId, request.team, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
91
+ d.ledger.track(request.taskId, plainTeam, url, request.contextId, briefOf(request.message), { kind: 'cli', id: request.caller }, birth);
69
92
  return result;
70
93
  }
71
94
  // The shared dispatcher's sync recovery query may prove a definitive failed
@@ -78,6 +101,29 @@ export function createDirectForward(d) {
78
101
  release();
79
102
  }
80
103
  }
81
- return { ok: false, code: -32004, task_id: request.taskId, error: 'forwarding to "' + request.team + '" failed: ' + (failures.join('; ') || 'no eligible peer candidate') };
104
+ const noReceiver = failures.some(f => f.includes('lists no receiving member'));
105
+ const next = noReceiver ? `the owner's canvas ${plainTeam} lists no member besides you; ask its owner to add one, or route to a member's own handle` : failures.length === 0 ? `no linked peer lists ${request.team}; check the name with \`a2a-net teams\`` : undefined;
106
+ return { ok: false, code: -32004, task_id: request.taskId, error: 'forwarding to "' + request.team + '" failed: ' + (failures.join('; ') || 'no eligible peer candidate'), ...(next !== undefined ? { next } : {}) };
82
107
  };
83
108
  }
109
+ /**
110
+ * #282 S3 (same team authorizes): the admission basis for a receipt delivered to a local CLI mailbox
111
+ * from a member the home never linked — only for that CLI's own pending forward, only from a receiver
112
+ * frozen at birth, only when the body-bound (Sig3) claim's card key is the frozen peer key. Anything else:
113
+ * undefined. Trust assumption (design D2): the selected owner host is honest about its own canvas — it may
114
+ * list any label as a receiver, including one of this host's session labels in the same zone.
115
+ */
116
+ export function forwardReceiptBasis(row, caller, cliHandle, claim) {
117
+ // The signer's zone must cover the caller label: B never signs for another zone's member (review of #287 M4).
118
+ if (!claimCovers(caller, claim.caller))
119
+ return undefined;
120
+ if (row === undefined || row.status !== 'pending' || row.forwarded !== true || row.sender?.kind !== 'cli' || row.sender.id !== cliHandle)
121
+ return undefined;
122
+ // Body-bound (Sig3) only: a stripped Sig3 would let a captured envelope carry another body (review of #287 P3-1).
123
+ if (!claim.bound || claim.bodyBound !== true || row.peerKey === undefined || claim.card.publicKey !== row.peerKey)
124
+ return undefined;
125
+ // A legacy row's deliveredTo may be the canvas handle itself (#272/#275): never a receiver.
126
+ if (row.deliveredTo !== undefined && (row.deliveredTo === row.team || row.deliveredTo.includes('/canvas/')))
127
+ return undefined;
128
+ return (row.deliveredTo !== undefined ? [row.deliveredTo] : row.receiverMembers ?? []).includes(caller) ? `peer-org:${row.team}` : undefined;
129
+ }
@@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { canvasNameReserved as reservedCanvasName, isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
22
+ import { qualifiedCanvasId } from "../canvas-id.js";
22
23
  import { refreshPeerCard, registerCanvasNotify } from "./canvas-notify.js";
23
24
  import { CLI_HANDLE_RE } from "../cli-deposit.js";
24
25
  import { applyOwnerSettings, parseSeatNames } from "../owner-settings.js";
@@ -135,6 +136,8 @@ export function registerAdminFaces(webServer, d) {
135
136
  teams: canvasStore.list().map(name => ({
136
137
  name,
137
138
  team: canvasTeamOf(name),
139
+ // #282 S2: the owner-qualified routing id.
140
+ id: `${canvasTeamOf(name)}@${d.ownCanvasFingerprint}`,
138
141
  // gh#152: members as the local tiers read them (gh#131). One
139
142
  // of this host's own handles renders as the local session it
140
143
  // names — join/live facts instead of an opaque remote chip — and
@@ -321,7 +324,7 @@ export function registerAdminFaces(webServer, d) {
321
324
  const unreachable = card !== undefined && entry?.missAt !== undefined ? { unreachable: true } : {};
322
325
  return Array.isArray(org)
323
326
  ? { url, ...unreachable, org: org.filter((e) => e !== null && typeof e === 'object')
324
- .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
327
+ .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: typeof e.team === 'string' ? e.team : '', id: typeof e.team === 'string' ? qualifiedCanvasId(e.team, card?.publicKey) : '', members: Array.isArray(e.members) ? e.members.filter((m) => typeof m === 'string') : [] }))
325
328
  .filter(e => e.team !== '') }
326
329
  : { url, ...unreachable, org: [] };
327
330
  }),
package/lib/faces/wire.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { isRemoteMemberRef, validRemoteMemberRef } from "../canvas-store.js";
2
+ import { splitCanvasOwner } from "../canvas-id.js";
2
3
  import { CLI_HANDLE_RE } from "../cli-deposit.js";
3
4
  import { TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_LIMIT } from "../session-transcript.js";
4
5
  import { refuseIfBudgetExhausted } from "../recursion-guard.js";
5
6
  import { peerPayloadFingerprint, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED } from "../idempotency-store.js";
6
7
  import { briefOf } from "../task-ledger.js";
7
8
  import { parseReceipt, taskIdRefusal } from "../receipt.js";
8
- import { buildAuthorization, claimCovers, refusalOf } from "../task-auth.js";
9
+ import { buildAuthorization, claimCovers, nextOf, refusalOf } from "../task-auth.js";
9
10
  import { isRootOriginUrl } from "../origin-claim.js";
10
11
  import { withResponseFields } from "../response-fields.js";
11
12
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "../transport-caps.js";
@@ -434,7 +435,9 @@ export function registerWireFaces(webServer, d) {
434
435
  return;
435
436
  }
436
437
  }
437
- const team = typeof body.team === 'string' ? body.team : '';
438
+ // #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
439
+ const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
440
+ const team = ownerQualified.owner !== undefined && ownerQualified.owner === d.ownCanvasFingerprint ? ownerQualified.team : typeof body.team === 'string' ? body.team : '';
438
441
  const message = typeof body.message === 'string' ? body.message : '';
439
442
  const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
440
443
  // P2 receipt-callback: where THIS node's receipt should route on
@@ -482,11 +485,25 @@ export function registerWireFaces(webServer, d) {
482
485
  const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
483
486
  const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true, rawBody }) : undefined;
484
487
  const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
485
- const admission = a2aCallerAdmission(caller, team, cliProof);
488
+ let admission = a2aCallerAdmission(caller, team, cliProof);
489
+ // #282 S3 (same team authorizes): a receipt for this host's own forward, to the CLI that sent it, from a
490
+ // receiver frozen at birth on the selected peer's key — the owner's signed canvas is the return path.
491
+ const echoed = !admission.ok && cliDeposits.get(team) !== undefined ? parseReceipt(message) : null;
492
+ if (echoed !== null) {
493
+ // Verify first, so an unsigned or Sig2-only caller cannot hold the connection on an in-flight forward (#287 review P3);
494
+ // then a body-bound receipt may beat the dispatch's HTTP answer: wait for this forward's row to be born (#287 P2).
495
+ const receiptClaim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
496
+ if (receiptClaim.ok && receiptClaim.bodyBound === true)
497
+ await waitForForwardBirth(message);
498
+ const basis = receiptClaim.ok ? d.forwardReceiptBasis(echoed.taskId, caller, team, receiptClaim) : undefined;
499
+ if (basis !== undefined)
500
+ admission = { ok: true, via: 'peer-org', rule: 'peer-org', basis: [basis] };
501
+ }
486
502
  const callerRefusal = refusalOf(admission);
487
503
  if (callerRefusal !== undefined || !admission.ok) {
488
504
  recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
489
- const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
505
+ const next = nextOf(admission);
506
+ const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED', ...(next !== undefined ? { next } : {}) });
490
507
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
491
508
  res.end(payload);
492
509
  return;
@@ -589,7 +606,8 @@ export function registerWireFaces(webServer, d) {
589
606
  // quiet window around it.
590
607
  d.touchWakeDemand();
591
608
  const forwardMiss = async (original) => {
592
- const result = await d.forwardDirect({ team, message, caller, taskId, contextId, noWait,
609
+ // #282 S2 review P1: forward with the id the caller sent — our own `@fp8` must find no other owner.
610
+ const result = await d.forwardDirect({ team: typeof body.team === 'string' && ownerQualified.owner !== undefined ? body.team : team, message, caller, taskId, contextId, noWait,
593
611
  auth: buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget: caller, receivedAt: admittedAt.at }),
594
612
  localCliProof: claim.ok && claim.cli === true && claim.bound && claim.caller === caller, guarded: req.headers['x-a2a-guard'] !== undefined }).catch((error) => ({ ok: false, error: `forwarding unavailable: ${String(error)}`, code: -32000, task_id: taskId }));
595
613
  const failure = result.ok ? '' : await d.diagnoseCanvas(team, original + ' — ' + result.error, claim.ok && claim.cli === true && claim.bound && claim.caller === caller ? caller : undefined);
@@ -597,7 +615,7 @@ export function registerWireFaces(webServer, d) {
597
615
  idempotencyStore.recordOutcome(taskId, { status: 'failed', error: failure });
598
616
  else if (result.ok && result.task_status === 'TASK_STATE_COMPLETED')
599
617
  idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: result.reply });
600
- const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED' });
618
+ const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED', ...(result.next !== undefined ? { next: result.next } : {}) });
601
619
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
602
620
  res.end(payload);
603
621
  };
@@ -104,7 +104,9 @@ export function createFloodGuard(outcomes, recordActivity) {
104
104
  const error = 'flood-guard: ' + reason + ' (gh#91): file data must use an independent channel; no delivery occurred' + (advice === undefined ? '' : '; use-file-channel: ' + advice.message);
105
105
  outcomes.recordOutcome(taskId, { status: 'failed', error });
106
106
  recordActivity('in', team, caller, false);
107
- const payload = JSON.stringify({ error, reason, code: WIRE_ERROR_RATE_LIMITED, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
107
+ // #282 S1: one next step — file bytes go through the file channel, never chat messages.
108
+ const next = 'send file data with a2a_file_send / `a2a-net file send <path> <team>` instead of message chunks; resend other text as one message with a new task id';
109
+ const payload = JSON.stringify({ error, reason, code: WIRE_ERROR_RATE_LIMITED, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED', next });
108
110
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
109
111
  res.end(payload);
110
112
  return true;
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * inbound direct routes into live agent sessions.
6
6
  * @module @nelsonlongxiang/dsh-open-a2a-net
7
7
  */
8
- import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
10
10
  import { networkInterfaces } from 'node:os';
11
11
  import { dirname, join } from 'node:path';
@@ -26,7 +26,7 @@ export function resolveCallerLabel(input) {
26
26
  return input.nodeTeam;
27
27
  return input.sessionLabel;
28
28
  }
29
- import { signCard, verifyCard } from "./card.js";
29
+ import { encodePublicKey, signCard, verifyCard } from "./card.js";
30
30
  import { GroupStore } from "./group-store.js";
31
31
  import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
32
32
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
@@ -43,20 +43,20 @@ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
43
43
  import { TeamMembershipStore } from "./team-store.js";
44
44
  import { PeerStore } from "./peer-store.js";
45
45
  import { SelfReferralFilter } from "./self-suppress.js";
46
- import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
46
+ import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
47
47
  import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
48
48
  import { runReceiptLadder } from "./receipt-ladder.js";
49
49
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
50
50
  import { createFloodGuard } from "./flood-guard.js";
51
- import { createQueueGuard, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
51
+ import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
52
52
  import { resolveZone } from "./zone.js";
53
53
  import { registerA2aNetworkSkill } from "./skill-registration.js";
54
54
  import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
55
55
  import { AuthObserver } from "./auth-observer.js";
56
56
  import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
57
- import s from '@deepseek-ai/schemastery';
58
57
  import { MessageId } from '@deepseek-ai/dsh-llm';
59
58
  import { SessionId } from '@deepseek-ai/dsh-session';
59
+ import { liveRootAgents } from "./live-roots.js";
60
60
  import { A2aClient } from "./a2a-client.js";
61
61
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
62
62
  import { registerAdminFaces } from "./faces/admin.js";
@@ -80,127 +80,10 @@ export { A2aClient } from "./a2a-client.js";
80
80
  export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
81
81
  export const name = 'a2a';
82
82
  export const inject = ['tools', 'timer', 'systemPrompt'];
83
- /** Schemastery configuration for the A2A client plugin row. */
84
- export const Config = s.object({
85
- apiKey: s.string().default(''),
86
- session: s.string().default(''),
87
- team: s.string().default('dsh'),
88
- /** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
89
- gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
90
- settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
91
- announce: s.boolean().default(false),
92
- isolated: s.boolean().default(false),
93
- forwardDirect: s.boolean().default(true),
94
- agentName: s.string().default('DeepSeek Harness A2A node'),
95
- peers: s.array(s.string()).default([]),
96
- delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
97
- dshHome: s.string().default(''),
98
- sessionNodes: s.boolean().default(true),
99
- wakeJoinedOnBoot: s.boolean().default(false),
100
- wakePrewarmDelayMs: s.number().default(10_000),
101
- wakePrewarmQuietMs: s.number().default(5_000),
102
- wakeBootStaggerMs: s.number().default(3_000),
103
- wakeReconcile: s.boolean().default(true),
104
- wakeReconcileIntervalMs: s.number().default(60_000),
105
- wakeReconcileBackoffBaseMs: s.number().default(5_000),
106
- wakeReconcileMaxBackoffMs: s.number().default(600_000),
107
- /**
108
- * S4 (gh#11 N7): the joinable-team curation list. A model calling
109
- * a2a_team_join may only declare membership in a team with an EXACT
110
- * entry here — trailing-`*` patterns are readable but authorize nothing
111
- * (no mint rights: a pattern pre-authorizing every future name under it
112
- * would let a model mint teams nobody created). Default empty: the
113
- * owner's no-self-join ruling, institutionalized as default-deny; a
114
- * refused join carries guidance instead of failing silently.
115
- */
116
- teamJoinAllowlist: s.array(s.string()).default([]),
117
- seatNames: s.dict(s.any()).default({}),
118
- /**
119
- * S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
120
- * caller↔target shared team membership before addressing — a session
121
- * with no declared team has no network (the unteamed-peer defect the
122
- * owner reproduced on 4080 and locally). Fleet compatibility rides the
123
- * roster capability flag (0.5.44 cards advertise roster:true; older
124
- * peers read as legacy). Set false to restore the open outbound seam.
125
- */
126
- teamScopeRouting: s.boolean().default(true),
127
- /**
128
- * S5 (default-on, owner directive 2026-10-20): the information-plane
129
- * tools answer from the joined-team scope only — a2a_teams lists
130
- * [this host] rows plus peer rows serving a joined team (note field
131
- * explains the scope); a2a_status narrows peers / in-flight / activity
132
- * to that scope (local traffic and idempotency aggregates stay);
133
- * a2a_probe sweeps serving peers and refuses a single URL whose
134
- * verified card cannot prove service. A host whose nodes declare no
135
- * teams keeps host-internal facts only. Set false to restore
136
- * fleet-wide disclosure.
137
- */
138
- teamScopeInfo: s.boolean().default(true),
139
- /**
140
- * Peer boundary model — connection layer: an empty list keeps the
141
- * historical open-referral posture; a non-empty list admits a referral
142
- * only when its host matches an entry (exact host[:port], bare domain
143
- * suffix, or full URL). Seeds from `peers` are owner-configured and
144
- * unaffected.
145
- */
146
- peerAllowlist: s.array(s.string()).default([]),
147
- /**
148
- * Peer boundary model — connection layer: when false, referral URLs are
149
- * neither learned nor offered; the tracked set stays the configured seed
150
- * set for the lifetime of the config that named them.
151
- */
152
- referralLearning: s.boolean().default(true),
153
- /**
154
- * gh#8 (N9): team-name resolution rides the operator-established peer
155
- * basis (config seeds + delegates) only; the referral-learned tail's
156
- * self-published card claims do not bind team names. Set true to restore
157
- * the legacy whole-store basis (fleet migration window).
158
- */
159
- referralTeamResolution: s.boolean().default(false),
160
- /**
161
- * #19 D2 (ruling final, owner-level knob): how much of a session team's
162
- * activity EXCERPT the card face publishes in sessionTeams descriptions.
163
- * 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
164
- * to a shape-only prefix; 'off' omits the description field entirely.
165
- * Titles and team handles stay in every tier — the knob is the excerpt,
166
- * not the listing.
167
- */
168
- exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
169
- /**
170
- * gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
171
- * the window (counted as legacyInbound); closing it rejects them like
172
- * failed signatures. See docs/protocol/delivery-origin-auth.md §4.
173
- */
174
- originClaimWindow: s.boolean().default(true),
175
- /**
176
- * OriginClaim unbound window: also sign/accept the legacy envelope that
177
- * names no audience (counted as unboundInbound); closing it leaves the
178
- * audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
179
- */
180
- originClaimUnboundWindow: s.boolean().default(true),
181
- /** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
182
- originClaimAudiences: s.array(s.string()).default([]),
183
- /** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
184
- originClaimUrl: s.string().default(''),
185
- /**
186
- * v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
187
- * route re-wakes its target with a one-line nudge (defect
188
- * t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
189
- */
190
- asyncNudgeDelayMs: s.number().default(120_000),
191
- /** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
192
- targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
193
- stateColdRowsTtlMs: s.number().default(5_000),
194
- cardCacheTtlMs: s.number().default(60_000),
195
- cardCacheNegativeTtlMs: s.number().default(30_000),
196
- remoteRowsTtlMs: s.number().default(15_000),
197
- cardTtlMs: s.number().default(172_800_000),
198
- flushTimeoutMs: s.number().default(300_000),
199
- routeTimeoutMs: s.number().default(1_800_000),
200
- taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
201
- nativeTeamsInbound: s.boolean().default(false),
202
- nativeRoundWaitMs: s.number().default(180_000),
203
- });
83
+ import { Config } from "./config.js";
84
+ import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
85
+ import { forwardReceiptBasis } from "./direct-forward.js";
86
+ export { Config };
204
87
  /**
205
88
  * Register the outbound peer tools, the direct-route endpoint, and (when
206
89
  * composed) the announced card and session-node control surface.
@@ -564,6 +447,8 @@ export function apply(ctx, config) {
564
447
  ...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
565
448
  }));
566
449
  const nodePrivateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
450
+ /** #282 S2: this host's canvas owner fingerprint (`<canvas>@<fp8>` names our own canvas). */
451
+ const ownCanvasFingerprint = canvasOwnerFingerprint(encodePublicKey(createPublicKey(nodePrivateKey)));
567
452
  /** Our own card URL once the web server settles — the OriginClaim anchor. */
568
453
  let ownCardUrl = '';
569
454
  /**
@@ -1360,7 +1245,8 @@ export function apply(ctx, config) {
1360
1245
  // side-effect-only.
1361
1246
  ctx.on('agent/created', ({ agent }) => {
1362
1247
  const agents = ctx.get('agents');
1363
- if (agents === undefined || !agents.roots().some(root => root.id === agent.id))
1248
+ // #291: hosts without roots() (0.2.2-rc) resolve top-level via list()+delegation depth; never throws.
1249
+ if (agents === undefined || !liveRootAgents(agents).some(root => root.id === agent.id))
1364
1250
  return undefined;
1365
1251
  liveRoots.set(String(agent.id), agent);
1366
1252
  if (joinedSessions.has(String(agent.id)))
@@ -1375,7 +1261,7 @@ export function apply(ctx, config) {
1375
1261
  // the remount above (leave is the only intent remover).
1376
1262
  sessionNodes.delete(String(agent.id));
1377
1263
  });
1378
- for (const agent of ctx.get('agents')?.roots() ?? []) {
1264
+ for (const agent of liveRootAgents(ctx.get('agents'))) {
1379
1265
  liveRoots.set(String(agent.id), agent);
1380
1266
  if (joinedSessions.has(String(agent.id)))
1381
1267
  mountSessionNode(agent);
@@ -1680,7 +1566,7 @@ export function apply(ctx, config) {
1680
1566
  get unboundInboundCount() { return unboundInboundCount; },
1681
1567
  get boundMissInboundCount() { return boundMissInboundCount; },
1682
1568
  get boundMissRecent() { return boundMissRecent; },
1683
- get ownCardUrl() { return ownCardUrl; },
1569
+ get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
1684
1570
  get ownerSeatNames() { return ownerSeatNames; },
1685
1571
  set ownerSeatNames(v) { ownerSeatNames = v; },
1686
1572
  get ownerAllowlist() { return ownerAllowlist; },
@@ -1852,7 +1738,7 @@ export function apply(ctx, config) {
1852
1738
  return agents.requireInitiator();
1853
1739
  }
1854
1740
  catch {
1855
- const roots = agents.roots();
1741
+ const roots = liveRootAgents(agents);
1856
1742
  return roots.length > 0 ? roots[0] : undefined;
1857
1743
  }
1858
1744
  }
@@ -2593,7 +2479,8 @@ export function apply(ctx, config) {
2593
2479
  idempotencyStore,
2594
2480
  verifyOriginClaim,
2595
2481
  remoteCliProof,
2596
- forwardDirect: request => forwardDirect(request),
2482
+ forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
2483
+ forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
2597
2484
  diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
2598
2485
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2599
2486
  recordActivity,
@@ -3297,6 +3184,7 @@ export function apply(ctx, config) {
3297
3184
  };
3298
3185
  }
3299
3186
  const remote = failures.filter(entry => !entry.startsWith('local: '));
3187
+ const queueFull = failures.some(entry => entry.startsWith('local: target-queue-full'));
3300
3188
  const suffix = team.slice(team.indexOf('/') + 1);
3301
3189
  const near = [...new Set([
3302
3190
  ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`),
@@ -3313,9 +3201,10 @@ export function apply(ctx, config) {
3313
3201
  `team "${team}" is not published by any configured peer${remote.length > 0 ? ` (unresolved delegations: ${remote.join('; ')})` : ''}`,
3314
3202
  ...failures.filter(entry => entry.startsWith('local: ')).map(entry => `local reason: ${entry.slice('local: '.length)}`),
3315
3203
  near.length > 0 ? `nearby advertised teams: ${near.join(', ')}` : undefined,
3316
- 'next: enumerate live alternatives with a2a_teams; do not retry unchanged; joining is the target session\'s own gesture (its sidebar) — relay that request to the human',
3204
+ // #282 S1: a full local queue means the target exists and is busy — waiting is the only right next step.
3205
+ queueFull ? `next: ${QUEUE_FULL_NEXT}` : 'next: enumerate live alternatives with a2a_teams; do not retry unchanged; joining is the target session\'s own gesture (its sidebar) — relay that request to the human',
3317
3206
  ].filter((line) => line !== undefined);
3318
- return { ok: false, error: lines.join(' — '), code: -32004 };
3207
+ return queueFull ? { ok: false, error: lines.join(' — '), code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT } : { ok: false, error: lines.join(' — '), code: -32004 };
3319
3208
  };
3320
3209
  actTools.registerRoute();
3321
3210
  // #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
@@ -3358,9 +3247,9 @@ export function apply(ctx, config) {
3358
3247
  for (const owner of owners.filter(row => row.team === team && !row.local)) {
3359
3248
  if (!owner.members.includes(caller))
3360
3249
  missing.push(owner.owner + ': caller is not an advertised canvas member');
3361
- for (const member of owner.members.filter(member => member !== caller))
3362
- if (!canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(member)))
3363
- missing.push(owner.owner + ': missing direct operator link to receiver ' + member);
3250
+ // #282 S3: no per-member link here; the owner's signed canvas is the return path.
3251
+ if (owner.members.every(member => member === caller))
3252
+ missing.push(owner.owner + ': the canvas lists no receiving member besides the caller');
3364
3253
  }
3365
3254
  }
3366
3255
  return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
@@ -3368,7 +3257,6 @@ export function apply(ctx, config) {
3368
3257
  const forwardDirect = createDirectForward({
3369
3258
  births: forwardingBirths,
3370
3259
  enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
3371
- memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
3372
3260
  scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
3373
3261
  candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
3374
3262
  dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
@@ -3400,6 +3288,12 @@ export function apply(ctx, config) {
3400
3288
  // node team when it has joined, else the node label), so the receiver
3401
3289
  // can answer with one a2a_route instead of an unroutable display label.
3402
3290
  a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
3291
+ // #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
3292
+ // lands on a same-named local canvas, and the directory walk keeps only that owner.
3293
+ const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
3294
+ const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
3295
+ if (ownerQualified.owner !== undefined)
3296
+ args = { ...args, team: ownerQualified.team };
3403
3297
  // A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
3404
3298
  const answer = parseReceipt(args.message);
3405
3299
  if (agent !== undefined && answer !== null)
@@ -3472,7 +3366,7 @@ export function apply(ctx, config) {
3472
3366
  const failures = [];
3473
3367
  if (signal?.aborted)
3474
3368
  return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
3475
- const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3369
+ const outcome = foreignOwner !== undefined ? undefined : await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
3476
3370
  const localBirth = localBirths.get(taskId);
3477
3371
  localBirths.delete(taskId);
3478
3372
  if (outcome !== undefined) {
@@ -3493,7 +3387,7 @@ export function apply(ctx, config) {
3493
3387
  // union in member order. Non-canvas teams keep the single-walk shape
3494
3388
  // unchanged. The ledger stays keyed to the canvas team the caller
3495
3389
  // named; the peer request carries the member handle it must resolve.
3496
- const canvasName = parseCanvasTeamName(args.team);
3390
+ const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
3497
3391
  // gh#131: one of this host's own handles is a local member, never a directory target.
3498
3392
  const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
3499
3393
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
@@ -3509,7 +3403,7 @@ export function apply(ctx, config) {
3509
3403
  }
3510
3404
  const candidatePool = [];
3511
3405
  for (const target of targets) {
3512
- for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
3406
+ for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
3513
3407
  candidatePool.push({ candidate, target });
3514
3408
  }
3515
3409
  }
@@ -3593,7 +3487,7 @@ export function apply(ctx, config) {
3593
3487
  const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
3594
3488
  if (queueRefusal !== undefined) {
3595
3489
  recordActivity('out', team, callerSession ?? 'local', false);
3596
- return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
3490
+ return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
3597
3491
  }
3598
3492
  const canvasName = parseCanvasTeamName(team);
3599
3493
  // Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
@@ -3978,14 +3872,18 @@ export function apply(ctx, config) {
3978
3872
  // (gh#8: a squatter two gossip hops away captured routes by naming
3979
3873
  // itself after the target). referralTeamResolution restores the legacy
3980
3874
  // whole-store basis for fleets mid-migration.
3875
+ // #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
3876
+ const qualified = splitCanvasOwner(team);
3877
+ team = qualified.team;
3878
+ const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
3981
3879
  const peers = resolutionPeers();
3982
3880
  const candidates = [];
3983
- const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
3881
+ const cards = (await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }))).map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
3984
3882
  // Check after fetching all cards: a seeded peer publishing the same id8
3985
3883
  // makes the caller handle ambiguous, so do not send a canvas payload.
3986
3884
  const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
3987
3885
  const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
3988
- const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3886
+ const resolutions = qualified.owner !== undefined ? [] : await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
3989
3887
  if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
3990
3888
  failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
3991
3889
  return [];
@@ -4074,7 +3972,7 @@ export function apply(ctx, config) {
4074
3972
  const card = await fetch(candidate);
4075
3973
  peerAsync = card?.capabilities?.async === true;
4076
3974
  }
4077
- const result = await dispatchPeerCandidate(candidate, request.handle, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
3975
+ const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4078
3976
  if (!result.ok) {
4079
3977
  // Idempotency verdicts (B3) are terminal, not failover: a replay
4080
3978
  // says the prior attempt at THIS peer stays authoritative —
@@ -4107,7 +4005,7 @@ export function apply(ctx, config) {
4107
4005
  break;
4108
4006
  continue;
4109
4007
  }
4110
- trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4008
+ trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
4111
4009
  const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
4112
4010
  if (result.task_status === 'TASK_STATE_COMPLETED') {
4113
4011
  return { kind: 'completed', text: result.reply, taskId, contextId };