@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/README.md +15 -11
- package/README_zh.md +3 -2
- package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
- package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +612 -0
- package/lib/card.js +17 -0
- package/lib/cli-deposit.js +141 -0
- package/lib/index.js +636 -25
- package/lib/owner-settings.js +100 -0
- package/lib/peer-store.js +65 -9
- package/lib/task-ledger.js +44 -3
- package/lib/types/cli-deposit.d.ts +53 -0
- package/lib/types/owner-settings.d.ts +19 -0
- package/lib/types/peer-store.d.ts +22 -3
- package/lib/types/task-ledger.d.ts +18 -0
- package/lib/types/types.d.ts +10 -0
- package/nexus-stage/src/canvas-ops.ts +2 -2
- package/nexus-stage/src/federation.ts +6 -0
- package/nexus-stage/src/main.ts +97 -4
- package/nexus-stage/src/planning-menus.ts +16 -4
- package/nexus-stage/src/planning-types.ts +3 -0
- package/nexus-stage/src/planning-view.ts +208 -18
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-chat.ts +670 -0
- package/nexus-stage/src/wb-directory.ts +222 -0
- package/nexus-stage/src/wb-ledger.ts +266 -0
- package/nexus-stage/src/wb-rail.ts +147 -0
- package/nexus-stage/src/workbench.css +190 -84
- package/nexus-stage/src/workbench.ts +385 -721
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +214 -218
- package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
- package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
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
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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})
|
|
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
|
-
|
|
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) =>
|
|
3807
|
-
const teamJoinPatternMatches = (team) =>
|
|
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 = [...
|
|
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),
|
|
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
|
|
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 (
|
|
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
|
|
4281
|
-
|
|
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) =>
|
|
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})
|
|
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);
|