@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.27

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.
package/lib/index.js CHANGED
@@ -34,11 +34,13 @@ import { LayoutStore } from "./layout-store.js";
34
34
  import { fileURLToPath } from 'node:url';
35
35
  import { JoinedSessions } from "./joined-store.js";
36
36
  import { ExposureStore } from "./exposure-store.js";
37
+ import { applyOwnerSettings } from "./owner-settings.js";
38
+ import { CliDepositStore } from "./cli-deposit.js";
37
39
  import { TeamMembershipStore } from "./team-store.js";
38
40
  import { PeerStore } from "./peer-store.js";
39
41
  import { SelfReferralFilter } from "./self-suppress.js";
40
42
  import { resolveStageMount } from "./stage-mount.js";
41
- 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";
42
44
  import { formatReceipt, parseReceipt } from "./receipt.js";
43
45
  import { runReceiptLadder } from "./receipt-ladder.js";
44
46
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
@@ -560,10 +562,22 @@ export function apply(ctx, config) {
560
562
  // 'fleet' opt-up. D4 (成文): group visibility = strictest member tier;
561
563
  // v1 ships no group-level knob by design.
562
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;
563
574
  // S2: node-declared team memberships — the roster half of the
564
575
  // team-roster iteration. Joins ride the allowlist-gated tools; the store
565
576
  // is the durable declaration the card publishes and the state face reads.
566
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'));
567
581
  // S4 audit ring, hoisted beside the stores: the boot-settlement prune runs
568
582
  // before the tools section initializes, and it records archive-prune events.
569
583
  const teamAuditRing = [];
@@ -1479,7 +1493,24 @@ export function apply(ctx, config) {
1479
1493
  })),
1480
1494
  },
1481
1495
  host: lanIp === '' ? {} : { lanIp },
1482
- 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
+ }),
1483
1514
  remote: (refreshRemoteRows(), (remoteRowsCache?.rows ?? []).map(row => {
1484
1515
  const canvasTeams = canvasStore.teamsOf(row.team);
1485
1516
  return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
@@ -1513,7 +1544,31 @@ export function apply(ctx, config) {
1513
1544
  tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
1514
1545
  tasksDead: taskLedger.list()
1515
1546
  .filter(task => task.status === 'dead')
1516
- .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
+ })),
1517
1572
  archivedCount: taskLedger.archive().length,
1518
1573
  // Ledger density face: the settled archive split by verdict, so
1519
1574
  // the workbench can say WHICH way those rows settled instead of
@@ -1534,6 +1589,19 @@ export function apply(ctx, config) {
1534
1589
  // Read-only; no fingerprints cross here (they are /a2a/query's
1535
1590
  // only auth material).
1536
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
+ }),
1537
1605
  // gh#9+gh#10 M1: envelope-absent deliveries during the
1538
1606
  // OriginClaim window — the operator watches this drain to
1539
1607
  // zero before closing the window.
@@ -1651,6 +1719,157 @@ export function apply(ctx, config) {
1651
1719
  });
1652
1720
  }),
1653
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');
1654
1873
  // Groups control: create/remove named groups and assign sessions to
1655
1874
  // them (an empty name unassigns). Same guard as join/leave — the
1656
1875
  // group taxonomy enumerates session ids, so it stays behind the key.
@@ -1997,6 +2216,10 @@ export function apply(ctx, config) {
1997
2216
  ...joinedSessions.list()
1998
2217
  .filter(id => !liveRoots.has(id) && isArchived?.(id) !== true)
1999
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 })),
2000
2223
  ];
2001
2224
  const memberships = teamMemberships.list().flatMap(entry => entry.teams.map(team => ({ node: `${config.team}/${id8(entry.session)}`, team })));
2002
2225
  const body = JSON.stringify({
@@ -2004,6 +2227,15 @@ export function apply(ctx, config) {
2004
2227
  peers: peerStore.list(),
2005
2228
  ...(named && sessionTeams.length > 0 ? { sessionTeams } : {}),
2006
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) })) } : {}),
2007
2239
  ...(named && lanIp !== '' ? { lanIp } : {}),
2008
2240
  ...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
2009
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)'}`,
@@ -2340,7 +2572,7 @@ export function apply(ctx, config) {
2340
2572
  // <id>" answer correlates with the caller's own route result.
2341
2573
  const from = caller === '' ? 'an unknown node' : caller;
2342
2574
  const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
2343
- const text = `[A2A direct] ${taskPrefix}from "${from}" (routed to ${team}) sent:
2575
+ const text = `[A2A direct] ${taskPrefix}${fromClause(from)} (routed to ${team}) sent:
2344
2576
 
2345
2577
  ${message}`;
2346
2578
  try {
@@ -2441,7 +2673,7 @@ ${message}`;
2441
2673
  function bridgeEnvelope(team, caller, message, taskId) {
2442
2674
  const from = caller === '' ? 'an unknown node' : caller;
2443
2675
  const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
2444
- 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}`;
2445
2677
  }
2446
2678
  /** The projection for a round that outlived the reply window: unlike the
2447
2679
  * steer deadline's text, this must NOT promise a receipt — native-teams
@@ -2548,8 +2780,12 @@ ${message}`;
2548
2780
  const verdict = verifyCard(JSON.parse(text), Date.now());
2549
2781
  if (verdict.ok)
2550
2782
  card = verdict.card;
2783
+ else
2784
+ req.__claimDbg = { stage: 'verify', reason: verdict.reason, head: text.slice(0, 100) };
2551
2785
  }
2552
2786
  catch (error) {
2787
+ ;
2788
+ req.__claimDbg = { stage: 'fetch', error: error instanceof Error ? error.message : String(error) };
2553
2789
  card = undefined;
2554
2790
  }
2555
2791
  }
@@ -2679,7 +2915,7 @@ ${message}`;
2679
2915
  * sessions' declarations. Used by both the inbound faces and the local
2680
2916
  * dispatch candidate so the two paths can never diverge.
2681
2917
  */
2682
- function sameZoneSharedTeamRefusal(caller) {
2918
+ function sameZoneSharedTeamRefusal(caller, targetTeam) {
2683
2919
  const declared = new Set();
2684
2920
  let callerSessionId;
2685
2921
  for (const entry of teamMemberships.list()) {
@@ -2716,6 +2952,55 @@ ${message}`;
2716
2952
  otherLocalDeclarations.add(t);
2717
2953
  }
2718
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
+ }
2719
3004
  if (shared)
2720
3005
  return undefined;
2721
3006
  return declared.size === 0
@@ -2730,16 +3015,16 @@ ${message}`;
2730
3015
  * rules; the foreign branch below) and stay untouched.
2731
3016
  */
2732
3017
  const joinedFaceCallerShape = /^[a-z0-9-]+\/[0-9a-f]{8}$/i;
2733
- function joinedFaceAdmissionRefusal(callerSession) {
3018
+ function joinedFaceAdmissionRefusal(callerSession, targetTeam) {
2734
3019
  if (callerSession === undefined || callerSession === '')
2735
3020
  return undefined;
2736
3021
  if (!joinedFaceCallerShape.test(callerSession))
2737
3022
  return undefined;
2738
3023
  if (callerSession.slice(0, callerSession.indexOf('/')) !== config.team)
2739
3024
  return undefined;
2740
- return sameZoneSharedTeamRefusal(callerSession);
3025
+ return sameZoneSharedTeamRefusal(callerSession, targetTeam);
2741
3026
  }
2742
- function a2aCallerRefusal(caller) {
3027
+ function a2aCallerRefusal(caller, targetTeam) {
2743
3028
  if (!config.teamScopeRouting)
2744
3029
  return undefined;
2745
3030
  if (caller === '') {
@@ -2756,7 +3041,9 @@ ${message}`;
2756
3041
  // Same-zone handle: the W1/W2 shared admission core — roster
2757
3042
  // declarations UNION canvas memberships against this host's
2758
3043
  // records (canvas names are link records too; see the helper).
2759
- return sameZoneSharedTeamRefusal(caller);
3044
+ // F5: the routed team rides along so admission can require a
3045
+ // TARGET-level intersection.
3046
+ return sameZoneSharedTeamRefusal(caller, targetTeam);
2760
3047
  }
2761
3048
  // Foreign-zone handle (N3/N4): admitted ONLY through this host's
2762
3049
  // operator-established cross-host links — a canvas remote member ref
@@ -2786,7 +3073,14 @@ ${message}`;
2786
3073
  if (String(agent.id) === caller)
2787
3074
  return undefined;
2788
3075
  }
2789
- 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.`;
2790
3084
  }
2791
3085
  /* ── Session-log read faces (shared by the local control route and the
2792
3086
  peer /a2a/log face): both answer from <dshHome>/sessions with
@@ -2851,6 +3145,106 @@ ${message}`;
2851
3145
  return { ok: true, id, messages: entries.map(entry => ({ role: entry.role, text: entry.text, ...(entry.time !== undefined ? { time: entry.time } : {}) })) };
2852
3146
  }
2853
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');
2854
3248
  ctx.effect(() => webServer.register({
2855
3249
  kind: 'exact',
2856
3250
  path: '/a2a/direct',
@@ -2954,7 +3348,7 @@ ${message}`;
2954
3348
  // (0.6.15: the rule now lives in a2aCallerRefusal so the
2955
3349
  // read-only /a2a/log face admits callers by exactly the same
2956
3350
  // standard as message delivery.)
2957
- const callerRefusal = a2aCallerRefusal(caller);
3351
+ const callerRefusal = a2aCallerRefusal(caller, team);
2958
3352
  if (callerRefusal !== undefined) {
2959
3353
  recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
2960
3354
  const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
@@ -3010,6 +3404,20 @@ ${message}`;
3010
3404
  // carried it here. Correlation is bookkeeping only — the message
3011
3405
  // steers on exactly as before.
3012
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
+ }
3013
3421
  // Inbound routing is foreground demand: boot prewarm yields for a
3014
3422
  // quiet window around it.
3015
3423
  lastWakeDemandAt = Date.now();
@@ -3037,7 +3445,7 @@ ${message}`;
3037
3445
  // latched, so the post-steer read alone would answer a false
3038
3446
  // consumed:true and strand the message.
3039
3447
  const runningBeforeSteer = probeConsumption(target, taskId);
3040
- 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);
3041
3449
  recordActivity('in', team, caller, true);
3042
3450
  armReceiptAutosend(target, taskId, receiptTarget, message);
3043
3451
  // v0.5.23 (async-stall): "delivered" only proves the steer call
@@ -3481,7 +3889,16 @@ ${message}`;
3481
3889
  else {
3482
3890
  dispatchLastAt.set(client, now);
3483
3891
  try {
3484
- 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);
3485
3902
  }
3486
3903
  catch (error) {
3487
3904
  result = { ok: false, error: `dispatch failed: ${error instanceof Error ? error.message : String(error)}` };
@@ -3715,6 +4132,138 @@ ${message}`;
3715
4132
  }
3716
4133
  const cardCache = new Map();
3717
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
+ }
3718
4267
  function cachedCardFetch(url) {
3719
4268
  const hit = cardCache.get(url);
3720
4269
  const now = Date.now();
@@ -3803,8 +4352,8 @@ ${message}`;
3803
4352
  // nothing at join time: a pattern pre-authorizing every future name
3804
4353
  // under it would let a model mint teams nobody created. Pattern entries
3805
4354
  // remain readable (whoami shows them) but carry no mint rights.
3806
- const teamJoinExactAllowed = (team) => (config.teamJoinAllowlist ?? []).includes(team);
3807
- const teamJoinPatternMatches = (team) => (config.teamJoinAllowlist ?? []).some(pattern => {
4355
+ const teamJoinExactAllowed = (team) => ownerAllowlist.includes(team);
4356
+ const teamJoinPatternMatches = (team) => ownerAllowlist.some(pattern => {
3808
4357
  if (pattern === '*')
3809
4358
  return true;
3810
4359
  if (pattern.endsWith('*')) {
@@ -3886,7 +4435,7 @@ ${message}`;
3886
4435
  .slice(-3)
3887
4436
  .reverse()
3888
4437
  .map(entry => ({ at: entry.at, action: entry.action, team: entry.team, reason: entry.reason ?? '' }));
3889
- const patterns = [...config.teamJoinAllowlist];
4438
+ const patterns = [...ownerAllowlist];
3890
4439
  return {
3891
4440
  ok: true,
3892
4441
  face: 'session',
@@ -4220,7 +4769,7 @@ ${message}`;
4220
4769
  + 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
4221
4770
  + 'discovers one extra referral hop within the call. Canvas teams (<team>/canvas/<name>, user-composed multi-member groups) '
4222
4771
  + 'list as local rows; routing to one resolves the first live member or wakes the first cold one. '
4223
- + '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. '
4224
4773
  + 'Call this before a2a_route to pick a target team.',
4225
4774
  parameters: {
4226
4775
  query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
@@ -4252,7 +4801,7 @@ ${message}`;
4252
4801
  },
4253
4802
  },
4254
4803
  },
4255
- 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).' },
4256
4805
  },
4257
4806
  },
4258
4807
  render: (args, value) => [{
@@ -4273,12 +4822,46 @@ ${message}`;
4273
4822
  // are host-internal facts and always survive. The note keeps the
4274
4823
  // model from reading a scoped listing as an empty fleet.
4275
4824
  let note;
4276
- if (config.teamScopeInfo) {
4825
+ if (ownerTeamScopeInfo) {
4277
4826
  const joined = joinedTeamScope();
4278
4827
  const remoteBefore = teams.filter(row => row.local !== true).length;
4279
4828
  teams = teams.filter(row => row.local === true || joined.has(row.team) || (row.teams ?? []).some(name => joined.has(name)));
4280
- const hidden = remoteBefore - teams.filter(row => row.local !== true).length;
4281
- 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`;
4282
4865
  }
4283
4866
  if (query === '')
4284
4867
  return { ok: true, query: '', teams, ...(note !== undefined ? { note } : {}) };
@@ -4503,7 +5086,25 @@ ${message}`;
4503
5086
  },
4504
5087
  timeoutMs: config.routeTimeoutMs,
4505
5088
  presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
4506
- 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
+ },
4507
5108
  }));
4508
5109
  /**
4509
5110
  * The routing trunk (0.6.9): the one failover sequence behind both the
@@ -4683,7 +5284,7 @@ ${message}`;
4683
5284
  // the cheapest path. The initiator face (bare node team) and
4684
5285
  // foreign-zone labels are NOT gated here (F1b host-face rules, R-C
4685
5286
  // pending) — only the joined face closes.
4686
- const joinedRefusal = joinedFaceAdmissionRefusal(callerSession);
5287
+ const joinedRefusal = joinedFaceAdmissionRefusal(callerSession, team);
4687
5288
  if (joinedRefusal !== undefined) {
4688
5289
  recordActivity('out', team, callerSession ?? 'local', false);
4689
5290
  return { ok: false, error: joinedRefusal, code: -32000 };
@@ -4693,6 +5294,16 @@ ${message}`;
4693
5294
  || resolveAgentForTeam(team) !== undefined
4694
5295
  || joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
4695
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
+ }
4696
5307
  // The native-teams claim probe is an await: pay it only when the four
4697
5308
  // cheap synchronous locality checks missed (a registry team is a local
4698
5309
  // candidate even though no session node backs it).
@@ -4779,7 +5390,7 @@ ${message}`;
4779
5390
  ? callbackAddress
4780
5391
  : routable ? callerSession : undefined;
4781
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>" }.)`;
4782
- 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);
4783
5394
  armReceiptAutosend(target, taskId, callerTeam, message);
4784
5395
  endRoute(flight);
4785
5396
  recordActivity('out', team, 'local', true);