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

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 (51) hide show
  1. package/README.md +7 -4
  2. package/README_zh.md +7 -4
  3. package/assets/nexusDist/assets/index-C7HagaGy.css +1 -0
  4. package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +35 -5
  7. package/lib/a2a-client.js +3 -3
  8. package/lib/canvas-discovery.js +29 -0
  9. package/lib/client.js +63 -63
  10. package/lib/direct-forward.js +49 -27
  11. package/lib/faces/admin.js +30 -4
  12. package/lib/faces/canvas-notify.js +8 -6
  13. package/lib/faces/wire.js +29 -10
  14. package/lib/index.js +107 -24
  15. package/lib/origin-claim.js +31 -8
  16. package/lib/owner-settings.js +38 -1
  17. package/lib/peer-directory.js +1 -1
  18. package/lib/peer-link-display.js +15 -0
  19. package/lib/skill/references/cli.md +11 -3
  20. package/lib/skill/references/pitfalls.md +1 -1
  21. package/lib/skill/references/setup.md +9 -9
  22. package/lib/task-auth.js +1 -1
  23. package/lib/task-ledger-rows.js +220 -0
  24. package/lib/task-ledger.js +13 -196
  25. package/lib/tools/info.js +14 -5
  26. package/lib/types/a2a-client.d.ts +1 -0
  27. package/lib/types/canvas-discovery.d.ts +20 -0
  28. package/lib/types/direct-forward.d.ts +9 -0
  29. package/lib/types/faces/admin.d.ts +11 -0
  30. package/lib/types/faces/canvas-notify.d.ts +3 -1
  31. package/lib/types/faces/wire.d.ts +4 -1
  32. package/lib/types/index.d.ts +3 -1
  33. package/lib/types/origin-claim.d.ts +16 -4
  34. package/lib/types/owner-settings.d.ts +5 -0
  35. package/lib/types/peer-link-display.d.ts +13 -0
  36. package/lib/types/task-auth.d.ts +2 -0
  37. package/lib/types/task-ledger-rows.d.ts +352 -0
  38. package/lib/types/task-ledger.d.ts +7 -288
  39. package/lib/types/tools/info.d.ts +5 -0
  40. package/lib/types/types.d.ts +2 -1
  41. package/nexus-stage/src/planning-member-handles.ts +28 -1
  42. package/nexus-stage/src/planning-menus.ts +12 -5
  43. package/nexus-stage/src/planning-view.ts +18 -14
  44. package/nexus-stage/src/seat-name-editor.ts +31 -0
  45. package/nexus-stage/src/session-drag.ts +64 -0
  46. package/nexus-stage/src/wb-directory.ts +2 -0
  47. package/nexus-stage/src/workbench.css +2 -0
  48. package/nexus-stage/src/workbench.ts +16 -5
  49. package/package.json +1 -1
  50. package/assets/nexusDist/assets/index-DIQNDBy6.js +0 -9
  51. package/assets/nexusDist/assets/index-z33lHtLw.css +0 -1
package/lib/index.js CHANGED
@@ -31,12 +31,15 @@ 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";
33
33
  import { LayoutStore } from "./layout-store.js";
34
+ import { peerLinkDisplay } from "./peer-link-display.js";
35
+ import { canvasOwners, canvasFailure } from "./canvas-discovery.js";
34
36
  import { JoinedSessions } from "./joined-store.js";
35
37
  import { ExposureStore } from "./exposure-store.js";
38
+ import { loadSeatNames } from "./owner-settings.js";
36
39
  import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
37
40
  import { cliHomeChoices, decideCliHome } from "./cli-home.js";
38
41
  import { outboundScope } from "./outbound-scope.js";
39
- import { createDirectForward } from "./direct-forward.js";
42
+ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
40
43
  import { TeamMembershipStore } from "./team-store.js";
41
44
  import { PeerStore } from "./peer-store.js";
42
45
  import { SelfReferralFilter } from "./self-suppress.js";
@@ -87,7 +90,7 @@ export const Config = s.object({
87
90
  settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
88
91
  announce: s.boolean().default(false),
89
92
  isolated: s.boolean().default(false),
90
- forwardDirect: s.boolean().default(false),
93
+ forwardDirect: s.boolean().default(true),
91
94
  agentName: s.string().default('DeepSeek Harness A2A node'),
92
95
  peers: s.array(s.string()).default([]),
93
96
  delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
@@ -111,6 +114,7 @@ export const Config = s.object({
111
114
  * refused join carries guidance instead of failing silently.
112
115
  */
113
116
  teamJoinAllowlist: s.array(s.string()).default([]),
117
+ seatNames: s.dict(s.any()).default({}),
114
118
  /**
115
119
  * S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
116
120
  * caller↔target shared team membership before addressing — a session
@@ -570,7 +574,9 @@ export function apply(ctx, config) {
570
574
  * request) and the relay/notify faces.
571
575
  */
572
576
  function originClaimHeaders(target) {
573
- return signOriginClaim(target, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
577
+ // No local web/card anchor yet: keep the preexisting absent-envelope migration posture, not a malformed Sig3.
578
+ const signedTarget = ownCardUrl === '' ? { url: target.url, method: target.method } : target;
579
+ return signOriginClaim(signedTarget, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
574
580
  }
575
581
  const client = new A2aClient({
576
582
  apiKey: config.apiKey,
@@ -598,6 +604,8 @@ export function apply(ctx, config) {
598
604
  // are read at call time, so the panel's write applies WITHOUT a restart;
599
605
  // announce is boot-time publishing (restart pending flag for the UI).
600
606
  let ownerAllowlist = [...(config.teamJoinAllowlist ?? [])];
607
+ let ownerSeatNames = loadSeatNames(config.seatNames ?? {}, reason => logger.warn(reason));
608
+ ctx.tools.guard(exec => exec.name.startsWith('a2a_') && exec.arguments !== null && typeof exec.arguments === 'object' && (Object.hasOwn(exec.arguments, 'seatName') || Object.hasOwn(exec.arguments, 'seatNames')) ? 'seatName is human-owned; use the workbench owner settings, not model tools' : undefined);
601
609
  // Truthiness matches the ORIGINAL read site (`if (config.teamScopeInfo)`):
602
610
  // literal compositions without the schema default carry undefined, which
603
611
  // reads as the OPEN plane — `!== false` would silently scope them.
@@ -675,7 +683,13 @@ export function apply(ctx, config) {
675
683
  // queryable here, and the correlating `[A2A receipt] task <id>` message
676
684
  // resolves it — the caller-side half of the receipt contract, persisted so
677
685
  // a restart does not orphan the reconciliation.
678
- const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs });
686
+ const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs, warn: message => logger.warn(message) });
687
+ const enforceBlockers = taskLedger.settlementEnforceBlockers();
688
+ const enforceRefused = config.settlementAuth === 'enforce' && enforceBlockers.length > 0 ? { reason: 'legacy forwarded canvas debts lack receiver facts', blockers: enforceBlockers } : undefined;
689
+ if (enforceRefused !== undefined) {
690
+ logger.error('settlementAuth enforce refused; running observe (#125/#275): %s', JSON.stringify(enforceRefused));
691
+ config = { ...config, settlementAuth: 'observe' };
692
+ }
679
693
  // Unjoined-node reporting ingress (design: docs/design/unjoined-report-ingress.md):
680
694
  // a one-way signal channel for sessions with no network membership — a
681
695
  // fallen-off or never-joined session reports "I exist / I have a task"
@@ -720,6 +734,12 @@ export function apply(ctx, config) {
720
734
  * the consumer's own discipline — keep seam listeners synchronous.
721
735
  * @param message - the inbound or relayed message text.
722
736
  */
737
+ const forwardingBirths = new ForwardBirths();
738
+ async function waitForForwardBirth(message) {
739
+ const receipt = parseReceipt(message);
740
+ if (receipt !== null)
741
+ await forwardingBirths.wait(receipt.taskId);
742
+ }
723
743
  function settleAndAnnounce(message, settler) {
724
744
  const parsedForJudge = parseReceipt(message);
725
745
  const judge = settlementJudgeFor(settler, parsedForJudge);
@@ -1253,14 +1273,15 @@ export function apply(ctx, config) {
1253
1273
  const LEGACY_PREWARM_PROBE_TITLE = '[prewarm] 请回复 ok。';
1254
1274
  function nodeMetadataOf(agent) {
1255
1275
  const title = sessionTitleOf(agent);
1256
- const cwd = agent.session.header?.cwd;
1276
+ const cwd = agent.session?.header?.cwd;
1257
1277
  // Display-layer fallback (pure presentation, zero data mutation): a title
1258
1278
  // that IS the legacy probe reads as machine junk in every directory and
1259
1279
  // card — surface the stable seat label instead. The session-title
1260
1280
  // service's stored value is untouched.
1261
1281
  const displayTitle = title !== undefined && title !== '' && title !== LEGACY_PREWARM_PROBE_TITLE ? title : undefined;
1262
1282
  return {
1263
- name: displayTitle !== undefined ? displayTitle : sessionLabelOf(agent),
1283
+ name: ownerSeatNames[String(agent.id)] ?? (displayTitle !== undefined ? displayTitle : sessionLabelOf(agent)),
1284
+ ...(ownerSeatNames[String(agent.id)] !== undefined ? { seatName: ownerSeatNames[String(agent.id)] } : {}),
1264
1285
  description: recentActivityOf(agent),
1265
1286
  ...(cwd !== undefined && cwd !== '' ? { workspace: cwd } : {}),
1266
1287
  };
@@ -1652,13 +1673,16 @@ export function apply(ctx, config) {
1652
1673
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1653
1674
  registerAdminFaces(webServer, {
1654
1675
  ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, 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,
1655
- indexUrl: import.meta.url,
1676
+ indexUrl: import.meta.url, enforceRefused,
1656
1677
  get cardCache() { return cardCache; },
1657
1678
  get remoteRowsCache() { return remoteRowsCache; },
1658
1679
  get legacyInboundCount() { return legacyInboundCount; },
1659
1680
  get unboundInboundCount() { return unboundInboundCount; },
1660
1681
  get boundMissInboundCount() { return boundMissInboundCount; },
1682
+ get boundMissRecent() { return boundMissRecent; },
1661
1683
  get ownCardUrl() { return ownCardUrl; },
1684
+ get ownerSeatNames() { return ownerSeatNames; },
1685
+ set ownerSeatNames(v) { ownerSeatNames = v; },
1662
1686
  get ownerAllowlist() { return ownerAllowlist; },
1663
1687
  set ownerAllowlist(v) { ownerAllowlist = v; },
1664
1688
  get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
@@ -1776,7 +1800,7 @@ export function apply(ctx, config) {
1776
1800
  ...[...sessionNodes.values()].filter(isJoinedNode).map(agent => exposureRow(sessionTeamOf(agent), nodeMetadataOf(agent))),
1777
1801
  ...joinedSessions.list()
1778
1802
  .filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
1779
- .map(id => exposureRow(`${config.team}/${id8(id)}`, { name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
1803
+ .map(id => exposureRow(`${config.team}/${id8(id)}`, { name: ownerSeatNames[id] ?? `${session}-${id8(id)}`, ...(ownerSeatNames[id] !== undefined ? { seatName: ownerSeatNames[id] } : {}), description: 'cold — not loaded; routing here wakes the session' })),
1780
1804
  // gh#45/47: deposited CLI identities publish as directory rows —
1781
1805
  // the fleet sees them as member cards (draggable = the operator
1782
1806
  // link). Their "zone" is the agent-name. gh#47: an EXPIRED
@@ -2111,15 +2135,15 @@ export function apply(ctx, config) {
2111
2135
  * marker instead of a (false) completion.
2112
2136
  * @param taskId - the correlation key the steered receipt header carries.
2113
2137
  */
2114
- async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
2138
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2115
2139
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
2116
2140
  if (agent !== undefined) {
2117
- return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
2141
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
2118
2142
  }
2119
2143
  // Wake-on-route: a cold joined team materializes on demand, then steers.
2120
2144
  const wake = wakeColdTeam(team);
2121
2145
  if (wake.kind === 'flight') {
2122
- return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2146
+ return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2123
2147
  }
2124
2148
  // Native-teams inbound bridge (opt-in): a registry team claims the
2125
2149
  // handle and dispatches through its authoritative seam. Checked before
@@ -2148,7 +2172,7 @@ export function apply(ctx, config) {
2148
2172
  if (live !== undefined) {
2149
2173
  if (callerIsAgent(live, caller))
2150
2174
  return { ok: false, error: selfRouteText(team) };
2151
- return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
2175
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
2152
2176
  }
2153
2177
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
2154
2178
  }
@@ -2163,7 +2187,7 @@ export function apply(ctx, config) {
2163
2187
  * @param caller - caller label for the steered prefix.
2164
2188
  * @returns the agent's final reply, or the explicit failure.
2165
2189
  */
2166
- function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
2190
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2167
2191
  return new Promise((resolve) => {
2168
2192
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
2169
2193
  resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
@@ -2180,7 +2204,11 @@ export function apply(ctx, config) {
2180
2204
  : `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
2181
2205
  const text = `${headerLine}\n\n${message}`;
2182
2206
  try {
2207
+ const floor = sessionEventsOf(agent).length;
2183
2208
  steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
2209
+ onSteered?.(agent, floor);
2210
+ if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
2211
+ queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
2184
2212
  }
2185
2213
  catch (error) {
2186
2214
  // The waiter was registered above, so the key always holds it; the
@@ -2213,6 +2241,15 @@ export function apply(ctx, config) {
2213
2241
  * closing originClaimUnboundWindow; boundMissInbound: those of them whose
2214
2242
  * bound form missed (a peer dials us by a name not in our audiences). */
2215
2243
  let unboundInboundCount = 0, boundMissInboundCount = 0;
2244
+ /** Which verified callers missed, and the origin they dialled: what to add to originClaimAudiences (#64). */
2245
+ const boundMissRecent = [];
2246
+ const noteBoundMiss = (miss) => {
2247
+ boundMissInboundCount += 1;
2248
+ const at = boundMissRecent.findIndex(row => row.caller === miss.caller && row.audience === miss.audience);
2249
+ const row = at >= 0 ? boundMissRecent.splice(at, 1)[0] : { ...miss, count: 0, lastAt: 0 };
2250
+ boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2251
+ boundMissRecent.length = Math.min(boundMissRecent.length, 16);
2252
+ };
2216
2253
  function verifyOriginClaim(req, options) {
2217
2254
  return verifyOriginClaimOf(req, {
2218
2255
  cardCache,
@@ -2220,10 +2257,11 @@ export function apply(ctx, config) {
2220
2257
  audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2221
2258
  unboundOpen: config.originClaimUnboundWindow !== false,
2222
2259
  onUnbound: () => { unboundInboundCount += 1; },
2223
- onBoundMiss: () => { boundMissInboundCount += 1; },
2260
+ onBoundMiss: noteBoundMiss,
2224
2261
  loopbackArrival: (config.originClaimUrl ?? '') === '',
2225
2262
  now: Date.now(),
2226
2263
  ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2264
+ ...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
2227
2265
  });
2228
2266
  }
2229
2267
  /**
@@ -2556,6 +2594,7 @@ export function apply(ctx, config) {
2556
2594
  verifyOriginClaim,
2557
2595
  remoteCliProof,
2558
2596
  forwardDirect: request => forwardDirect(request),
2597
+ diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
2559
2598
  callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
2560
2599
  recordActivity,
2561
2600
  bumpLegacyInbound: () => { legacyInboundCount += 1; },
@@ -2569,7 +2608,7 @@ export function apply(ctx, config) {
2569
2608
  taskLedger,
2570
2609
  refuseIfFlooded,
2571
2610
  refuseIfQueueFull,
2572
- settleAndAnnounce,
2611
+ settleAndAnnounce, waitForForwardBirth,
2573
2612
  touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
2574
2613
  session,
2575
2614
  resolveAgentForTeam,
@@ -2817,10 +2856,10 @@ export function apply(ctx, config) {
2817
2856
  }
2818
2857
  /**
2819
2858
  * gh#20 W-A: THIS host's view of one tracked peer's link state (the
2820
- * mirror of callerLinkedToThisHost, judged from our side about THEM):
2821
- * · linked — bilateral seeding (our config.peers carries a URL of the
2822
- * peer's origin AND the peer's card carries ours), or a canvas remote
2823
- * ref naming the peer's zone or one of its session teams;
2859
+ * display projection, not an admission decision:
2860
+ * · linked — bilateral URL seeding, or a canvas reference to an exact
2861
+ * nonlocal session handle uniquely advertised by that peer card key;
2862
+ * a shared zone or a minimal card alone never proves host identity;
2824
2863
  * · stranger — no operator-established basis (a verified key is not a
2825
2864
  * relationship);
2826
2865
  * · verified — we hold a signature-valid cached card (the badge's
@@ -2835,6 +2874,15 @@ export function apply(ctx, config) {
2835
2874
  * Pure over warm caches and config; never fetches.
2836
2875
  */
2837
2876
  function peerLinkState(peerUrl) {
2877
+ return peerLinkDisplay(peerUrl, {
2878
+ card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, seeds: config.peers,
2879
+ refs: canvasStore.list().flatMap(name => [...canvasStore.remoteMembersOf(name)]),
2880
+ ownHandles: new Set([...joinedSessions.list(), ...liveRoots.keys()].map(id => `${config.team}/${id8(id)}`)),
2881
+ cards: [...cardCache.values()].flatMap(entry => entry.card === undefined ? [] : [entry.card]),
2882
+ });
2883
+ }
2884
+ // Compatibility for existing CLI home/admission semantics: badge projection must not change authorization.
2885
+ function peerAdmissionLinkState(peerUrl) {
2838
2886
  const cached = cardCache.get(peerUrl)?.card;
2839
2887
  const verified = cached !== undefined;
2840
2888
  const zone = cached?.team ?? '';
@@ -2901,7 +2949,7 @@ export function apply(ctx, config) {
2901
2949
  const card = await fetchPeerCard(url).catch(() => undefined);
2902
2950
  if (card !== undefined)
2903
2951
  cardCache.set(url, { at: Date.now(), card });
2904
- return { url, card, linked: card === undefined || peerLinkState(url).link === 'linked' };
2952
+ return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
2905
2953
  });
2906
2954
  }
2907
2955
  async function cliHomeForAdd(handle, requested) {
@@ -2935,7 +2983,7 @@ export function apply(ctx, config) {
2935
2983
  if (card === undefined || card.publicKey !== claim.card.publicKey)
2936
2984
  continue;
2937
2985
  cardCache.set(url, { at: Date.now(), card });
2938
- if (peerLinkState(url).link !== 'linked')
2986
+ if (peerAdmissionLinkState(url).link !== 'linked')
2939
2987
  continue;
2940
2988
  const row = card.sessionTeams?.find(item => item.team === handle);
2941
2989
  if (row !== undefined)
@@ -3037,7 +3085,7 @@ export function apply(ctx, config) {
3037
3085
  };
3038
3086
  const SWEEP_CONCURRENCY = 6;
3039
3087
  const infoTools = createInfoTools({
3040
- ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
3088
+ ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
3041
3089
  inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
3042
3090
  teamMemberships, exposureStore, idempotencyStore, cardCache,
3043
3091
  id8, sessionTeamOf,
@@ -3106,6 +3154,7 @@ export function apply(ctx, config) {
3106
3154
  team: canvasTeamOf(name),
3107
3155
  session,
3108
3156
  name,
3157
+ kind: 'canvas', owner: ownCardUrl, memberCount: canvasStore.membersOf(name).length,
3109
3158
  description: `canvas team — ${members.length} member${members.length === 1 ? '' : 's'}, ${live} live`,
3110
3159
  local: true,
3111
3160
  origin: localOrigin,
@@ -3284,7 +3333,40 @@ export function apply(ctx, config) {
3284
3333
  * caller label — the "能发即能收" invariant: whatever it addresses can
3285
3334
  * answer it with one route.
3286
3335
  */
3336
+ const knownCanvasOwners = () => canvasOwners(canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name) })), ownCardUrl, [...cardCache.entries()].flatMap(([url, entry]) => entry.card === undefined ? [] : [{ url, card: entry.card }]));
3337
+ const diagnoseCanvas = async (team, reason, caller) => {
3338
+ if (!new RegExp('^.+/canvas/[^/]+$').test(team))
3339
+ return reason;
3340
+ try {
3341
+ await listDirectoryTeams(false, true);
3342
+ }
3343
+ catch (error) {
3344
+ return reason + ' — canvas discovery unavailable: ' + String(error);
3345
+ }
3346
+ const resolve = localHandleResolver();
3347
+ const localId = caller === undefined ? undefined : resolve(caller);
3348
+ const owners = knownCanvasOwners().filter(row => !ownerTeamScopeInfo || (caller !== undefined && cliDeposits.get(caller) !== undefined) || (row.local ? localId !== undefined && row.members.some(ref => ref === localId || resolve(ref) === localId) : (row.scopeMembers ?? row.members).some(ref => { const id = resolve(ref); return id !== undefined && joinedSessions.has(id); })));
3349
+ const missing = [];
3350
+ if (caller !== undefined && cliDeposits.get(caller) !== undefined) {
3351
+ if (!config.forwardDirect)
3352
+ missing.push('home forwardDirect is off');
3353
+ if (config.isolated)
3354
+ missing.push('home is isolated');
3355
+ const scope = config.teamScopeRouting ? await outboundScope(senderScopeRecords, caller, caller, team) : { ok: true };
3356
+ if (!scope.ok)
3357
+ missing.push('sender scope: ' + scope.error);
3358
+ for (const owner of owners.filter(row => row.team === team && !row.local)) {
3359
+ if (!owner.members.includes(caller))
3360
+ 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);
3364
+ }
3365
+ }
3366
+ return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
3367
+ };
3287
3368
  const forwardDirect = createDirectForward({
3369
+ births: forwardingBirths,
3288
3370
  enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
3289
3371
  memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
3290
3372
  scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
@@ -3343,7 +3425,7 @@ export function apply(ctx, config) {
3343
3425
  scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
3344
3426
  const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
3345
3427
  if (!scope.ok)
3346
- return localRoute(scope);
3428
+ return localRoute({ ...scope, error: await diagnoseCanvas(args.team, scope.error, callerHandle) });
3347
3429
  }
3348
3430
  }
3349
3431
  let callerSession = resolveCallerLabel({
@@ -3471,7 +3553,8 @@ export function apply(ctx, config) {
3471
3553
  }
3472
3554
  if (failures.length > 0)
3473
3555
  logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
3474
- return routeVerdict(args.team, failures, candidatePool.length);
3556
+ const verdict = routeVerdict(args.team, failures, candidatePool.length);
3557
+ return { ...verdict, error: await diagnoseCanvas(args.team, verdict.error, callerSession) };
3475
3558
  }
3476
3559
  /**
3477
3560
  * The route dispatcher's local half: same-host candidates dial in-process,
@@ -4,7 +4,7 @@
4
4
  * face (card, /a2a/direct, /a2a/log, /a2a/canvas-op, /a2a/canvas-changed)
5
5
  * runs.
6
6
  *
7
- * Two canonical forms ride the same four base headers
7
+ * Three canonical forms share the base caller headers
8
8
  * (X-A2A-Caller / -Url / -Ts):
9
9
  * · bound (v2, §2.1): JSON.stringify([tag, caller, callerUrl, ts,
10
10
  * audience, METHOD, path]) in X-A2A-Caller-Sig2, the audience origin in
@@ -14,18 +14,22 @@
14
14
  * · unbound (v1, legacy): JSON.stringify([caller, callerUrl, ts]) in
15
15
  * X-A2A-Caller-Sig. Emitted and accepted only while the unbound window
16
16
  * is open (originClaimUnboundWindow); accepted ones are counted.
17
- * Once the window closes a present Sig2 is the only thing verified. While
17
+ * A present Sig3 requires the exact raw body and v3 signature, with no downgrade.
18
+ * Without Sig3, once the window closes a present Sig2 is the only thing verified. While
18
19
  * it is open, a Sig2 that misses (audience or signature) falls back to the
19
20
  * Sig the envelope also carries — no more than stripping Sig2 would give —
20
21
  * and the miss is counted, so a peer dialled by an unlisted name keeps
21
22
  * working through the rollout and shows on the gauge.
22
23
  */
23
- import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
24
+ import { createHash, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
24
25
  import { verifyCard } from "./card.js";
25
26
  /** ts skew tolerance (protocol constant, §7). */
26
27
  export const ORIGIN_CLAIM_SKEW_MS = 5 * 60_000;
27
28
  /** Domain tag of the bound form: a v1 signature can never verify as v2. */
28
29
  export const ORIGIN_CLAIM_BOUND_TAG = 'a2a-origin-claim/2';
30
+ export const ORIGIN_CLAIM_BODY_TAG = 'a2a-origin-claim/3';
31
+ /** Digest the exact UTF-8 string/bytes passed to transport, never reserialized JSON. */
32
+ export const originBodySha256 = (body) => createHash('sha256').update(body).digest('base64url');
29
33
  /** Only the canonical origin root can claim that origin's identity. */
30
34
  export function isRootOriginUrl(value) {
31
35
  try {
@@ -62,6 +66,10 @@ export function unboundCanonical(caller, callerUrl, ts) {
62
66
  export function boundCanonical(caller, callerUrl, ts, audience, method, path) {
63
67
  return JSON.stringify([ORIGIN_CLAIM_BOUND_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path]);
64
68
  }
69
+ /** The body-bound v3 form is domain-separated from v1/v2, with the digest appended. */
70
+ export function bodyBoundCanonical(caller, callerUrl, ts, audience, method, path, bodySha256) {
71
+ return JSON.stringify([ORIGIN_CLAIM_BODY_TAG, caller, callerUrl, ts, audience, method.toUpperCase(), path, bodySha256]);
72
+ }
65
73
  /** An origin in URL's normal form, or '' when the text is not an http(s) URL. */
66
74
  export function originOf(url) {
67
75
  try {
@@ -98,6 +106,11 @@ export function signOriginClaim(target, signer) {
98
106
  if (audience !== '') {
99
107
  headers['X-A2A-Caller-Aud'] = audience;
100
108
  headers['X-A2A-Caller-Sig2'] = sign(boundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url)));
109
+ if (target.body !== undefined) {
110
+ const digest = originBodySha256(target.body);
111
+ headers['X-A2A-Body-Sha256'] = digest;
112
+ headers['X-A2A-Caller-Sig3'] = sign(bodyBoundCanonical(caller, callerUrl, ts, audience, target.method, pathOf(target.url), digest));
113
+ }
101
114
  }
102
115
  if (signer.unbound)
103
116
  headers['X-A2A-Caller-Sig'] = sign(unboundCanonical(caller, callerUrl, ts));
@@ -153,15 +166,21 @@ export async function verifyOriginClaim(req, d) {
153
166
  const unboundSig = header('x-a2a-caller-sig');
154
167
  const boundSig = header('x-a2a-caller-sig2');
155
168
  const audience = header('x-a2a-caller-aud');
156
- if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || (unboundSig === '' && boundSig === ''))
157
- return { ok: false, reason: 'absent' };
169
+ const hasBodySig = req.headers['x-a2a-caller-sig3'] !== undefined;
170
+ const bodySig = header('x-a2a-caller-sig3');
171
+ const digest = header('x-a2a-body-sha256');
172
+ // Presence, even malformed/empty, opts into v3. It must never become absent/legacy or fall back to Sig2/v1.
173
+ if (hasBodySig && (!bodySig || !/^[A-Za-z0-9_-]{43}$/.test(digest) || d.rawBody === undefined || originBodySha256(d.rawBody) !== digest))
174
+ return { ok: false, reason: 'body-mismatch' };
175
+ if (caller === '' || callerUrl === '' || !Number.isFinite(ts) || ts <= 0 || (unboundSig === '' && boundSig === '' && !hasBodySig))
176
+ return { ok: false, reason: hasBodySig ? 'bad-sig' : 'absent' };
158
177
  if (!originClaimCallerShape.test(caller))
159
178
  return { ok: false, reason: 'bad-caller-shape' };
160
179
  if (Math.abs(d.now - ts) > ORIGIN_CLAIM_SKEW_MS)
161
180
  return { ok: false, reason: 'bad-ts' };
162
- const hasBound = boundSig !== '';
181
+ const hasBound = boundSig !== '' || hasBodySig;
163
182
  const audienceOk = hasBound && requestAudiences(req, d.audiences, d.loopbackArrival ?? true).has(originOf(audience));
164
- const fallback = d.unboundOpen && unboundSig !== '';
183
+ const fallback = !hasBodySig && d.unboundOpen && unboundSig !== '';
165
184
  if (hasBound && !audienceOk && !fallback)
166
185
  return { ok: false, reason: 'wrong-audience' };
167
186
  if (!hasBound && !d.unboundOpen)
@@ -179,6 +198,10 @@ export async function verifyOriginClaim(req, d) {
179
198
  // signature is accepted for migration compatibility but remains unbound.
180
199
  let bound = false;
181
200
  const signedBy = (card) => {
201
+ if (hasBodySig) {
202
+ bound = audienceOk && verifies(card, bodyBoundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? ''), digest), bodySig);
203
+ return bound;
204
+ }
182
205
  bound = audienceOk && verifies(card, boundCanonical(caller, callerUrl, ts, audience, req.method ?? '', pathOf(req.url ?? '')), boundSig);
183
206
  return bound || (fallback && verifies(card, unboundCanonical(caller, callerUrl, ts), unboundSig));
184
207
  };
@@ -187,7 +210,7 @@ export async function verifyOriginClaim(req, d) {
187
210
  return;
188
211
  d.onUnbound();
189
212
  if (hasBound)
190
- d.onBoundMiss?.();
213
+ d.onBoundMiss?.({ caller, audience: originOf(audience) });
191
214
  };
192
215
  // gh#133: a CLI identity deposited HERE, decided from the deposit table
193
216
  // before any fetch (the CLI may reach its home through a proxy the host
@@ -8,8 +8,39 @@
8
8
  * else; a missing entry is appended in the canonical short-id form.
9
9
  * @module @nelsonlongxiang/dsh-open-a2a-net/owner-settings
10
10
  */
11
+ /** A label shaped like a routable address (`dsh/66abc2cf`, `zone/canvas/x`, `name/<16 hex>`) would pass for another
12
+ * session in the directory and in `a2a-net teams <query>`; labels are display-only, so that shape is refused. */
13
+ const ADDRESS_SHAPED = /^[A-Za-z0-9._-]+\/\S+$/;
14
+ /** Shared validation for boot config and owner POST; labels are plain text, never handles or authority. */
15
+ export function parseSeatNames(value) {
16
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length > 1024)
17
+ return undefined;
18
+ const entries = Object.entries(value);
19
+ if (entries.some(([id, name]) => !/^session-[a-zA-Z0-9-]{8,128}$/.test(id) || typeof name !== 'string' || name.trim() === '' || [...name.trim()].length > 64 || /[\p{Cc}\p{Cf}\u2028\u2029\ufffd]/u.test(name) || !/[^\p{Z}\p{C}\p{M}]/u.test(name) || ADDRESS_SHAPED.test(name.trim())))
20
+ return undefined;
21
+ return Object.fromEntries(entries.map(([id, name]) => [id, name.trim()]));
22
+ }
23
+ /** Boot config degrades entry-by-entry; owner POST remains all-or-nothing. Never echo label contents in warnings. */
24
+ export function loadSeatNames(value, warn) {
25
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
26
+ warn('seatNames: ignored invalid map');
27
+ return {};
28
+ }
29
+ const kept = {};
30
+ let count = 0;
31
+ for (const [id, name] of Object.entries(value)) {
32
+ const entry = parseSeatNames({ [id]: name });
33
+ if (entry === undefined || count >= 1024) {
34
+ warn('seatNames: ignored invalid or over-cap entry');
35
+ continue;
36
+ }
37
+ Object.assign(kept, entry);
38
+ count += 1;
39
+ }
40
+ return kept;
41
+ }
11
42
  /** The exact keys this module may touch, in wire order. */
12
- const KEYS = ['teamJoinAllowlist', 'teamScopeInfo', 'announce'];
43
+ const KEYS = ['teamJoinAllowlist', 'teamScopeInfo', 'announce', 'seatNames'];
13
44
  export function applyOwnerSettings(patchYaml, changes) {
14
45
  const lines = patchYaml.split('\n');
15
46
  // Find the a2a entry: `- id: a2a` / `- id: 'a2a'` variants.
@@ -34,6 +65,8 @@ export function applyOwnerSettings(patchYaml, changes) {
34
65
  writes.push(` teamScopeInfo: ${changes.teamScopeInfo ? 'true' : 'false'}`);
35
66
  if (changes.announce !== undefined)
36
67
  writes.push(` announce: ${changes.announce ? 'true' : 'false'}`);
68
+ if (changes.seatNames !== undefined)
69
+ writes.push(` seatNames: ${JSON.stringify(changes.seatNames)}`);
37
70
  if (writes.length === 0)
38
71
  return { yaml: patchYaml, restartNeeded };
39
72
  if (entryStart === -1) {
@@ -81,6 +114,10 @@ export function applyOwnerSettings(patchYaml, changes) {
81
114
  if (write !== undefined) {
82
115
  lines[j] = write;
83
116
  found = true;
117
+ // A map may have been authored as block YAML. Replace its nested rows too, not just the heading.
118
+ if (key === 'seatNames')
119
+ while (j + 1 < lines.length && (/^ {5,}\S/.test(lines[j + 1] ?? '') || /^\s*$/.test(lines[j + 1] ?? '')))
120
+ lines.splice(j + 1, 1);
84
121
  }
85
122
  break;
86
123
  }
@@ -109,7 +109,7 @@ export function peerDirectoryRows(card, peer, includeCanvas) {
109
109
  if (includeCanvas)
110
110
  for (const entry of advertisedCanvasTeams(card)) {
111
111
  const declared = rosterOf.get(entry.team);
112
- rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
112
+ rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', kind: 'canvas', owner: peer, memberCount: entry.members.length, origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
113
113
  }
114
114
  return rows;
115
115
  }
@@ -0,0 +1,15 @@
1
+ export function peerLinkDisplay(peerUrl, d) {
2
+ const { card } = d;
3
+ if (card === undefined)
4
+ return { link: 'stranger', verified: false };
5
+ const origin = (url) => { try {
6
+ return new URL(url).origin;
7
+ }
8
+ catch {
9
+ return '';
10
+ } };
11
+ const peerOrigin = origin(peerUrl), ownOrigin = origin(d.ownUrl);
12
+ const bilateral = peerOrigin !== '' && ownOrigin !== '' && d.seeds.some(url => origin(url) === peerOrigin) && (card.peers ?? []).some(url => origin(url) === ownOrigin);
13
+ const exactMember = d.refs.some(ref => !d.ownHandles.has(ref) && (card.sessionTeams ?? []).some(row => row.team === ref) && !d.cards.some(other => other.publicKey !== card.publicKey && (other.sessionTeams ?? []).some(row => row.team === ref)));
14
+ return { link: bilateral || exactMember ? 'linked' : 'stranger', verified: true };
15
+ }
@@ -42,6 +42,10 @@ model tools.
42
42
  405 with an empty body. The fix is a host upgrade, not a retry.
43
43
  - `a2a-net revoke` deregisters early.
44
44
 
45
+ ## Team display names
46
+
47
+ `a2a-net teams <query>` searches the owner-set seat label as well as the route handle; `--json` exposes the effective display name. Local rows prefer `seatName`, then the current title (`name`), then the legacy label. Only the human workbench owner panel/config can set `seatNames`; the CLI has no naming command.
48
+
45
49
  ## Tasks: ledger and host observability
46
50
 
47
51
  `a2a-net tasks --json` returns the existing `pending`, `dead`, and `archived` arrays plus `settlement` and `conservation` copied unchanged from `/__dsh_a2a/state`. These blocks include the lifetime `settlement.refusalsTotal`, refusal ring and mode, and the host conservation verdict/counters/duplicate ids. Older hosts omit a block they do not expose; the CLI does not manufacture zero counters or a passing verdict. Plain `tasks` appends a conservation line (`conserved`, `delta`) and a settlement line (`mode`, `refusalsTotal`, `offChain`) when the respective block exists.
@@ -117,9 +121,9 @@ Files do not go through session messages: use the file channel below, never JPFI
117
121
  - Reusing an id for a different message, or switching `--sync` on or off,
118
122
  is refused (exit 1): drop `--task` to send it as a new task.
119
123
  - `report` takes `--task` the same way.
120
- - Home-host forwarding is opt-in: `forwardDirect: true` forwards one hop only after a local target miss. H must not be isolated, the CLI must be online and sign a bound envelope, the inbound request must have no Guard, and the target must pass H’s sender scope.
124
+ - From 0.6.38 home-host forwarding defaults on (`forwardDirect: true`); explicit `false` restores local-only delivery. It forwards one hop only after a local target miss. H must not be isolated, the CLI must be online and sign a bound envelope, the inbound request must have no Guard, and the target must pass H’s sender scope.
121
125
  - Both operators establish links: at A, put the CLI and B receiving member handles in an operator canvas; at B, link the CLI handle and pin its home to A. A remote canvas target requires A to directly link every advertised receiving member; a peer-org claim alone is insufficient for the return receipt.
122
- - B signs its auto-receipt as the member that did the work. Its destination prefers an online CLI household on that host (an owner-controlled fact); otherwise it returns only to the pinned home, never another directory advertiser. In the normal single-home setup it settles A’s single forwarded outbound row on-chain under `enforce` and enters the CLI mailbox (`a2a-net inbox`). `--sync` returns B’s final reply. A 15s transport-budget expiry is `-32005`; reconcile by task id rather than resend blindly.
126
+ - B signs its auto-receipt as the member that did the work. Its destination prefers an online CLI household on that host (an owner-controlled fact); otherwise it returns only to the pinned home, never another directory advertiser. In the normal single-home setup it settles A’s single forwarded outbound row on-chain under `enforce` and enters the CLI mailbox (`a2a-net inbox`). `--sync` returns B’s final reply. The transport still has a 15s synchronous patience window, not a full model-turn budget. For forwarded or long work, prefer `a2a-net route <team> <message>` (async by default) then `a2a-net inbox --wait --renew`. A `--sync` transport expiry is `-32005`; the target still auto-receipts the original task after finishing. Reconcile by task id/inbox instead of treating a new direct pong as its receipt or resending blindly.
123
127
  - With forwarding off or any prerequisite missing, the original "No live DSH session node accepts team" error includes the reason it was not forwarded. Receiving hosts never forward the forwarded request again.
124
128
  - Admission: a deposited handle is a node fact of its home host (N3); that fact alone grants no remote scope or admission. Operator canvas membership and B’s home pin are still required. Global self-description is never a link.
125
129
  - `a2a-net report <message…>` is a one-way report to the host's process team.
@@ -165,9 +169,13 @@ Files do not go through session messages: use the file channel below, never JPFI
165
169
 
166
170
  ## Reading the host
167
171
 
172
+ If configured enforcement is refused because old forwarded canvas debts lack receiver facts, the plugin stays online in observe. `tasks --json` preserves `settlement.mode` and `settlement.enforceRefused:{reason,blockers}`; tasks/status text also shows the refusal. This boot-time audit remains after reconciliation; a later owner-controlled reapplication may enable enforce once blockers are cleared. Configuration on disk is unchanged.
173
+
168
174
  `whoami`, `teams [query]`, `status`, `tasks`, `probe` read the home host's
169
175
  state face (key-gated on a keyed host). `--json` prints raw rows for
170
- `teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
176
+ `whoami`, `teams`, `tasks`, `probe`, `route`, `report`, `inbox` and `skills`.
177
+
178
+ `teams` includes local canvas and peerOrg rows with qualified route name, owner URL and memberCount; same names on different hosts stay separate. `whoami --json` is pure JSON and includes only canvases naming this CLI identity, local and remote. An unreadable optional state face reports canvasError/UNKNOWN, never falsely empty membership. Canvas failures retain the original refusal and add visible-directory not-found, owner prerequisites (including each missing direct receiver link), or conflicting owner candidates. Owner URLs do not require direct dialing or a home override and grant no admission.
171
179
 
172
180
  ## Local commands
173
181
 
@@ -24,7 +24,7 @@
24
24
  The `a2a-net` CLI (0.6.27+) is the supported exception: a DEPOSITED handle
25
25
  is routable on its home host and receives into its mailbox
26
26
  ([cli.md](cli.md)).
27
- - **"No live DSH session node accepts team" may name a forwarding prerequisite.** Direct delivery remains local by default. `forwardDirect: true` permits one hop only for an online, bound-signed local CLI, on a non-isolated home, with no inbound Guard and a target inside sender scope. A must link B’s receiving members; B must link the CLI and pin A as its home. Remote canvas org alone cannot authorize the receipt return path. Read the appended "not forwarded" reason; do not treat a missing link, expired deposit or already-forwarded request as a transport outage. The target host never forwards again.
27
+ - **"No live DSH session node accepts team" may name a forwarding prerequisite.** From 0.6.38 `forwardDirect` defaults on; explicit `false` disables it. It permits one hop only for an online, bound-signed local CLI, on a non-isolated home, with no inbound Guard and a target inside sender scope. A must link B’s receiving members; B must link the CLI and pin A as its home. Remote canvas org alone cannot authorize the receipt return path. Read the appended "not forwarded" reason; do not treat a missing link, expired deposit or already-forwarded request as a transport outage. The target host never forwards again.
28
28
  - A sync route to a remote peer rides a 15s transport budget (a target on
29
29
  this host gets 180s) — both are shorter than many model turns. For
30
30
  anything that runs long, prefer `async: true` and the receipt contract.
@@ -21,6 +21,10 @@ presenting the operator's gesture on the operator's behalf is exactly the
21
21
  | Version | `/__dsh_a2a/state` `version`, or the workbench header badge | the same version on every host you will link (0.6.28+) |
22
22
  | Clock | `date` on each host | NTP-synced: card signatures carry `expiresAt`, and the OriginClaim envelope allows ±5 min |
23
23
 
24
+ ## Human seat names
25
+
26
+ The owner can open the workbench header ⚙, select a local session under “会话席位名”, and explicitly save a stable display name (up to 64 characters; a name shaped like a route address such as `dsh/66abc2cf` is refused so it cannot pass for another session). Empty input clears the label. The `seatNames` map is stored in the home patch layer and hot-applies; cards and the board prefer it over the session title, and `a2a_whoami` / `a2a-net teams <name>` expose it. It never changes the route handle, join state or admission. Agents must ask the owner rather than self-name: A2A tool arguments containing `seatName`/`seatNames` are refused; no naming model tool exists. The owner control API keeps its existing apiKey/loopback trust model, not a sandbox against local shell or config-file writers.
27
+
24
28
  ## 1. One host: sessions talking to each other
25
29
 
26
30
  **Owner:**
@@ -111,14 +115,11 @@ Do section 1 on both hosts first. Then the **owner**, on BOTH hosts:
111
115
 
112
116
  ## 3. Beyond direct peers (A cannot reach C)
113
117
 
114
- Host-to-host forwarding is **not shipped yet**. Today a delivery reaches only
115
- the host it is sent to:
116
- - `/a2a/direct` never relays.
117
- - A session's `a2a_route` dials the host that publishes the team.
118
- - #57 Plan A (host forwarding) is designed. Its prerequisite #60 hop budget
119
- has landed at ingress.
118
+ Home-host one-hop forwarding shipped in 0.6.34 and defaults **on from 0.6.38**. Explicit `forwardDirect: false` restores local-only CLI delivery. It only follows a local miss for a bound-signed, online CLI deposited on that home, inside the unchanged sender scope. A must directly link B's receiving member (all advertised potential receivers for a canvas), and B must link the CLI and pin A as home. The home must not be isolated and any inbound Guard prevents a second forward. The outbound hop budget remains DEFAULT-1. No links are auto-created or relaxed.
119
+
120
+ Long forwarded work should use async `route` plus `inbox --wait --renew`, not expect a 15s `--sync` transport window to cover an entire model turn. A late final still produces the original task's receipt, and the forwarded row keeps selected-peer receiver facts for enforced settlement.
120
121
 
121
- Until forwarding ships, pick one:
122
+ Other connectivity options (not authority bypasses):
122
123
 
123
124
  1. **Make it a peer pair.** Open a tunnel or port-forward so A reaches C,
124
125
  then do section 2 for A↔C. This is the simplest and keeps receipts
@@ -135,8 +136,7 @@ Until forwarding ships, pick one:
135
136
  ## 4. Callers outside a session
136
137
 
137
138
  Scripts and terminals use the `a2a-net` CLI: deposit on ONE home host, read
138
- a mailbox, no wake. The same rule applies: the CLI reaches only teams on its
139
- home host. See [cli.md](cli.md).
139
+ a mailbox, no wake. The home can forward eligible requests one hop under section 3; an online deposit alone grants no remote scope. See [cli.md](cli.md).
140
140
 
141
141
  ## 5. Symptom → step
142
142