@nelsonlongxiang/dsh-open-a2a-net 0.6.24 → 0.6.26

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 (38) hide show
  1. package/README.md +15 -11
  2. package/README_zh.md +3 -2
  3. package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
  4. package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +300 -0
  7. package/lib/card.js +17 -0
  8. package/lib/cli-deposit.js +141 -0
  9. package/lib/exposure-store.js +82 -0
  10. package/lib/index.js +904 -57
  11. package/lib/owner-settings.js +100 -0
  12. package/lib/peer-store.js +65 -9
  13. package/lib/task-ledger.js +44 -3
  14. package/lib/types/cli-deposit.d.ts +53 -0
  15. package/lib/types/exposure-store.d.ts +45 -0
  16. package/lib/types/index.d.ts +5 -0
  17. package/lib/types/owner-settings.d.ts +19 -0
  18. package/lib/types/peer-store.d.ts +22 -3
  19. package/lib/types/task-ledger.d.ts +18 -0
  20. package/lib/types/types.d.ts +10 -0
  21. package/nexus-stage/src/canvas-ops.ts +2 -2
  22. package/nexus-stage/src/federation.ts +6 -0
  23. package/nexus-stage/src/main.ts +97 -4
  24. package/nexus-stage/src/planning-menus.ts +16 -4
  25. package/nexus-stage/src/planning-types.ts +3 -0
  26. package/nexus-stage/src/planning-view.ts +208 -18
  27. package/nexus-stage/src/planning.css +4 -0
  28. package/nexus-stage/src/wb-chat.ts +670 -0
  29. package/nexus-stage/src/wb-directory.ts +222 -0
  30. package/nexus-stage/src/wb-ledger.ts +266 -0
  31. package/nexus-stage/src/wb-rail.ts +147 -0
  32. package/nexus-stage/src/workbench.css +190 -84
  33. package/nexus-stage/src/workbench.ts +385 -721
  34. package/nexus-stage/src/world.ts +5 -1
  35. package/package.json +18 -22
  36. package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
  37. package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
  38. package/lib/types/client.d.ts +0 -73
package/lib/index.js CHANGED
@@ -33,11 +33,14 @@ import { decodeSessionLog, extractTranscript, TRANSCRIPT_DEFAULT_LIMIT, TRANSCRI
33
33
  import { LayoutStore } from "./layout-store.js";
34
34
  import { fileURLToPath } from 'node:url';
35
35
  import { JoinedSessions } from "./joined-store.js";
36
+ import { ExposureStore } from "./exposure-store.js";
37
+ import { applyOwnerSettings } from "./owner-settings.js";
38
+ import { CliDepositStore } from "./cli-deposit.js";
36
39
  import { TeamMembershipStore } from "./team-store.js";
37
40
  import { PeerStore } from "./peer-store.js";
38
41
  import { SelfReferralFilter } from "./self-suppress.js";
39
42
  import { resolveStageMount } from "./stage-mount.js";
40
- import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
43
+ import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, ARCHIVE_CAP, archivedEndedBy, briefOf } from "./task-ledger.js";
41
44
  import { formatReceipt, parseReceipt } from "./receipt.js";
42
45
  import { runReceiptLadder } from "./receipt-ladder.js";
43
46
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
@@ -125,6 +128,15 @@ export const Config = s.object({
125
128
  * the legacy whole-store basis (fleet migration window).
126
129
  */
127
130
  referralTeamResolution: s.boolean().default(false),
131
+ /**
132
+ * #19 D2 (ruling final, owner-level knob): how much of a session team's
133
+ * activity EXCERPT the card face publishes in sessionTeams descriptions.
134
+ * 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
135
+ * to a shape-only prefix; 'off' omits the description field entirely.
136
+ * Titles and team handles stay in every tier — the knob is the excerpt,
137
+ * not the listing.
138
+ */
139
+ exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
128
140
  /**
129
141
  * gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
130
142
  * the window (counted as legacyInbound); closing it rejects them like
@@ -545,10 +557,27 @@ export function apply(ctx, config) {
545
557
  // host restarts silently dropped every join. The store remounts the node
546
558
  // whenever the Agent comes back, until the user leaves.
547
559
  const joinedSessions = new JoinedSessions(join(home, 'a2a', 'joined.json'));
560
+ // #19 D3/D4: per-session exposure tiers — 'linked' floor (the absence
561
+ // default, so existing members transfer automatically) with a voluntary
562
+ // 'fleet' opt-up. D4 (成文): group visibility = strictest member tier;
563
+ // v1 ships no group-level knob by design.
564
+ const exposureStore = new ExposureStore(join(home, 'a2a', 'exposure.json'));
565
+ // gh#34 owner three-key panel hot-apply state: allowlist and teamScopeInfo
566
+ // are read at call time, so the panel's write applies WITHOUT a restart;
567
+ // announce is boot-time publishing (restart pending flag for the UI).
568
+ let ownerAllowlist = [...(config.teamJoinAllowlist ?? [])];
569
+ // Truthiness matches the ORIGINAL read site (`if (config.teamScopeInfo)`):
570
+ // literal compositions without the schema default carry undefined, which
571
+ // reads as the OPEN plane — `!== false` would silently scope them.
572
+ let ownerTeamScopeInfo = config.teamScopeInfo === true;
573
+ let ownerAnnounceRestartPending = false;
548
574
  // S2: node-declared team memberships — the roster half of the
549
575
  // team-roster iteration. Joins ride the allowlist-gated tools; the store
550
576
  // is the durable declaration the card publishes and the state face reads.
551
577
  const teamMemberships = new TeamMembershipStore(join(home, 'a2a', 'teams.json'));
578
+ // gh#45 CLI 户口: deposited CLI identities (one key, multiple registrations,
579
+ // ≤4h online by owner ruling — expiry auto-deregisters).
580
+ const cliDeposits = new CliDepositStore(join(home, 'a2a', 'cli-deposits.json'));
552
581
  // S4 audit ring, hoisted beside the stores: the boot-settlement prune runs
553
582
  // before the tools section initializes, and it records archive-prune events.
554
583
  const teamAuditRing = [];
@@ -1033,6 +1062,19 @@ export function apply(ctx, config) {
1033
1062
  }
1034
1063
  return 'no activity yet';
1035
1064
  }
1065
+ /**
1066
+ * #19 D2: apply the owner-level excerpt knob to a session-team
1067
+ * description at the card face. 'full' keeps the verbatim excerpt;
1068
+ * 'masked' reduces to a shape-only prefix (16 chars — enough to
1069
+ * recognize, not enough to read); 'off' omits the field entirely.
1070
+ */
1071
+ function exposureDescription(description) {
1072
+ if (config.exposureExcerpt === 'off')
1073
+ return undefined;
1074
+ if (config.exposureExcerpt === 'masked')
1075
+ return `${description.slice(0, 16)}${description.length > 16 ? '…' : ''}`;
1076
+ return description;
1077
+ }
1036
1078
  /**
1037
1079
  * Join one live top-level session: its team dispatches direct routes and
1038
1080
  * rides the announced card from this moment.
@@ -1388,6 +1430,9 @@ export function apply(ctx, config) {
1388
1430
  joined: sessionNodes.has(String(agent.id)),
1389
1431
  live: true,
1390
1432
  teams: [...teamMemberships.teamsOf(String(agent.id))],
1433
+ // #19 D3: the session's exposure tier (linked floor /
1434
+ // fleet opt-up) — readable on the state face.
1435
+ exposure: exposureStore.tierOf(String(agent.id)),
1391
1436
  ...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
1392
1437
  ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
1393
1438
  }));
@@ -1448,7 +1493,24 @@ export function apply(ctx, config) {
1448
1493
  })),
1449
1494
  },
1450
1495
  host: lanIp === '' ? {} : { lanIp },
1451
- peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
1496
+ // gh#20 W-C (host rail): each peer row also carries its display
1497
+ // name and version from the warm card (absent until a fetch),
1498
+ // and the last-contact timestamp for the health lamp. W-A's
1499
+ // link/verified ride along for the badge.
1500
+ peers: peerStore.list().map(url => {
1501
+ const card = cardCache.get(url)?.card;
1502
+ const lastContact = peerStore.lastContact(url);
1503
+ const lastContactOk = peerStore.lastContactOk(url);
1504
+ return {
1505
+ url,
1506
+ score: peerStore.score(url),
1507
+ ...(card?.name !== undefined && card.name !== '' ? { name: card.name } : {}),
1508
+ ...(typeof card?.version === 'string' && card.version !== '' ? { version: card.version } : {}),
1509
+ ...(lastContact !== undefined ? { lastContactAt: lastContact } : {}),
1510
+ ...(lastContactOk !== undefined ? { lastContactOk } : {}),
1511
+ ...peerLinkState(url),
1512
+ };
1513
+ }),
1452
1514
  remote: (refreshRemoteRows(), (remoteRowsCache?.rows ?? []).map(row => {
1453
1515
  const canvasTeams = canvasStore.teamsOf(row.team);
1454
1516
  return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
@@ -1482,7 +1544,31 @@ export function apply(ctx, config) {
1482
1544
  tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
1483
1545
  tasksDead: taskLedger.list()
1484
1546
  .filter(task => task.status === 'dead')
1485
- .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}) })),
1547
+ .map(task => ({ taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, deadAt: task.deadAt ?? task.startedAt, reason: task.reason ?? 'unspecified', ...(task.brief !== undefined ? { brief: task.brief } : {}), ...(task.direction !== undefined ? { direction: task.direction } : {}) })),
1548
+ // gh#31: per-task outcomes for rows that LEFT the ledger — the
1549
+ // chat drawer used to guess (settled, abandoned or cancelled).
1550
+ // Bounded to the archive (≤64), newest first, with the
1551
+ // controlled-vocabulary verdict each row settled by.
1552
+ // gh#31 follow-up: archive() is already newest first (settles
1553
+ // prepend), so the projection keeps that order — the earlier
1554
+ // .reverse() served it oldest first. Each row also says HOW it
1555
+ // ended (endedBy: abandon and cancel both record outcome
1556
+ // 'abandoned', and so does a peer's own 'abandoned' receipt),
1557
+ // whether a receipt came after the caller ended it (late), and
1558
+ // its direction when known.
1559
+ archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
1560
+ taskId: row.taskId,
1561
+ team: row.team,
1562
+ peer: row.peer,
1563
+ startedAt: row.startedAt,
1564
+ resolvedAt: row.resolvedAt,
1565
+ outcome: row.outcome ?? 'unclassified',
1566
+ endedBy: archivedEndedBy(row),
1567
+ ...(row.direction !== undefined ? { direction: row.direction } : {}),
1568
+ ...(row.lateReceiptAt !== undefined ? { late: true } : {}),
1569
+ ...(row.brief !== undefined ? { brief: row.brief } : {}),
1570
+ ...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
1571
+ })),
1486
1572
  archivedCount: taskLedger.archive().length,
1487
1573
  // Ledger density face: the settled archive split by verdict, so
1488
1574
  // the workbench can say WHICH way those rows settled instead of
@@ -1503,6 +1589,19 @@ export function apply(ctx, config) {
1503
1589
  // Read-only; no fingerprints cross here (they are /a2a/query's
1504
1590
  // only auth material).
1505
1591
  idempotency: idempotencyStore.stats(),
1592
+ // gh#41 fleet planning: each tracked peer's published canvas
1593
+ // org (full-card canvasOrg) from the warm card cache — the
1594
+ // board renders REMOTE team frames from this. Absent until a
1595
+ // linked fetch warmed the card.
1596
+ peerOrg: peerStore.list().map(url => {
1597
+ const card = cardCache.get(url)?.card;
1598
+ const org = card?.canvasOrg;
1599
+ return Array.isArray(org)
1600
+ ? { url, org: org.filter((e) => e !== null && typeof e === 'object')
1601
+ .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') : [] }))
1602
+ .filter(e => e.team !== '') }
1603
+ : { url, org: [] };
1604
+ }),
1506
1605
  // gh#9+gh#10 M1: envelope-absent deliveries during the
1507
1606
  // OriginClaim window — the operator watches this drain to
1508
1607
  // zero before closing the window.
@@ -1620,6 +1719,157 @@ export function apply(ctx, config) {
1620
1719
  });
1621
1720
  }),
1622
1721
  }), 'a2a: session-node leave route');
1722
+ // gh#45 CLI 户口 control face (loopback/key gated like every write):
1723
+ // publish (deposit a signed CLI card, ≤4h by ruling), revoke, list,
1724
+ // and mailbox readout — the CLI polls this while alive.
1725
+ ctx.effect(() => webServer.register({
1726
+ kind: 'exact',
1727
+ path: '/__dsh_a2a/cli-deposit',
1728
+ handler: controlRoute((req, res) => {
1729
+ readJsonBody(req, res, (body) => {
1730
+ const action = body.action;
1731
+ let payload;
1732
+ if (action === 'publish') {
1733
+ const card = typeof body.card === 'string' ? body.card : '';
1734
+ const out = cliDeposits.deposit(card);
1735
+ payload = JSON.stringify(out.ok
1736
+ ? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
1737
+ : { ok: false, error: out.error });
1738
+ }
1739
+ else if (action === 'revoke') {
1740
+ payload = JSON.stringify({ ok: cliDeposits.revoke(typeof body.handle === 'string' ? body.handle : '') });
1741
+ }
1742
+ else if (action === 'inbox') {
1743
+ const handle = typeof body.handle === 'string' ? body.handle : '';
1744
+ payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
1745
+ }
1746
+ else {
1747
+ payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
1748
+ }
1749
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1750
+ res.end(payload);
1751
+ });
1752
+ }),
1753
+ }), 'a2a: cli-deposit route');
1754
+ // gh#41 fleet planning relay: the GUI asks THIS host to regulate a
1755
+ // REMOTE peer's canvas team. The node key never leaves the server —
1756
+ // the relay stamps the OriginClaim envelope and forwards to the
1757
+ // peer's /a2a/canvas-op face; the peer's link gate authorizes.
1758
+ ctx.effect(() => webServer.register({
1759
+ kind: 'exact',
1760
+ path: '/__dsh_a2a/fleet-canvas-op',
1761
+ handler: controlRoute((req, res) => {
1762
+ readJsonBody(req, res, async (body) => {
1763
+ const { peer, op, name, id } = body;
1764
+ const peerUrl = typeof peer === 'string' ? peer : '';
1765
+ const opV = typeof op === 'string' ? op : '';
1766
+ const nameV = typeof name === 'string' ? name : '';
1767
+ const idV = typeof id === 'string' ? id : '';
1768
+ const send = (payload) => {
1769
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1770
+ res.end(payload);
1771
+ };
1772
+ if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
1773
+ send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
1774
+ return;
1775
+ }
1776
+ if (!peerStore.list().includes(peerUrl)) {
1777
+ send(JSON.stringify({ ok: false, error: `peer ${peerUrl} is not tracked — regulate tracked peers only` }));
1778
+ return;
1779
+ }
1780
+ try {
1781
+ const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
1782
+ const reply = await globalThis.fetch(target, {
1783
+ method: 'POST',
1784
+ headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
1785
+ body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV } : {}) }),
1786
+ signal: AbortSignal.timeout(15_000),
1787
+ });
1788
+ const raw = await reply.text();
1789
+ try {
1790
+ const out = JSON.parse(raw);
1791
+ send(JSON.stringify(out));
1792
+ }
1793
+ catch {
1794
+ // A non-JSON reply is a peer WITHOUT the canvas-op face (a
1795
+ // pre-gh41 build) or a proxy page — name the shape honestly.
1796
+ send(JSON.stringify({ ok: false, error: `peer ${peerUrl} answered HTTP ${String(reply.status)} without JSON — it likely runs a build without the fleet canvas-op face (pre gh#41); upgrade it, or regulate its teams on that host directly` }));
1797
+ }
1798
+ }
1799
+ catch (error) {
1800
+ send(JSON.stringify({ ok: false, error: `fleet op failed: ${error instanceof Error ? error.message : String(error)}` }));
1801
+ }
1802
+ });
1803
+ }),
1804
+ }), 'a2a: fleet-canvas-op relay route');
1805
+ // gh#34 owner three-key panel: the remote build-out unlock knobs,
1806
+ // surfaced as a control face instead of "翻配置文件". GET reads the
1807
+ // effective values; POST writes the HOME patch layer (the
1808
+ // authoritative surface) AND hot-applies the two call-time keys
1809
+ // (teamJoinAllowlist / teamScopeInfo); announce is boot-time
1810
+ // publishing, so its change lands on the next host restart and the
1811
+ // reply says so. Same key gate as every write face.
1812
+ ctx.effect(() => webServer.register({
1813
+ kind: 'exact',
1814
+ path: '/__dsh_a2a/owner-settings',
1815
+ handler: controlRoute((req, res) => {
1816
+ if (req.method === 'GET') {
1817
+ const payload = JSON.stringify({
1818
+ ok: true,
1819
+ teamJoinAllowlist: [...ownerAllowlist],
1820
+ teamScopeInfo: ownerTeamScopeInfo,
1821
+ announce: config.announce === true,
1822
+ announceRestartPending: ownerAnnounceRestartPending,
1823
+ });
1824
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1825
+ res.end(payload);
1826
+ return;
1827
+ }
1828
+ readJsonBody(req, res, (rawBody) => {
1829
+ const body = rawBody;
1830
+ const changes = {};
1831
+ if (Array.isArray(body.teamJoinAllowlist)) {
1832
+ const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
1833
+ changes.teamJoinAllowlist = [...new Set(list)];
1834
+ }
1835
+ if (typeof body.teamScopeInfo === 'boolean')
1836
+ changes.teamScopeInfo = body.teamScopeInfo;
1837
+ if (typeof body.announce === 'boolean')
1838
+ changes.announce = body.announce;
1839
+ if (Object.keys(changes).length === 0) {
1840
+ const payload = JSON.stringify({ ok: false, error: 'no recognized keys: teamJoinAllowlist (string[]), teamScopeInfo (bool), announce (bool)' });
1841
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1842
+ res.end(payload);
1843
+ return;
1844
+ }
1845
+ try {
1846
+ const patchPath = join(home, 'cordis.patch.yml');
1847
+ const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
1848
+ const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
1849
+ mkdirSync(home, { recursive: true });
1850
+ writeFileSync(patchPath, yaml, 'utf8');
1851
+ // Hot-apply the two call-time keys.
1852
+ if (changes.teamJoinAllowlist !== undefined)
1853
+ ownerAllowlist = [...changes.teamJoinAllowlist];
1854
+ if (changes.teamScopeInfo !== undefined)
1855
+ ownerTeamScopeInfo = changes.teamScopeInfo;
1856
+ if (changes.announce !== undefined && changes.announce !== (config.announce === true))
1857
+ ownerAnnounceRestartPending = true;
1858
+ if (changes.announce !== undefined && changes.announce === (config.announce === true))
1859
+ ownerAnnounceRestartPending = false;
1860
+ logger.info(`a2a: owner settings updated (${Object.keys(changes).join(', ')}${restartNeeded ? ' — restart pending for announce' : ''})`);
1861
+ const payload = JSON.stringify({ ok: true, restartNeeded, keys: Object.keys(changes) });
1862
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1863
+ res.end(payload);
1864
+ }
1865
+ catch (error) {
1866
+ const payload = JSON.stringify({ ok: false, error: `patch-layer write failed: ${error instanceof Error ? error.message : String(error)}` });
1867
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1868
+ res.end(payload);
1869
+ }
1870
+ });
1871
+ }),
1872
+ }), 'a2a: owner-settings route');
1623
1873
  // Groups control: create/remove named groups and assign sessions to
1624
1874
  // them (an empty name unassigns). Same guard as join/leave — the
1625
1875
  // group taxonomy enumerates session ids, so it stays behind the key.
@@ -1904,7 +2154,17 @@ export function apply(ctx, config) {
1904
2154
  void named;
1905
2155
  };
1906
2156
  const claim = await verifyOriginClaim(req);
1907
- const named = claim.ok;
2157
+ // W3 (#18 half-side, 0.6.25): a VERIFIED envelope proves the
2158
+ // caller holds a real node key — it does not prove a relationship
2159
+ // with THIS host. The full card (sessionTeams/rosters =
2160
+ // collaboration facts) additionally requires a LINK (same-host
2161
+ // membership, canvas remote ref, or the caller's own card seeding
2162
+ // us — see callerLinkedToThisHost). An unlinked-but-verified
2163
+ // stranger gets the MINIMAL discovery card, #18 X1's shape.
2164
+ // Interaction with the OriginClaim narrowing: the caller is a
2165
+ // bare zone; linkage judges that zone face against our canvas
2166
+ // remote refs, joined handles, and the caller card's peers.
2167
+ const named = claim.ok && callerLinkedToThisHost(claim.caller, claim.ok ? claim.callerUrl : '', claim.ok ? claim.card : undefined);
1908
2168
  // gh#7 G1 (review-seat R2 ruling): the card serves two faces.
1909
2169
  // A NAMED caller — ?caller_session= or the X-A2A-Caller header in
1910
2170
  // the zone or zone/id8 shape — gets the FULL card. An anonymous
@@ -1943,11 +2203,23 @@ export function apply(ctx, config) {
1943
2203
  return false;
1944
2204
  }
1945
2205
  };
2206
+ // #19 D2: the excerpt knob rides the served-fresh rows at the
2207
+ // card face — live nodes (nodeMetadataOf description) and cold
2208
+ // rows alike. 'off' omits the field (undefined drops out).
2209
+ const exposureRow = (team, meta) => {
2210
+ const { description, ...rest } = meta;
2211
+ const excerpt = exposureDescription(description ?? '');
2212
+ return { team, ...rest, ...(excerpt !== undefined ? { description: excerpt } : {}) };
2213
+ };
1946
2214
  const sessionTeams = [
1947
- ...[...sessionNodes.values()].filter(isJoinedNode).map(agent => ({ team: sessionTeamOf(agent), ...nodeMetadataOf(agent) })),
2215
+ ...[...sessionNodes.values()].filter(isJoinedNode).map(agent => exposureRow(sessionTeamOf(agent), nodeMetadataOf(agent))),
1948
2216
  ...joinedSessions.list()
1949
2217
  .filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
1950
- .map(id => ({ team: `${config.team}/${id8(id)}`, name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
2218
+ .map(id => exposureRow(`${config.team}/${id8(id)}`, { name: `${session}-${id8(id)}`, description: 'cold — not loaded; routing here wakes the session' })),
2219
+ // gh#45: deposited CLI identities publish as directory rows —
2220
+ // the fleet sees them as member cards (draggable = the operator
2221
+ // link). Their "zone" is the agent-name; expiry drops the row.
2222
+ ...cliDeposits.cardRows().map(row => exposureRow(row.team, { name: row.name, description: row.description })),
1951
2223
  ];
1952
2224
  const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
1953
2225
  const body = JSON.stringify({
@@ -1955,6 +2227,15 @@ export function apply(ctx, config) {
1955
2227
  peers: peerStore.list(),
1956
2228
  ...(named && sessionTeams.length > 0 ? { sessionTeams } : {}),
1957
2229
  ...(named && memberships.length > 0 ? { teamMemberships: memberships } : {}),
2230
+ // gh#41 fleet planning (owner ruling: any peer composes and
2231
+ // regulates): the host's own canvas org rides the FULL card as a
2232
+ // served-fresh unsigned field — linked peers render each other's
2233
+ // team structure on the planning board; the minimal face omits
2234
+ // it exactly like sessionTeams. Members are CANONICALIZED to
2235
+ // zone/id8 handles (local session ids translate to the node's
2236
+ // session-team handle) — one form on the wire, so readers never
2237
+ // mix id/handle shapes (owner: 数据对不上).
2238
+ ...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name).map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id) })) } : {}),
1958
2239
  ...(named && lanIp !== '' ? { lanIp } : {}),
1959
2240
  ...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
1960
2241
  description: `A2A node exposing team ${config.team}${named ? '' : ' (minimal card — OriginClaim envelope required for the full card: X-A2A-Caller/-Url/-Ts/-Sig signed with your card key)'}`,
@@ -2291,7 +2572,7 @@ export function apply(ctx, config) {
2291
2572
  // <id>" answer correlates with the caller's own route result.
2292
2573
  const from = caller === '' ? 'an unknown node' : caller;
2293
2574
  const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
2294
- const text = `[A2A direct] ${taskPrefix}from "${from}" (routed to ${team}) sent:
2575
+ const text = `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:
2295
2576
 
2296
2577
  ${message}`;
2297
2578
  try {
@@ -2392,7 +2673,7 @@ ${message}`;
2392
2673
  function bridgeEnvelope(team, caller, message, taskId) {
2393
2674
  const from = caller === '' ? 'an unknown node' : caller;
2394
2675
  const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
2395
- return `[A2A direct] ${taskPrefix}from "${from}" (routed to ${team}) sent:\n\n${message}`;
2676
+ return `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:\n\n${message}`;
2396
2677
  }
2397
2678
  /** The projection for a round that outlived the reply window: unlike the
2398
2679
  * steer deadline's text, this must NOT promise a receipt — native-teams
@@ -2499,8 +2780,12 @@ ${message}`;
2499
2780
  const verdict = verifyCard(JSON.parse(text), Date.now());
2500
2781
  if (verdict.ok)
2501
2782
  card = verdict.card;
2783
+ else
2784
+ req.__claimDbg = { stage: 'verify', reason: verdict.reason, head: text.slice(0, 100) };
2502
2785
  }
2503
2786
  catch (error) {
2787
+ ;
2788
+ req.__claimDbg = { stage: 'fetch', error: error instanceof Error ? error.message : String(error) };
2504
2789
  card = undefined;
2505
2790
  }
2506
2791
  }
@@ -2528,16 +2813,218 @@ ${message}`;
2528
2813
  if (card.team !== caller) {
2529
2814
  return { ok: false, reason: 'not-derivable' };
2530
2815
  }
2531
- return { ok: true, caller };
2816
+ return { ok: true, caller, callerUrl, card };
2817
+ }
2818
+ /**
2819
+ * W3 (#18 half-side): is a VERIFIED caller LINKED to this host? Key
2820
+ * possession proves the caller is a real host; it says nothing about a
2821
+ * relationship with US. The full card carries collaboration facts
2822
+ * (sessionTeams/rosters), so it additionally requires a link:
2823
+ * 1. same-host membership — the caller's card URL origin equals OUR
2824
+ * card URL origin (the loopback self-fetch face: true self is an
2825
+ * ORIGIN equality; zone-name equality carries no identity in a
2826
+ * single-zone fleet), or one of our session pseudo-handles;
2827
+ * 2. a canvas remote ref — the EXACT handle only (#19 D1, ruling
2828
+ * final: the zone-sibling variant was a blanket fallback that any
2829
+ * one ref opened to a whole zone's hosts in single-zone fleets; the
2830
+ * operator names THAT node, that node links);
2831
+ * 3. BILATERAL seeding (review M1 fix): our config.peers contains the
2832
+ * caller's URL **and** the caller's card (fetched fresh during
2833
+ * verification) carries our origin among its peers — both
2834
+ * operators made the gesture, each on their own side. The single-
2835
+ * sided card-claims variant was a hole the reviewer's adversarial
2836
+ * probe walked through (one config.peers line → full card with
2837
+ * zero commitment from us): peers is an unsigned served-fresh
2838
+ * field, exactly the self-description trust class N3/V5 abolished.
2839
+ * Our operator's config anchors; the card shadow corroborates.
2840
+ */
2841
+ function callerLinkedToThisHost(caller, callerUrl, callerCard) {
2842
+ // Basis 1, same-host membership (review second-round M-fix): "self" is
2843
+ // an ORIGIN equality, not a zone-name equality. `caller === config.team`
2844
+ // granted unconditionally in a single-zone fleet (the deployment
2845
+ // reality: every host named 'dsh') — any verified caller naming the
2846
+ // shared zone string was "us". True self is the loopback self-fetch
2847
+ // face: the caller's card URL origin equals OUR card URL origin.
2848
+ try {
2849
+ if (callerUrl !== '' && new URL(callerUrl).origin === new URL(ownCardUrl).origin)
2850
+ return true;
2851
+ }
2852
+ catch { /* malformed callerUrl — falls through to the record bases */ }
2853
+ for (const id of joinedSessions.list()) {
2854
+ if (`${config.team}/${id8(id)}` === caller)
2855
+ return true;
2856
+ }
2857
+ // #19 D1 (ruling final): canvas remote refs admit by EXACT HANDLE only.
2858
+ // The zone-sibling variant (`refZone === zone`) was a blanket fallback —
2859
+ // in a single-zone fleet any one ref linked the whole zone face, the
2860
+ // exact trust shape the exposure ruling removed. Exact handle in, the
2861
+ // operator named THAT node.
2862
+ for (const name of canvasStore.list()) {
2863
+ for (const ref of canvasStore.remoteMembersOf(name)) {
2864
+ if (ref === caller)
2865
+ return true;
2866
+ }
2867
+ }
2868
+ // Basis 3, BILATERAL (review M1 fix): OUR config.peers seeds a URL
2869
+ // whose origin matches the caller's card URL, AND the caller's card
2870
+ // reciprocates by carrying OUR origin among its peers — both
2871
+ // operators made the gesture, each on their own side. The single-
2872
+ // sided card-claims variant was a hole the reviewer's adversarial
2873
+ // probe walked through (one config.peers line on the CALLER → full
2874
+ // card with zero commitment from us): peers is an unsigned
2875
+ // served-fresh field, exactly the self-description trust class
2876
+ // N3/V5 abolished. Our operator's own config anchors; the card
2877
+ // shadow only corroborates.
2878
+ if (callerCard !== undefined && ownCardUrl !== '') {
2879
+ const ourOrigin = new URL(ownCardUrl).origin;
2880
+ const callerOrigin = new URL(callerUrl).origin;
2881
+ const weSeedThem = config.peers.some(peer => {
2882
+ try {
2883
+ return new URL(peer).origin === callerOrigin;
2884
+ }
2885
+ catch {
2886
+ return false;
2887
+ }
2888
+ });
2889
+ const theySeedUs = (callerCard.peers ?? []).some(peer => {
2890
+ try {
2891
+ return new URL(peer).origin === ourOrigin;
2892
+ }
2893
+ catch {
2894
+ return false;
2895
+ }
2896
+ });
2897
+ if (weSeedThem && theySeedUs)
2898
+ return true;
2899
+ }
2900
+ return false;
2532
2901
  }
2533
2902
  /* ── Inbound admission shared by the peer faces (/a2a/direct,
2534
- /a2a/log): caller_session is the protocol contract. Anonymous is
2535
- refused, and admission is decided by THIS host's own records — never
2536
- by the caller's self-description (gh#7 N3, V3/V5): not its
2537
- caller_session spelling, not its own card's team claims, not
2538
- first-contact defaults. Returns the refusal detail, or undefined
2539
- when admissible. */
2540
- function a2aCallerRefusal(caller) {
2903
+ /a2a/log) and — since the boundary-hardening wave (W1) — the LOCAL
2904
+ dispatch candidate: caller_session is the protocol contract.
2905
+ Anonymous is refused, and admission is decided by THIS host's own
2906
+ records — never by the caller's self-description (gh#7 N3, V3/V5).
2907
+ Returns the refusal detail, or undefined when admissible. */
2908
+ /**
2909
+ * W1/W2 shared-team admission core (pure over the host's stores): a
2910
+ * same-zone zone/id8 caller passes when it holds a team this host's
2911
+ * records also hold. The caller's basis is its roster declarations UNION
2912
+ * its canvas memberships (W2②: canvas teammates share a team by being in
2913
+ * the team — the roster is not the only link record); the host's basis is
2914
+ * the routable set (bare zone, joined handles, canvas names) union other
2915
+ * sessions' declarations. Used by both the inbound faces and the local
2916
+ * dispatch candidate so the two paths can never diverge.
2917
+ */
2918
+ function sameZoneSharedTeamRefusal(caller, targetTeam) {
2919
+ const declared = new Set();
2920
+ let callerSessionId;
2921
+ for (const entry of teamMemberships.list()) {
2922
+ if (`${config.team}/${id8(entry.session)}` === caller) {
2923
+ callerSessionId = entry.session;
2924
+ for (const t of entry.teams)
2925
+ declared.add(t);
2926
+ }
2927
+ }
2928
+ // W2②: canvas membership is a link record too — the caller's session
2929
+ // sitting in a canvas team declares that team as much as a roster line.
2930
+ if (callerSessionId === undefined) {
2931
+ for (const id of joinedSessions.list()) {
2932
+ if (`${config.team}/${id8(id)}` === caller) {
2933
+ callerSessionId = id;
2934
+ break;
2935
+ }
2936
+ }
2937
+ }
2938
+ if (callerSessionId !== undefined) {
2939
+ for (const name of canvasStore.teamsOf(callerSessionId))
2940
+ declared.add(canvasTeamOf(name));
2941
+ }
2942
+ const routable = new Set([config.team]);
2943
+ for (const id of joinedSessions.list())
2944
+ routable.add(`${config.team}/${id8(id)}`);
2945
+ for (const name of canvasStore.list())
2946
+ routable.add(canvasTeamOf(name));
2947
+ const otherLocalDeclarations = new Set();
2948
+ for (const entry of teamMemberships.list()) {
2949
+ if (`${config.team}/${id8(entry.session)}` === caller)
2950
+ continue;
2951
+ for (const t of entry.teams)
2952
+ otherLocalDeclarations.add(t);
2953
+ }
2954
+ const shared = [...declared].some(t => routable.has(t) || otherLocalDeclarations.has(t));
2955
+ // F5 (gh#26, attribute-owner ruling): same-host admission needs a
2956
+ // TARGET-level intersection — a canvas member used to reach ANY session
2957
+ // team on this host through a host-level link alone. The target's link
2958
+ // set = its roster declarations ∪ its canvas teams. EXCEPTION (ruling,
2959
+ // 成文): the PROCESS face (target === config.team) is the owner face,
2960
+ // not a member face — any shared team clears it.
2961
+ if (targetTeam !== undefined && targetTeam !== config.team) {
2962
+ let targetLinks;
2963
+ const targetId8 = targetTeam.startsWith(`${config.team}/`) ? targetTeam.slice(config.team.length + 1) : undefined;
2964
+ if (targetId8 !== undefined) {
2965
+ // Only a KNOWN local session earns target-level judgement; an
2966
+ // unresolvable handle (archived/foreign spelling) keeps the
2967
+ // host-level basis — an empty target set must not refuse everyone.
2968
+ // The handle carries the id8 SLICE, so resolve it back to the
2969
+ // session id before asking the stores (canvas teamsOf keys by id).
2970
+ const targetSession = joinedSessions.list().find(sessionId => id8(sessionId) === targetId8)
2971
+ ?? teamMemberships.list().find(entry => id8(entry.session) === targetId8)?.session;
2972
+ if (targetSession !== undefined) {
2973
+ targetLinks = new Set();
2974
+ for (const entry of teamMemberships.list()) {
2975
+ if (entry.session === targetSession)
2976
+ for (const t of entry.teams)
2977
+ targetLinks.add(t);
2978
+ }
2979
+ for (const name of canvasStore.teamsOf(targetSession))
2980
+ targetLinks.add(canvasTeamOf(name));
2981
+ }
2982
+ }
2983
+ else {
2984
+ const canvasName = canvasStore.list().find(name => canvasTeamOf(name) === targetTeam);
2985
+ if (canvasName !== undefined) {
2986
+ targetLinks = new Set([targetTeam]);
2987
+ for (const memberId of canvasStore.membersOf(canvasName)) {
2988
+ for (const entry of teamMemberships.list())
2989
+ if (entry.session === memberId)
2990
+ for (const t of entry.teams)
2991
+ targetLinks.add(t);
2992
+ }
2993
+ }
2994
+ }
2995
+ if (targetLinks !== undefined) {
2996
+ if ([...declared].some(t => targetLinks.has(t)))
2997
+ return undefined;
2998
+ return declared.size === 0
2999
+ ? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
3000
+ : `caller "${caller}" shares no team with target "${targetTeam}" — declared [${[...declared].join(', ')}], target links [${[...targetLinks].join(', ')}] (F5 target-level admission: a host-level link alone does not open every session team).`;
3001
+ }
3002
+ // Unresolvable target (foreign/zone delegation): host-level basis.
3003
+ }
3004
+ if (shared)
3005
+ return undefined;
3006
+ return declared.size === 0
3007
+ ? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
3008
+ : `caller "${caller}" declares teams [${[...declared].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`;
3009
+ }
3010
+ /**
3011
+ * W1: does a caller label fall under the joined-face admission gate? The
3012
+ * zone/id8 shape with THIS zone names a joined session node — the face
3013
+ * the boundary-hardening wave closes. The initiator face (bare node
3014
+ * team) and foreign-zone labels are judged elsewhere (F1b host-face
3015
+ * rules; the foreign branch below) and stay untouched.
3016
+ */
3017
+ const joinedFaceCallerShape = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
3018
+ function joinedFaceAdmissionRefusal(callerSession, targetTeam) {
3019
+ if (callerSession === undefined || callerSession === '')
3020
+ return undefined;
3021
+ if (!joinedFaceCallerShape.test(callerSession))
3022
+ return undefined;
3023
+ if (callerSession.slice(0, callerSession.indexOf('/')) !== config.team)
3024
+ return undefined;
3025
+ return sameZoneSharedTeamRefusal(callerSession, targetTeam);
3026
+ }
3027
+ function a2aCallerRefusal(caller, targetTeam) {
2541
3028
  if (!config.teamScopeRouting)
2542
3029
  return undefined;
2543
3030
  if (caller === '') {
@@ -2551,33 +3038,12 @@ ${message}`;
2551
3038
  if (zoneShaped) {
2552
3039
  const callerZone = caller.slice(0, caller.indexOf('/'));
2553
3040
  if (callerZone === config.team) {
2554
- // Same-zone handle: judged by this host's own roster — the
2555
- // operator-established link record for local nodes.
2556
- const declared = new Set();
2557
- for (const entry of teamMemberships.list()) {
2558
- if (`${config.team}/${id8(entry.session)}` === caller)
2559
- for (const t of entry.teams)
2560
- declared.add(t);
2561
- }
2562
- const routable = new Set([config.team]);
2563
- for (const id of joinedSessions.list())
2564
- routable.add(`${config.team}/${id8(id)}`);
2565
- for (const name of canvasStore.list())
2566
- routable.add(canvasTeamOf(name));
2567
- const otherLocalDeclarations = new Set();
2568
- for (const entry of teamMemberships.list()) {
2569
- if (`${config.team}/${id8(entry.session)}` === caller)
2570
- continue;
2571
- for (const t of entry.teams)
2572
- otherLocalDeclarations.add(t);
2573
- }
2574
- const shared = [...declared].some(t => routable.has(t) || otherLocalDeclarations.has(t));
2575
- if (!shared) {
2576
- return declared.size === 0
2577
- ? `caller "${caller}" declares no team membership on this host's roster — a teamless node has no network (S3 phase-3 admission). Declare a shared team on both ends, or route through a teamed member.`
2578
- : `caller "${caller}" declares teams [${[...declared].join(', ')}] — none is routable on this node (shared-team admission). Declare a shared team on both ends, or route through a teamed member.`;
2579
- }
2580
- return undefined;
3041
+ // Same-zone handle: the W1/W2 shared admission core — roster
3042
+ // declarations UNION canvas memberships against this host's
3043
+ // records (canvas names are link records too; see the helper).
3044
+ // F5: the routed team rides along so admission can require a
3045
+ // TARGET-level intersection.
3046
+ return sameZoneSharedTeamRefusal(caller, targetTeam);
2581
3047
  }
2582
3048
  // Foreign-zone handle (N3/N4): admitted ONLY through this host's
2583
3049
  // operator-established cross-host links — a canvas remote member ref
@@ -2607,7 +3073,14 @@ ${message}`;
2607
3073
  if (String(agent.id) === caller)
2608
3074
  return undefined;
2609
3075
  }
2610
- return `caller_session "${caller}" matches no node fact on this host (joined session id or live node label) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`;
3076
+ // gh#45: a DEPOSITED CLI handle is a published node fact of this host
3077
+ // (the card's sessionTeams carries it; the envelope's zone derivation
3078
+ // already proved the CLI key on the wire face's own verify). The
3079
+ // handle is <agent-name>/<uuid16> — not zone/id8-shaped, hence this
3080
+ // branch, but it is as much a fact as a joined session.
3081
+ if (cliDeposits.get(caller) !== undefined)
3082
+ return undefined;
3083
+ return `caller_session "${caller}" matches no node fact on this host (joined session id, live node label, or deposited CLI handle) — free-form caller labels are self-description and are not admissible (N3). Pass caller_session as <zone>/<id8> with an operator-established link.`;
2611
3084
  }
2612
3085
  /* ── Session-log read faces (shared by the local control route and the
2613
3086
  peer /a2a/log face): both answer from <dshHome>/sessions with
@@ -2672,6 +3145,106 @@ ${message}`;
2672
3145
  return { ok: true, id, messages: entries.map(entry => ({ role: entry.role, text: entry.text, ...(entry.time !== undefined ? { time: entry.time } : {}) })) };
2673
3146
  }
2674
3147
  whenWebServerSettled((webServer) => {
3148
+ // gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
3149
+ // CLI's SIGNED card is served verbatim under its own base URL — an
3150
+ // OriginClaim verification of a CLI envelope fetches exactly here
3151
+ // (callerUrl + /.well-known/agent-card.json). Expired or unknown
3152
+ // handles 404 (auto-deregistered — the identity is dead until
3153
+ // re-publish).
3154
+ ctx.effect(() => webServer.register({
3155
+ kind: 'prefix',
3156
+ path: '/a2a/cli',
3157
+ handler: (req, res) => {
3158
+ const MOUNT = '/a2a/cli';
3159
+ const rest = (req.url ?? '').slice(MOUNT.length);
3160
+ const m = /^\/([^/]+)\/\.well-known\/agent-card\.json$/.exec(rest);
3161
+ if (req.method !== 'GET' || m === null) {
3162
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
3163
+ res.end();
3164
+ return;
3165
+ }
3166
+ const handle = decodeURIComponent(m[1]);
3167
+ const row = cliDeposits.get(handle);
3168
+ if (row === undefined) {
3169
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
3170
+ res.end();
3171
+ return;
3172
+ }
3173
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
3174
+ res.end(row.cardJson);
3175
+ },
3176
+ }), 'a2a: cli deposit card face');
3177
+ // gh#41 fleet planning, peer-write face: a LINKED peer may regulate this
3178
+ // host's canvas teams (create / add-member / remove-member) with a
3179
+ // VERIFIED OriginClaim envelope. The owner's ruling: any peer composes
3180
+ // and regulates the whole fleet — the bilateral/operator link IS the
3181
+ // authorization, the envelope proves the key, and every op lands in the
3182
+ // activity ring + host log for audit. Unlinked or unsigned callers get
3183
+ // a refusal, never a mutation.
3184
+ ctx.effect(() => webServer.register({
3185
+ kind: 'exact',
3186
+ path: '/a2a/canvas-op',
3187
+ handler: async (req, res) => {
3188
+ const send = (status, payload) => {
3189
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
3190
+ res.end(payload);
3191
+ };
3192
+ if (req.method !== 'POST') {
3193
+ send(200, JSON.stringify({ ok: false, error: 'POST required' }));
3194
+ return;
3195
+ }
3196
+ const chunks = [];
3197
+ let size = 0;
3198
+ req.on('data', (chunk) => { size += chunk.length; if (size > 16 * 1024) {
3199
+ chunks.length = 0;
3200
+ return;
3201
+ } chunks.push(chunk); });
3202
+ req.on('end', () => {
3203
+ void (async () => {
3204
+ try {
3205
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
3206
+ const claim = await verifyOriginClaim(req);
3207
+ if (!claim.ok) {
3208
+ logger.warn(`a2a: canvas-op claim failed (${claim.reason}) — caller=${String(req.headers['x-a2a-caller'] ?? '')} url=${String(req.headers['x-a2a-caller-url'] ?? '')} ts=${String(req.headers['x-a2a-caller-ts'] ?? '')}`);
3209
+ send(200, JSON.stringify({ ok: false, error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — canvas regulation requires a signed envelope`, code: -32000 }));
3210
+ return;
3211
+ }
3212
+ if (!callerLinkedToThisHost(claim.caller, claim.callerUrl, claim.card)) {
3213
+ recordActivity('in', 'fleet-canvas', claim.caller, false);
3214
+ send(200, JSON.stringify({ ok: false, error: `caller "${claim.caller}" is not linked to this host — canvas regulation requires the operator link (bilateral seed, canvas ref, or same-host membership)` }));
3215
+ return;
3216
+ }
3217
+ const op = body.op;
3218
+ const name = typeof body.name === 'string' ? body.name : '';
3219
+ const id = typeof body.id === 'string' ? body.id : '';
3220
+ let payload;
3221
+ if (op === 'create') {
3222
+ const stored = canvasStore.create(name);
3223
+ payload = stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, members: canvasStore.membersOf(stored) };
3224
+ }
3225
+ else if (op === 'add-member') {
3226
+ const memberValid = isRemoteMemberRef(id) ? validRemoteMemberRef(id) : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
3227
+ payload = name === '' || !memberValid
3228
+ ? { ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle) are required' }
3229
+ : (canvasStore.create(name), { ok: canvasStore.addMember(name, id), members: canvasStore.membersOf(name) });
3230
+ }
3231
+ else if (op === 'remove-member') {
3232
+ payload = { ok: name !== '' && canvasStore.removeMember(name, id), members: name !== '' ? canvasStore.membersOf(name) : undefined };
3233
+ }
3234
+ else {
3235
+ payload = { ok: false, error: 'op must be create | add-member | remove-member' };
3236
+ }
3237
+ recordActivity('in', 'fleet-canvas', `${claim.caller}:${String(op)}`, payload.ok);
3238
+ logger.info(`a2a: fleet canvas op from ${claim.caller} (${String(op)} ${name}${id !== '' ? ` ${id}` : ''}) -> ${payload.ok ? 'applied' : `refused: ${payload.error ?? '?'}`}`);
3239
+ send(200, JSON.stringify(payload));
3240
+ }
3241
+ catch (error) {
3242
+ send(200, JSON.stringify({ ok: false, error: `canvas-op failed: ${error instanceof Error ? error.message : String(error)}` }));
3243
+ }
3244
+ })();
3245
+ });
3246
+ },
3247
+ }), 'a2a: fleet canvas-op peer route');
2675
3248
  ctx.effect(() => webServer.register({
2676
3249
  kind: 'exact',
2677
3250
  path: '/a2a/direct',
@@ -2775,7 +3348,7 @@ ${message}`;
2775
3348
  // (0.6.15: the rule now lives in a2aCallerRefusal so the
2776
3349
  // read-only /a2a/log face admits callers by exactly the same
2777
3350
  // standard as message delivery.)
2778
- const callerRefusal = a2aCallerRefusal(caller);
3351
+ const callerRefusal = a2aCallerRefusal(caller, team);
2779
3352
  if (callerRefusal !== undefined) {
2780
3353
  recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
2781
3354
  const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
@@ -2831,6 +3404,20 @@ ${message}`;
2831
3404
  // carried it here. Correlation is bookkeeping only — the message
2832
3405
  // steers on exactly as before.
2833
3406
  settleAndAnnounce(message);
3407
+ // gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
3408
+ // CLI handle never resolves a session and never wakes — it lands
3409
+ // in the deposit's mailbox and answers delivered. Placement here
3410
+ // keeps the idempotency claim (above) exactly as for sessions.
3411
+ const cliRow = cliDeposits.get(team);
3412
+ if (cliRow !== undefined) {
3413
+ cliDeposits.mail(team, caller, message);
3414
+ recordActivity('in', team, caller, true);
3415
+ logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
3416
+ const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note: `delivered to the mailbox of ${team} — the CLI polls while online` });
3417
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
3418
+ res.end(payload);
3419
+ return;
3420
+ }
2834
3421
  // Inbound routing is foreground demand: boot prewarm yields for a
2835
3422
  // quiet window around it.
2836
3423
  lastWakeDemandAt = Date.now();
@@ -2858,7 +3445,7 @@ ${message}`;
2858
3445
  // latched, so the post-steer read alone would answer a false
2859
3446
  // consumed:true and strand the message.
2860
3447
  const runningBeforeSteer = probeConsumption(target, taskId);
2861
- steerRelay(target, `[A2A direct] (task ${taskId}) from "${from}" (routed to ${team}) sent:\n\n${message}\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`, from === 'an unknown node' ? undefined : from);
3448
+ steerRelay(target, `[A2A direct] (task ${taskId}) ${fromClause(from)} (routed to ${team}) sent:\n\n${message}\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`, from === 'an unknown node' ? undefined : from);
2862
3449
  recordActivity('in', team, caller, true);
2863
3450
  armReceiptAutosend(target, taskId, receiptTarget, message);
2864
3451
  // v0.5.23 (async-stall): "delivered" only proves the steer call
@@ -3302,7 +3889,16 @@ ${message}`;
3302
3889
  else {
3303
3890
  dispatchLastAt.set(client, now);
3304
3891
  try {
3305
- result = await routeTrunk({ team: target, message, async: true }, undefined, AbortSignal.timeout(20_000), config.team);
3892
+ // gh#36: a quick dispatch rides a LIVE joined session's node
3893
+ // team as its caller face when one exists — receipts then
3894
+ // route back to that session and the owner SEES the answer in
3895
+ // its chat drawer. Bare-zone callers (the old shape) cannot
3896
+ // receive: the receipt ladder failed every lane and the owner
3897
+ // read it as 派发异常没有收信. No live session keeps the
3898
+ // host face (the reply's reconciliation note still applies).
3899
+ const liveJoined = joinedSessions.list().find(id => liveRoots.has(id));
3900
+ const hostFace = liveJoined !== undefined ? `${config.team}/${id8(liveJoined)}` : config.team;
3901
+ result = await routeTrunk({ team: target, message, async: true }, undefined, AbortSignal.timeout(20_000), hostFace);
3306
3902
  }
3307
3903
  catch (error) {
3308
3904
  result = { ok: false, error: `dispatch failed: ${error instanceof Error ? error.message : String(error)}` };
@@ -3536,6 +4132,138 @@ ${message}`;
3536
4132
  }
3537
4133
  const cardCache = new Map();
3538
4134
  const cardCacheInFlight = new Map();
4135
+ /**
4136
+ * The from-line display name (owner ruling, claim 8ea4150f94): a bare
4137
+ * routing handle (`dsh/74555a36`) carries zero semantics for the reader.
4138
+ * Resolve what the handle NAMES, cheaply and read-only:
4139
+ * 1. a local handle — a joined/live session id, its zone/id8 pseudo-team,
4140
+ * or a live node label → nodeMetadataOf(agent).name (the session-title
4141
+ * product, exactly what the directory shows);
4142
+ * 2. a remote bare-zone caller → the warmest cached card whose team
4143
+ * equals the zone (the publisher's own card NAME is the display name);
4144
+ * 3. a remote zone/id8 caller → the cached card whose sessionTeams row
4145
+ * matches the handle → that row's name;
4146
+ * 4. nothing resolves → '' (the from line stays bare — no false names).
4147
+ * Read-only over warm caches: never fetches, so a cold cache degrades to
4148
+ * the bare handle, never to a stall.
4149
+ */
4150
+ function callerDisplayName(caller) {
4151
+ if (caller === '' || caller === 'an unknown node')
4152
+ return '';
4153
+ // Local: joined/live session facts on THIS host.
4154
+ for (const [id, agent] of sessionNodes) {
4155
+ if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
4156
+ return nodeMetadataOf(agent).name;
4157
+ }
4158
+ }
4159
+ for (const [id, agent] of liveRoots) {
4160
+ if (id === caller || sessionLabelOf(agent) === caller || `${config.team}/${id8(id)}` === caller) {
4161
+ return nodeMetadataOf(agent).name;
4162
+ }
4163
+ }
4164
+ // Remote: warm card cache only.
4165
+ const zone = caller.includes('/') ? caller.slice(0, caller.indexOf('/')) : caller;
4166
+ let zoneName = '';
4167
+ for (const entry of cardCache.values()) {
4168
+ const card = entry.card;
4169
+ if (card === undefined)
4170
+ continue;
4171
+ if (card.team === zone && zoneName === '')
4172
+ zoneName = card.name;
4173
+ if (caller.includes('/')) {
4174
+ const row = (card.sessionTeams ?? []).find(item => item.team === caller);
4175
+ if (row !== undefined && row.name !== '')
4176
+ return row.name;
4177
+ }
4178
+ }
4179
+ if (!caller.includes('/') && zoneName !== '')
4180
+ return zoneName;
4181
+ return '';
4182
+ }
4183
+ /** The from clause with the display name in full-width parens when resolved. */
4184
+ function fromClause(from) {
4185
+ const display = callerDisplayName(from);
4186
+ return `from "${from}"${display !== '' ? `(${display})` : ''}`;
4187
+ }
4188
+ /**
4189
+ * gh#20 W-A: THIS host's view of one tracked peer's link state (the
4190
+ * mirror of callerLinkedToThisHost, judged from our side about THEM):
4191
+ * · linked — bilateral seeding (our config.peers carries a URL of the
4192
+ * peer's origin AND the peer's card carries ours), or a canvas remote
4193
+ * ref naming the peer's zone or one of its session teams;
4194
+ * · stranger — no operator-established basis (a verified key is not a
4195
+ * relationship);
4196
+ * · verified — we hold a signature-valid cached card (the badge's
4197
+ * tick/cross: reachable-and-authentic vs unknown/bad).
4198
+ * NOTE (gh#20 review observation, ruling-accepted): the UI's third
4199
+ * colour — fleet blue — has no v1 data source: a peer's #19 exposure
4200
+ * tier is that host's state-face private fact and is NOT published on
4201
+ * its card, so we cannot classify a peer as fleet-visible. The badge
4202
+ * renders two colours (linked green / stranger gray) and the blue slot
4203
+ * lights up the day the card face carries a tier field (or an
4204
+ * a2a_exposure state query is added to fetch it).
4205
+ * Pure over warm caches and config; never fetches.
4206
+ */
4207
+ function peerLinkState(peerUrl) {
4208
+ const cached = cardCache.get(peerUrl)?.card;
4209
+ const verified = cached !== undefined;
4210
+ const zone = cached?.team ?? '';
4211
+ const handles = new Set();
4212
+ if (zone !== '')
4213
+ handles.add(zone);
4214
+ for (const row of cached?.sessionTeams ?? [])
4215
+ handles.add(row.team);
4216
+ let linked = false;
4217
+ // Basis 1: bilateral seeds (origin match both ways).
4218
+ if (cached !== undefined && ownCardUrl !== '') {
4219
+ try {
4220
+ const peerOrigin = new URL(peerUrl).origin;
4221
+ const ourOrigin = new URL(ownCardUrl).origin;
4222
+ const weSeedThem = config.peers.some(peer => {
4223
+ try {
4224
+ return new URL(peer).origin === peerOrigin;
4225
+ }
4226
+ catch {
4227
+ return false;
4228
+ }
4229
+ });
4230
+ const theySeedUs = (cached.peers ?? []).some(peer => {
4231
+ try {
4232
+ return new URL(peer).origin === ourOrigin;
4233
+ }
4234
+ catch {
4235
+ return false;
4236
+ }
4237
+ });
4238
+ if (weSeedThem && theySeedUs)
4239
+ linked = true;
4240
+ }
4241
+ catch { /* malformed peer URL — falls through to the canvas basis */ }
4242
+ }
4243
+ // Basis 2: a canvas remote ref names the peer's zone or one of its
4244
+ // session teams. Exact-handle match first (the admission basis); the
4245
+ // ref's ZONE prefix against card.team accepts the common compose form
4246
+ // (the operator added the peer's session-team handle; we see the
4247
+ // minimal card without sessionTeams until linked — display-level
4248
+ // zone matching is exact-host here: the ref names a handle of THAT
4249
+ // zone, and the tracked peer IS that zone's card).
4250
+ if (!linked && handles.size > 0 && zone !== '') {
4251
+ outer: for (const name of canvasStore.list()) {
4252
+ for (const ref of canvasStore.remoteMembersOf(name)) {
4253
+ if (handles.has(ref)) {
4254
+ linked = true;
4255
+ break outer;
4256
+ }
4257
+ const refZone = ref.includes('/') ? ref.slice(0, ref.indexOf('/')) : ref;
4258
+ if (refZone === zone) {
4259
+ linked = true;
4260
+ break outer;
4261
+ }
4262
+ }
4263
+ }
4264
+ }
4265
+ return { link: linked ? 'linked' : 'stranger', verified };
4266
+ }
3539
4267
  function cachedCardFetch(url) {
3540
4268
  const hit = cardCache.get(url);
3541
4269
  const now = Date.now();
@@ -3624,8 +4352,8 @@ ${message}`;
3624
4352
  // nothing at join time: a pattern pre-authorizing every future name
3625
4353
  // under it would let a model mint teams nobody created. Pattern entries
3626
4354
  // remain readable (whoami shows them) but carry no mint rights.
3627
- const teamJoinExactAllowed = (team) => (config.teamJoinAllowlist ?? []).includes(team);
3628
- const teamJoinPatternMatches = (team) => (config.teamJoinAllowlist ?? []).some(pattern => {
4355
+ const teamJoinExactAllowed = (team) => ownerAllowlist.includes(team);
4356
+ const teamJoinPatternMatches = (team) => ownerAllowlist.some(pattern => {
3629
4357
  if (pattern === '*')
3630
4358
  return true;
3631
4359
  if (pattern.endsWith('*')) {
@@ -3707,7 +4435,7 @@ ${message}`;
3707
4435
  .slice(-3)
3708
4436
  .reverse()
3709
4437
  .map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
3710
- const patterns = [...config.teamJoinAllowlist];
4438
+ const patterns = [...ownerAllowlist];
3711
4439
  return {
3712
4440
  ok: true,
3713
4441
  face: 'session',
@@ -3877,6 +4605,51 @@ ${message}`;
3877
4605
  return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
3878
4606
  },
3879
4607
  }));
4608
+ ctx.tools.register(defineTool({
4609
+ name: 'a2a_exposure',
4610
+ description: '#19 exposure tiers: set THIS session\'s network visibility. \'linked\' (the default '
4611
+ + 'floor) — visible to linked callers per the card-face link gate; \'fleet\' — a voluntary '
4612
+ + 'opt-UP making the session\'s team visible to any fleet peer. Members may raise and lower '
4613
+ + 'their own tier freely; nothing exists below linked (no private tier in v1), and group '
4614
+ + 'visibility always derives from the strictest member (no group knobs). Requires the '
4615
+ + 'session to be joined to the network.',
4616
+ parameters: {
4617
+ tier: { type: 'string', enum: ['linked', 'fleet'], required: true, description: 'The exposure tier to set for this session.' },
4618
+ },
4619
+ output: {
4620
+ schema: {
4621
+ type: 'object',
4622
+ additionalProperties: false,
4623
+ properties: {
4624
+ ok: { type: 'boolean', required: true },
4625
+ id: { type: 'string' },
4626
+ tier: { type: 'string' },
4627
+ error: { type: 'string', description: 'Refusal reason when ok is false (not joined, or no agent context).' },
4628
+ },
4629
+ },
4630
+ render: (_args, value) => [{
4631
+ type: 'text',
4632
+ text: value.ok === true
4633
+ ? `Session ${String(value.id)} exposure tier set to ${String(value.tier)}`
4634
+ : `Exposure refused: ${String(value.error)}`,
4635
+ }],
4636
+ },
4637
+ presentCall: () => ({ card: 'generic', title: 'A2A exposure tier', kind: 'other', rawInput: null }),
4638
+ execute: async (args, exec) => {
4639
+ const agent = exec.agent;
4640
+ if (agent === undefined) {
4641
+ return { ok: false, error: 'no agent context — the exposure tier is a per-session fact; call from a session' };
4642
+ }
4643
+ const id = String(agent.id);
4644
+ if (!joinedSessions.has(id)) {
4645
+ return { ok: false, error: 'session is not joined to the A2A network — join first (the sidebar control or the host join route)' };
4646
+ }
4647
+ const tier = args.tier === 'fleet' ? 'fleet' : 'linked';
4648
+ exposureStore.setTier(id, tier);
4649
+ logger.info(`a2a: ${id8(id)} exposure tier -> ${tier}`);
4650
+ return { ok: true, id, tier };
4651
+ },
4652
+ }));
3880
4653
  ctx.tools.register(defineTool({
3881
4654
  name: 'a2a_report',
3882
4655
  description: 'Report a task or status to this host\'s A2A network — the ONE ingress a session WITHOUT network '
@@ -3996,7 +4769,7 @@ ${message}`;
3996
4769
  + 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
3997
4770
  + 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
3998
4771
  + 'list as local rows; routing to one resolves the first live member or wakes the first cold one. '
3999
- + 'While teamScopeInfo is on (default), peer rows serve only teams joined by this host\'s nodes — [this host] rows always show and the note field explains the scope. '
4772
+ + 'While teamScopeInfo is on (default), the listing follows YOUR collaboration circle: remote rows serve only teams joined by this host\'s nodes, and [this host] session rows show only your own row, your canvas teams, and sessions sharing a team with you — an exposed node id invites mis-routing, so the directory does not advertise strangers. The owner\'s GUI panel keeps the full plane; teamScopeInfo: false opens it for tools too. '
4000
4773
  + 'Call this before a2a_route to pick a target team.',
4001
4774
  parameters: {
4002
4775
  query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
@@ -4028,7 +4801,7 @@ ${message}`;
4028
4801
  },
4029
4802
  },
4030
4803
  },
4031
- note: { type: 'string', description: 'Present while teamScopeInfo scoping is on: the joined-team scope and how many remote rows it withheld ([this host] rows always show).' },
4804
+ note: { type: 'string', description: 'Present while teamScopeInfo scoping is on: the joined-team scope plus how many rows outside YOUR collaboration circle were withheld (remote/local split; the process team, your own row, and your canvas teams always show).' },
4032
4805
  },
4033
4806
  },
4034
4807
  render: (args, value) => [{
@@ -4049,12 +4822,46 @@ ${message}`;
4049
4822
  // are host-internal facts and always survive. The note keeps the
4050
4823
  // model from reading a scoped listing as an empty fleet.
4051
4824
  let note;
4052
- if (config.teamScopeInfo) {
4825
+ if (ownerTeamScopeInfo) {
4053
4826
  const joined = joinedTeamScope();
4054
4827
  const remoteBefore = teams.filter(row => row.local !== true).length;
4055
4828
  teams = teams.filter(row => row.local === true || joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name)));
4056
- const hidden = remoteBefore - teams.filter(row => row.local !== true).length;
4057
- note = `teamScopeInfo on: ${String(joined.size)} joined team${joined.size === 1 ? '' : 's'} on this host — ${String(hidden)} remote row${hidden === 1 ? '' : 's'} outside the joined scope withheld ([this host] rows always show). To widen the scope: the owner adds the EXACT team name to teamJoinAllowlist (patterns carry no mint rights, gh#11) and a session declares membership with a2a_team_join, or the owner sets teamScopeInfo: false for the open plane`;
4829
+ const hiddenRemote = remoteBefore - teams.filter(row => row.local !== true).length;
4830
+ // gh#39 (owner ruling: the listing ADVERTISES targets — an exposed
4831
+ // node id invites a session with no collaboration relationship to
4832
+ // route at it): [this host] session rows follow the CALLER's
4833
+ // collaboration circle. The host's own GUI/panel keeps the full
4834
+ // plane (the owner's eyes); the model-tool face is scoped.
4835
+ let hiddenLocal = 0;
4836
+ const callerAgent = exec.agent;
4837
+ if (callerAgent !== undefined) {
4838
+ const callerId = String(callerAgent.id);
4839
+ const ownHandle = sessionTeamOf(callerAgent);
4840
+ const myCanvas = new Set(canvasStore.teamsOf(callerId));
4841
+ const myRoster = new Set(teamMemberships.teamsOf(callerId));
4842
+ const before = teams.length;
4843
+ teams = teams.filter(row => {
4844
+ if (row.local !== true)
4845
+ return true;
4846
+ if (row.team === config.team || row.team === ownHandle)
4847
+ return true;
4848
+ if (row.team.startsWith(`${config.team}/canvas/`))
4849
+ return myCanvas.has(row.name);
4850
+ // Another session's team row: visible only when we share a
4851
+ // canvas or a declared team with that session.
4852
+ const targetId8 = row.team.slice(config.team.length + 1);
4853
+ const target = [...sessionNodes.values()].find(agent => id8(String(agent.id)) === targetId8);
4854
+ if (target === undefined)
4855
+ return true; // unresolvable: keep (host fact)
4856
+ const targetId = String(target.id);
4857
+ const shares = [...canvasStore.teamsOf(targetId)].some(name => myCanvas.has(name))
4858
+ || [...teamMemberships.teamsOf(targetId)].some(name => myRoster.has(name));
4859
+ return shares;
4860
+ });
4861
+ hiddenLocal = before - teams.length;
4862
+ }
4863
+ const hidden = hiddenRemote + hiddenLocal;
4864
+ note = `teamScopeInfo on: ${String(joined.size)} joined team${joined.size === 1 ? '' : 's'} on this host — ${String(hidden)} row${hidden === 1 ? '' : 's'} outside your collaboration circle withheld (${String(hiddenRemote)} remote, ${String(hiddenLocal)} local [this host] session rows; the process team, your own row, and your canvas teams always show). To widen the scope: a human composes the shared canvas team (the board drag), the owner adds the EXACT team name to teamJoinAllowlist (patterns carry no mint rights, gh#11), or the owner sets teamScopeInfo: false for the open plane`;
4058
4865
  }
4059
4866
  if (query === '')
4060
4867
  return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
@@ -4279,7 +5086,25 @@ ${message}`;
4279
5086
  },
4280
5087
  timeoutMs: config.routeTimeoutMs,
4281
5088
  presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
4282
- execute: async (args, exec) => routeTrunk(args, exec.agent, exec.signal),
5089
+ execute: async (args, exec) => {
5090
+ // Owner ruling (tool-layer interception): the PROCESS face (bare
5091
+ // zone === config.team) keeps its host-level EXEMPTION on the network
5092
+ // admission layer — receipts, quick dispatch and host-side routing
5093
+ // still target it internally — but a MODEL TOOL call must not address
5094
+ // it: the machine's own mailbox is not a member face, and letting a
5095
+ // session route there would turn the host entrance into a group-free
5096
+ // hop. Host-face entry (agent-less control routes, receipt ladders)
5097
+ // never passes through this tool and stays untouched.
5098
+ const requestedTeam = String(args.team ?? '').trim();
5099
+ if (exec.agent !== undefined && requestedTeam === config.team) {
5100
+ return {
5101
+ ok: false,
5102
+ error: `the process face "${config.team}" is the host's own mailbox — model tools must target a member face. Route to a session team handle ("${config.team}/<id8>" from a2a_teams) or a canvas team; receipts and quick dispatch reach the host face on their own paths.`,
5103
+ code: -32000,
5104
+ };
5105
+ }
5106
+ return routeTrunk(args, exec.agent, exec.signal);
5107
+ },
4283
5108
  }));
4284
5109
  /**
4285
5110
  * The routing trunk (0.6.9): the one failover sequence behind both the
@@ -4452,11 +5277,33 @@ ${message}`;
4452
5277
  */
4453
5278
  async function dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode = false, taskIdFromCaller, callbackAddress) {
4454
5279
  const webServer = ctx.get('webServer');
5280
+ // W1 (boundary hardening): the local in-process candidate is an
5281
+ // admission seam like any other — a JOINED session (zone/id8 caller
5282
+ // face) must clear the same shared-team admission /a2a/direct applies,
5283
+ // or a teamless local node could route to any session team by riding
5284
+ // the cheapest path. The initiator face (bare node team) and
5285
+ // foreign-zone labels are NOT gated here (F1b host-face rules, R-C
5286
+ // pending) — only the joined face closes.
5287
+ const joinedRefusal = joinedFaceAdmissionRefusal(callerSession, team);
5288
+ if (joinedRefusal !== undefined) {
5289
+ recordActivity('out', team, callerSession ?? 'local', false);
5290
+ return { ok: false, error: joinedRefusal, code: -32000 };
5291
+ }
4455
5292
  const canvasName = parseCanvasTeamName(team);
4456
5293
  let teamIsLocal = team === config.team
4457
5294
  || resolveAgentForTeam(team) !== undefined
4458
5295
  || joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
4459
5296
  || (canvasName !== undefined && (canvasLiveAgent(canvasName) !== undefined || canvasColdMemberId(canvasName) !== undefined));
5297
+ // gh#45 CLI mailbox: a delivery targeting a DEPOSITED CLI handle is
5298
+ // local by definition — it lands in the deposit's mailbox (the CLI
5299
+ // polls while alive; receipts and messages never wake anything).
5300
+ const cliRow = cliDeposits.get(team);
5301
+ if (cliRow !== undefined) {
5302
+ cliDeposits.mail(team, callerSession ?? 'unknown', message);
5303
+ recordActivity('in', team, callerSession ?? 'peer', true);
5304
+ logger.info(`a2a: cli mailbox delivery for ${team} (from ${callerSession ?? 'unknown'})`);
5305
+ return { ok: true, team, reply: `delivered to the mailbox of ${team} — the CLI polls while online`, task_id: taskIdFromCaller ?? '', context_id: contextId ?? '', task_status: 'TASK_STATE_COMPLETED' };
5306
+ }
4460
5307
  // The native-teams claim probe is an await: pay it only when the four
4461
5308
  // cheap synchronous locality checks missed (a registry team is a local
4462
5309
  // candidate even though no session node backs it).
@@ -4543,7 +5390,7 @@ ${message}`;
4543
5390
  ? callbackAddress
4544
5391
  : routable ? callerSession : undefined;
4545
5392
  const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
4546
- steerRelay(target, `[A2A direct] (task ${taskId}) from "${callerSession ?? session}" (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam);
5393
+ steerRelay(target, `[A2A direct] (task ${taskId}) ${fromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam);
4547
5394
  armReceiptAutosend(target, taskId, callerTeam, message);
4548
5395
  endRoute(flight);
4549
5396
  recordActivity('out', team, 'local', true);