@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +85 -14
  2. package/README_zh.md +81 -7
  3. package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
  4. package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -624
  7. package/cordis.patch.yml +21 -21
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +744 -0
  11. package/lib/index.js +139 -737
  12. package/lib/recursion-guard.js +78 -0
  13. package/lib/runtime.js +1 -0
  14. package/lib/skill/SKILL.md +34 -9
  15. package/lib/skill/references/cli.md +24 -7
  16. package/lib/skill/references/discovery.md +5 -2
  17. package/lib/skill/references/pitfalls.md +2 -1
  18. package/lib/skill/references/routing.md +18 -8
  19. package/lib/skill/references/setup.md +152 -0
  20. package/lib/task-ledger.js +6 -0
  21. package/lib/types/faces/admin.d.ts +132 -0
  22. package/lib/types/index.d.ts +11 -3
  23. package/lib/types/recursion-guard.d.ts +56 -0
  24. package/lib/types/runtime.d.ts +34 -0
  25. package/lib/types/task-ledger.d.ts +6 -0
  26. package/nexus-stage/package-lock.json +2262 -2262
  27. package/nexus-stage/src/canvas-ops.ts +175 -175
  28. package/nexus-stage/src/canvas-wire.ts +156 -156
  29. package/nexus-stage/src/edges.ts +72 -72
  30. package/nexus-stage/src/federation-overlay.ts +103 -103
  31. package/nexus-stage/src/federation.ts +240 -240
  32. package/nexus-stage/src/layout-doc.ts +143 -143
  33. package/nexus-stage/src/layout-wire.ts +158 -158
  34. package/nexus-stage/src/planning-card-text.ts +73 -0
  35. package/nexus-stage/src/planning-fit.ts +45 -0
  36. package/nexus-stage/src/planning-legend.ts +38 -0
  37. package/nexus-stage/src/planning-overlap.ts +134 -0
  38. package/nexus-stage/src/planning-relayout.ts +121 -0
  39. package/nexus-stage/src/planning-search.ts +168 -0
  40. package/nexus-stage/src/planning-view.ts +63 -88
  41. package/nexus-stage/src/planning.css +39 -8
  42. package/nexus-stage/src/seat.ts +35 -35
  43. package/nexus-stage/src/stage-name.ts +13 -13
  44. package/nexus-stage/src/tokens.ts +31 -31
  45. package/nexus-stage/src/viewport.ts +24 -1
  46. package/nexus-stage/src/wb-chat.ts +20 -6
  47. package/nexus-stage/src/wb-ledger.ts +20 -7
  48. package/nexus-stage/src/wb-names.ts +43 -0
  49. package/nexus-stage/src/workbench.css +8 -2
  50. package/nexus-stage/src/workbench.ts +55 -17
  51. package/nexus-stage/src/world.ts +5 -1
  52. package/package.json +216 -214
  53. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  54. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  55. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
package/lib/index.js CHANGED
@@ -31,19 +31,17 @@ import { GroupStore } from "./group-store.js";
31
31
  import { CanvasStore, isRemoteMemberRef, validRemoteMemberRef } from "./canvas-store.js";
32
32
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_DEFAULT_LIMIT, TRANSCRIPT_MAX_FILE_BYTES, TRANSCRIPT_MAX_LIMIT } from "./session-transcript.js";
33
33
  import { LayoutStore } from "./layout-store.js";
34
- import { fileURLToPath } from 'node:url';
35
34
  import { JoinedSessions } from "./joined-store.js";
36
35
  import { ExposureStore } from "./exposure-store.js";
37
- import { applyOwnerSettings } from "./owner-settings.js";
38
36
  import { CliDepositStore } from "./cli-deposit.js";
39
37
  import { TeamMembershipStore } from "./team-store.js";
40
38
  import { PeerStore } from "./peer-store.js";
41
39
  import { SelfReferralFilter } from "./self-suppress.js";
42
- import { resolveStageMount } from "./stage-mount.js";
43
- import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, ARCHIVE_CAP, archivedEndedBy, briefOf } from "./task-ledger.js";
40
+ import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
44
41
  import { formatReceipt, parseReceipt } from "./receipt.js";
45
42
  import { runReceiptLadder } from "./receipt-ladder.js";
46
43
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
44
+ import { refuseIfBudgetExhausted } from "./recursion-guard.js";
47
45
  import { resolveZone } from "./zone.js";
48
46
  import { registerA2aNetworkSkill } from "./skill-registration.js";
49
47
  import s from '@deepseek-ai/schemastery';
@@ -51,7 +49,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
51
49
  import { MessageId } from '@deepseek-ai/dsh-llm';
52
50
  import { SessionId } from '@deepseek-ai/dsh-session';
53
51
  import { A2aClient } from "./a2a-client.js";
54
- import { MAX_LAYOUT_BODY_BYTES, WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "./transport-caps.js";
52
+ import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "./transport-caps.js";
53
+ import { registerAdminFaces } from "./faces/admin.js";
55
54
  import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
56
55
  export { A2aClient } from "./a2a-client.js";
57
56
  export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
@@ -79,11 +78,13 @@ export const Config = s.object({
79
78
  wakeReconcileBackoffBaseMs: s.number().default(5_000),
80
79
  wakeReconcileMaxBackoffMs: s.number().default(600_000),
81
80
  /**
82
- * S4: the joinable-team curation list. A model calling a2a_team_join may
83
- * only declare membership in a team matching one of these patterns — an
84
- * exact name or a trailing-`*` prefix (`dsh/canvas/*`). Default empty:
85
- * the owner's no-self-join ruling, institutionalized as default-deny;
86
- * a refused join carries guidance instead of failing silently.
81
+ * S4 (gh#11 N7): the joinable-team curation list. A model calling
82
+ * a2a_team_join may only declare membership in a team with an EXACT
83
+ * entry here — trailing-`*` patterns are readable but authorize nothing
84
+ * (no mint rights: a pattern pre-authorizing every future name under it
85
+ * would let a model mint teams nobody created). Default empty: the
86
+ * owner's no-self-join ruling, institutionalized as default-deny; a
87
+ * refused join carries guidance instead of failing silently.
87
88
  */
88
89
  teamJoinAllowlist: s.array(s.string()).default([]),
89
90
  /**
@@ -365,10 +366,10 @@ export function apply(ctx, config) {
365
366
  * @param res - the response for the malformed-body rejection.
366
367
  * @param use - receives the parsed body.
367
368
  */
368
- // Wire code for boundary-refused corrupted text. -32004 is intentionally
369
- // left as a reservation gap for the client-abort telemetry family seen in
370
- // the wild; this lands at -32005 (cluster-C error-code registry).
371
- const WIRE_ERROR_TEXT_CORRUPTED = -32005;
369
+ // Wire code for boundary-refused corrupted text (cluster-C error-code
370
+ // registry): -32004 belongs to the route verdict, -32005 to the client's
371
+ // DELIVERED-UNSETTLED family, so the corrupted-text guard owns -32006.
372
+ const WIRE_ERROR_TEXT_CORRUPTED = -32006;
372
373
  /**
373
374
  * Boundary guard for undecodable text (consumed by the readJsonBody
374
375
  * guard): U+FFFD is produced by a lossy decoder upstream of us — the bytes
@@ -586,8 +587,18 @@ export function apply(ctx, config) {
586
587
  if (teamAuditRing.length > 32)
587
588
  teamAuditRing.splice(0, teamAuditRing.length - 32);
588
589
  };
590
+ /**
591
+ * Recent routing activity for the network panel: a bounded ring of the
592
+ * latest inbound and outbound route outcomes, newest last. The state route
593
+ * serves a copy; the panel derives unread badges from new entries.
594
+ */
589
595
  const ACTIVITY_CAP = 20;
590
596
  const recentActivity = [];
597
+ /**
598
+ * In-flight outbound routes: a route registers on dial and unregisters on
599
+ * settlement, so the panel can show collaboration in progress ("who is
600
+ * waiting on whom") — the state route serves a snapshot.
601
+ */
591
602
  const inFlightRoutes = new Map();
592
603
  let inFlightSeq = 0;
593
604
  const beginRoute = (team, peer) => {
@@ -1413,715 +1424,21 @@ export function apply(ctx, config) {
1413
1424
  });
1414
1425
  };
1415
1426
  whenWebServerSettled((webServer) => {
1416
- ctx.effect(() => webServer.register({
1417
- kind: 'exact',
1418
- path: '/__dsh_a2a/state',
1419
- handler: controlRoute((_req, res) => {
1420
- void (async () => {
1421
- const t0 = Date.now();
1422
- // The panel polls this route: pruning here makes a mid-session
1423
- // archive leave the network within one poll interval, no restart
1424
- // required.
1425
- pruneArchivedJoins();
1426
- pruneCanvasMemberships();
1427
- const tPrune = Date.now();
1428
- const assignments = groupStore.all();
1429
- const groupOf = (id) => {
1430
- const name = assignments[id];
1431
- return name === undefined ? undefined : name;
1432
- };
1433
- // Archived sessions are closed: the GUI hides them everywhere, so
1434
- // the panel must not re-list them as join surface rows — joined
1435
- // or not. (The prune above already took their join intents; this
1436
- // covers never-joined live roots and any intent a restart left.)
1437
- const isArchived = archivedSessionFilter();
1438
- const tFacts0 = Date.now();
1439
- const sessions = [...liveRoots.values()]
1440
- .filter(agent => isArchived?.(String(agent.id)) !== true)
1441
- .map(agent => ({
1442
- id: String(agent.id),
1443
- label: sessionLabelOf(agent),
1444
- team: sessionTeamOf(agent),
1445
- ...nodeMetadataOf(agent),
1446
- joined: sessionNodes.has(String(agent.id)),
1447
- live: true,
1448
- teams: [...teamMemberships.teamsOf(String(agent.id))],
1449
- // #19 D3: the session's exposure tier (linked floor /
1450
- // fleet opt-up) — readable on the state face.
1451
- exposure: exposureStore.tierOf(String(agent.id)),
1452
- ...(canvasStore.teamsOf(String(agent.id)).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(String(agent.id))] } : {}),
1453
- ...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
1454
- }));
1455
- // Cold joined sessions: a remembered intent whose Agent is not
1456
- // back yet (host restarted, session not opened). The row carries
1457
- // no facts — the Web client merges the session title from its
1458
- // own list — and wakes through the normal session-open path,
1459
- // after which the remount above joins it automatically.
1460
- // Cold rows only exist when a joined intent lacks a live agent;
1461
- // with all intents live (or none) the persistence layer is never
1462
- // asked and the handler stays synchronous to its response.
1463
- const tFacts1 = Date.now();
1464
- const coldCandidates = joinedSessions.list().filter(id => !liveRoots.has(id));
1465
- const persistence = ctx.get('sessionPersistence');
1466
- const persisted = persistence !== undefined && coldCandidates.length > 0 ? await coldJoinedIds() : new Set();
1467
- const tCold = Date.now();
1468
- for (const id of coldCandidates) {
1469
- // Archived intents are retained on disk (an archived window is
1470
- // transient; the 3081 wake defect came from deleting them), so
1471
- // the join surface must skip them here instead.
1472
- if (!persisted.has(id) || isArchived?.(id) === true)
1473
- continue;
1474
- sessions.push({
1475
- id,
1476
- label: `${session}-${id8(id)}`,
1477
- team: `${config.team}/${id8(id)}`,
1478
- joined: true,
1479
- live: false,
1480
- teams: [...teamMemberships.teamsOf(id)],
1481
- ...(canvasStore.teamsOf(id).length > 0 ? { canvasTeams: [...canvasStore.teamsOf(id)] } : {}),
1482
- ...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
1483
- });
1484
- }
1485
- const body = JSON.stringify({
1486
- nodes: true,
1487
- version: PLUGIN_VERSION,
1488
- sessions,
1489
- groups: groupStore.list(),
1490
- canvas: {
1491
- teams: canvasStore.list().map(name => ({
1492
- name,
1493
- team: canvasTeamOf(name),
1494
- members: canvasStore.membersOf(name).map(id => {
1495
- // Dual shape: local members report this zone's join/live
1496
- // facts; remote refs are opaque team handles — the state
1497
- // face passes them through with remote:true for the
1498
- // workbench's member chips and pickers.
1499
- if (isRemoteMemberRef(id)) {
1500
- return { id, team: id, remote: true };
1501
- }
1502
- return {
1503
- id,
1504
- team: `${config.team}/${id8(id)}`,
1505
- joined: sessionNodes.has(id) || joinedSessions.has(id),
1506
- live: sessionNodes.has(id),
1507
- };
1508
- }),
1509
- })),
1510
- },
1511
- host: lanIp === '' ? {} : { lanIp },
1512
- // gh#20 W-C (host rail): each peer row also carries its display
1513
- // name and version from the warm card (absent until a fetch),
1514
- // and the last-contact timestamp for the health lamp. W-A's
1515
- // link/verified ride along for the badge.
1516
- peers: peerStore.list().map(url => {
1517
- const card = cardCache.get(url)?.card;
1518
- const lastContact = peerStore.lastContact(url);
1519
- const lastContactOk = peerStore.lastContactOk(url);
1520
- return {
1521
- url,
1522
- score: peerStore.score(url),
1523
- ...(card?.name !== undefined && card.name !== '' ? { name: card.name } : {}),
1524
- ...(typeof card?.version === 'string' && card.version !== '' ? { version: card.version } : {}),
1525
- ...(lastContact !== undefined ? { lastContactAt: lastContact } : {}),
1526
- ...(lastContactOk !== undefined ? { lastContactOk } : {}),
1527
- ...peerLinkState(url),
1528
- };
1529
- }),
1530
- remote: (refreshRemoteRows(), (remoteRowsCache?.rows ?? []).map(row => {
1531
- const canvasTeams = canvasStore.teamsOf(row.team);
1532
- return canvasTeams.length > 0 ? { ...row, canvasTeams: [...canvasTeams] } : row;
1533
- })),
1534
- activity: recentActivity.slice(),
1535
- inFlight: [...inFlightRoutes.values()].map(route => ({
1536
- team: route.team,
1537
- peer: route.peer,
1538
- startedAt: route.startedAt,
1539
- })),
1540
- // Owed receipts: pending rows only — the cross-turn waits the
1541
- // in-flight ring cannot show. Dead-lettered rows ride their own
1542
- // list and settled ones just a count, all additive so the panel
1543
- // keeps rendering plain owed rows until it learns the tiers.
1544
- tasks: taskLedger.list()
1545
- .filter(task => task.status === 'pending')
1546
- .map(task => ({
1547
- taskId: task.taskId, team: task.team, peer: task.peer, startedAt: task.startedAt, status: task.status,
1548
- direction: task.direction ?? 'outbound', receiptExpected: task.receiptExpected !== false,
1549
- // The dispatch brief rides every row: a task id and a team
1550
- // say nothing about WHAT is owed — supervision cannot tell
1551
- // a nudge from an abandon without it.
1552
- ...(task.brief !== undefined ? { brief: task.brief } : {}),
1553
- // C-ruling bucket split: a pending row is settleable while
1554
- // it is young, expected, and answerable; past the stale
1555
- // hour it is conversational noise waiting on the dead TTL.
1556
- settleable: task.receiptExpected !== false && Date.now() - task.startedAt < 60 * 60_000,
1557
- })),
1558
- // C face: the stale/conversational tail as a count, so the
1559
- // panel can render only truly settleable debt.
1560
- tasksStaleCount: taskLedger.list().filter(task => task.status === 'pending' && (task.receiptExpected === false || Date.now() - task.startedAt >= 60 * 60_000)).length,
1561
- tasksDead: taskLedger.list()
1562
- .filter(task => task.status === 'dead')
1563
- .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 } : {}) })),
1564
- // gh#31: per-task outcomes for rows that LEFT the ledger — the
1565
- // chat drawer used to guess (settled, abandoned or cancelled).
1566
- // Bounded to the archive (≤64), newest first, with the
1567
- // controlled-vocabulary verdict each row settled by.
1568
- // gh#31 follow-up: archive() is already newest first (settles
1569
- // prepend), so the projection keeps that order — the earlier
1570
- // .reverse() served it oldest first. Each row also says HOW it
1571
- // ended (endedBy: abandon and cancel both record outcome
1572
- // 'abandoned', and so does a peer's own 'abandoned' receipt),
1573
- // whether a receipt came after the caller ended it (late), and
1574
- // its direction when known.
1575
- archived: taskLedger.archive().slice(0, ARCHIVE_CAP).map(row => ({
1576
- taskId: row.taskId,
1577
- team: row.team,
1578
- peer: row.peer,
1579
- startedAt: row.startedAt,
1580
- resolvedAt: row.resolvedAt,
1581
- outcome: row.outcome ?? 'unclassified',
1582
- endedBy: archivedEndedBy(row),
1583
- ...(row.direction !== undefined ? { direction: row.direction } : {}),
1584
- ...(row.lateReceiptAt !== undefined ? { late: true } : {}),
1585
- ...(row.brief !== undefined ? { brief: row.brief } : {}),
1586
- ...(row.summary !== undefined && row.summary !== '' ? { summary: row.summary.slice(0, 160) } : {}),
1587
- })),
1588
- archivedCount: taskLedger.archive().length,
1589
- // Ledger density face: the settled archive split by verdict, so
1590
- // the workbench can say WHICH way those rows settled instead of
1591
- // just how many. Additive and bounded (outcome vocabulary is a
1592
- // closed set); rows without an envelope outcome count as
1593
- // 'unclassified' rather than being dropped.
1594
- archivedSplit: (() => {
1595
- const split = {};
1596
- for (const row of taskLedger.archive()) {
1597
- const key = row.outcome ?? 'unclassified';
1598
- split[key] = (split[key] ?? 0) + 1;
1599
- }
1600
- return split;
1601
- })(),
1602
- // 0.5.36 observability: the idempotency window aggregate —
1603
- // window occupancy, outcome split, and cumulative claim-verdict
1604
- // counters (the data the UNIQUE-ization decision waits on).
1605
- // Read-only; no fingerprints cross here (they are /a2a/query's
1606
- // only auth material).
1607
- idempotency: idempotencyStore.stats(),
1608
- // gh#41 fleet planning: each tracked peer's published canvas
1609
- // org (full-card canvasOrg) from the warm card cache — the
1610
- // board renders REMOTE team frames from this. Absent until a
1611
- // linked fetch warmed the card.
1612
- peerOrg: peerStore.list().map(url => {
1613
- const card = cardCache.get(url)?.card;
1614
- const org = card?.canvasOrg;
1615
- return Array.isArray(org)
1616
- ? { url, org: org.filter((e) => e !== null && typeof e === 'object')
1617
- .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') : [] }))
1618
- .filter(e => e.team !== '') }
1619
- : { url, org: [] };
1620
- }),
1621
- // gh#9+gh#10 M1: envelope-absent deliveries during the
1622
- // OriginClaim window — the operator watches this drain to
1623
- // zero before closing the window.
1624
- legacyInbound: legacyInboundCount,
1625
- // F-observability: the boot prewarm's lifecycle — a dead or
1626
- // skipped prewarm is the root shape behind "cold rows pile up
1627
- // after restart" and must be readable, not a console rumor.
1628
- prewarm: { ...prewarmStatus, failed: [...prewarmStatus.failed] },
1629
- // F8 observability: reconciliation cadence, wake outcomes,
1630
- // and per-row failure reasons with backoff and the parked
1631
- // needs-repair state — the cold row's "why am I still cold".
1632
- reconcile: { ...reconcileStatus, rows: [...reconcileStatus.rows] },
1633
- // S1: flat node registry — local sessions and remote card
1634
- // nodes in one table, zone-qualified team handles as the
1635
- // stable cross-host addresses. Local ids are the session
1636
- // UUIDs; remote ids are the publishing team handles. (The
1637
- // `nodes` boolean above is the capability flag the panel
1638
- // already consumes; this face rides its own key.)
1639
- registry: {
1640
- nodes: [
1641
- ...sessions.map(row => ({
1642
- id: String(row.id),
1643
- label: String(row.label ?? ''),
1644
- team: String(row.team ?? ''),
1645
- zone: config.team,
1646
- live: row.live === true,
1647
- joined: row.joined === true,
1648
- ...(typeof row.workspace === 'string' && row.workspace !== '' ? { workspace: row.workspace } : {}),
1649
- teams: [...teamMemberships.teamsOf(String(row.id))],
1650
- })),
1651
- ...(remoteRowsCache?.rows ?? []).map(row => ({
1652
- id: String(row.team),
1653
- label: String(row.name ?? ''),
1654
- team: String(row.team),
1655
- zone: String(row.team).split('/')[0] ?? config.team,
1656
- host: String(row.origin ?? ''),
1657
- ...(row.workspace !== undefined && row.workspace !== '' ? { workspace: row.workspace } : {}),
1658
- ...(row.legacy === true ? { legacy: true } : {}),
1659
- teams: [...(row.teams ?? [])],
1660
- remote: true,
1661
- })),
1662
- ],
1663
- // S2: local team rosters rebuilt from member declarations.
1664
- teams: (() => {
1665
- const roster = new Map();
1666
- for (const entry of teamMemberships.list()) {
1667
- for (const team of entry.teams) {
1668
- const members = roster.get(team) ?? [];
1669
- members.push(entry.session);
1670
- roster.set(team, members);
1671
- }
1672
- }
1673
- return [...roster.entries()].map(([team, members]) => ({
1674
- team,
1675
- members,
1676
- live: members.filter(member => liveRoots.has(member)).length,
1677
- }));
1678
- })(),
1679
- // S4 audit face: join/leave attempts with outcomes — the
1680
- // queryable half that rotating log lines cannot be.
1681
- teamAudit: [...teamAuditRing],
1682
- },
1683
- // Unjoined-node reports: the one-way signal ring (design:
1684
- // docs/design/unjoined-report-ingress.md), newest first.
1685
- reports: [...reportRing].reverse(),
1686
- });
1687
- res.writeHead(200, {
1688
- 'Content-Type': 'application/json',
1689
- 'Content-Length': Buffer.byteLength(body),
1690
- // Phase timing (ms since request start): prune / live-row facts /
1691
- // cold-rows enumeration. Diagnostic for the poll-latency question.
1692
- 'X-A2A-Timing': `prune=${tPrune - t0};facts=${tFacts1 - tFacts0};cold=${tCold - tFacts1};total=${Date.now() - t0}`,
1693
- });
1694
- res.end(body);
1695
- })();
1696
- }),
1697
- }), 'a2a: session-node state route');
1698
- ctx.effect(() => webServer.register({
1699
- kind: 'exact',
1700
- path: '/__dsh_a2a/join',
1701
- handler: controlRoute((req, res) => {
1702
- readJsonBody(req, res, (body) => {
1703
- const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
1704
- if (agent === undefined) {
1705
- const payload = JSON.stringify({ error: 'no live session with that id' });
1706
- res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1707
- res.end(payload);
1708
- return;
1709
- }
1710
- mountSessionNode(agent);
1711
- joinedSessions.add(String(agent.id));
1712
- const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
1713
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1714
- res.end(payload);
1715
- });
1716
- }),
1717
- }), 'a2a: session-node join route');
1718
- ctx.effect(() => webServer.register({
1719
- kind: 'exact',
1720
- path: '/__dsh_a2a/leave',
1721
- handler: controlRoute((req, res) => {
1722
- readJsonBody(req, res, (body) => {
1723
- const id = typeof body.id === 'string' ? body.id : '';
1724
- if (id !== '')
1725
- sessionNodes.delete(id);
1726
- joinedSessions.remove(id);
1727
- // Leaving the network leaves every canvas team too: membership
1728
- // without join consent would be a routing backdoor. Team
1729
- // roster declarations die the same way (S2 consistency).
1730
- canvasStore.dropMember(id);
1731
- teamMemberships.dropSession(id);
1732
- const payload = JSON.stringify({ id });
1733
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1734
- res.end(payload);
1735
- });
1736
- }),
1737
- }), 'a2a: session-node leave route');
1738
- // gh#45 CLI 户口 control face (loopback/key gated like every write):
1739
- // publish (deposit a signed CLI card, ≤4h by ruling), revoke, list,
1740
- // and mailbox readout — the CLI polls this while alive.
1741
- ctx.effect(() => webServer.register({
1742
- kind: 'exact',
1743
- path: '/__dsh_a2a/cli-deposit',
1744
- handler: controlRoute((req, res) => {
1745
- readJsonBody(req, res, (body) => {
1746
- const action = body.action;
1747
- let payload;
1748
- if (action === 'publish') {
1749
- const card = typeof body.card === 'string' ? body.card : '';
1750
- const out = cliDeposits.deposit(card);
1751
- payload = JSON.stringify(out.ok
1752
- ? { ok: true, handle: out.handle, expiresAt: out.expiresAt, cardUrl: `${ownCardUrl}/a2a/cli/${encodeURIComponent(out.handle)}` }
1753
- : { ok: false, error: out.error });
1754
- }
1755
- else if (action === 'revoke') {
1756
- payload = JSON.stringify({ ok: cliDeposits.revoke(typeof body.handle === 'string' ? body.handle : '') });
1757
- }
1758
- else if (action === 'inbox') {
1759
- const handle = typeof body.handle === 'string' ? body.handle : '';
1760
- payload = JSON.stringify({ ok: true, mail: cliDeposits.readMail(handle) });
1761
- }
1762
- else {
1763
- payload = JSON.stringify({ ok: true, deposits: cliDeposits.list().map(row => ({ handle: row.handle, name: row.name, depositedAt: row.depositedAt, expiresAt: row.expiresAt })) });
1764
- }
1765
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1766
- res.end(payload);
1767
- });
1768
- }),
1769
- }), 'a2a: cli-deposit route');
1770
- // gh#41 fleet planning relay: the GUI asks THIS host to regulate a
1771
- // REMOTE peer's canvas team. The node key never leaves the server —
1772
- // the relay stamps the OriginClaim envelope and forwards to the
1773
- // peer's /a2a/canvas-op face; the peer's link gate authorizes.
1774
- ctx.effect(() => webServer.register({
1775
- kind: 'exact',
1776
- path: '/__dsh_a2a/fleet-canvas-op',
1777
- handler: controlRoute((req, res) => {
1778
- readJsonBody(req, res, async (body) => {
1779
- const { peer, op, name, id } = body;
1780
- const peerUrl = typeof peer === 'string' ? peer : '';
1781
- const opV = typeof op === 'string' ? op : '';
1782
- const nameV = typeof name === 'string' ? name : '';
1783
- const idV = typeof id === 'string' ? id : '';
1784
- const send = (payload) => {
1785
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1786
- res.end(payload);
1787
- };
1788
- if (peerUrl === '' || !/^https?:\/\//.test(peerUrl) || !['create', 'add-member', 'remove-member'].includes(opV) || (opV !== 'create' && nameV === '')) {
1789
- send(JSON.stringify({ ok: false, error: 'peer (http url), op (create | add-member | remove-member), and name are required' }));
1790
- return;
1791
- }
1792
- if (!peerStore.list().includes(peerUrl)) {
1793
- send(JSON.stringify({ ok: false, error: `peer ${peerUrl} is not tracked — regulate tracked peers only` }));
1794
- return;
1795
- }
1796
- try {
1797
- const target = peerUrl.replace(/\/$/, '') + '/a2a/canvas-op';
1798
- const reply = await globalThis.fetch(target, {
1799
- method: 'POST',
1800
- headers: { 'Content-Type': 'application/json', ...originClaimHeaders() },
1801
- body: JSON.stringify({ op: opV, name: nameV, ...(opV !== 'create' ? { id: idV } : {}) }),
1802
- signal: AbortSignal.timeout(15_000),
1803
- });
1804
- const raw = await reply.text();
1805
- try {
1806
- const out = JSON.parse(raw);
1807
- send(JSON.stringify(out));
1808
- }
1809
- catch {
1810
- // A non-JSON reply is a peer WITHOUT the canvas-op face (a
1811
- // pre-gh41 build) or a proxy page — name the shape honestly.
1812
- 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` }));
1813
- }
1814
- }
1815
- catch (error) {
1816
- send(JSON.stringify({ ok: false, error: `fleet op failed: ${error instanceof Error ? error.message : String(error)}` }));
1817
- }
1818
- });
1819
- }),
1820
- }), 'a2a: fleet-canvas-op relay route');
1821
- // gh#34 owner three-key panel: the remote build-out unlock knobs,
1822
- // surfaced as a control face instead of "翻配置文件". GET reads the
1823
- // effective values; POST writes the HOME patch layer (the
1824
- // authoritative surface) AND hot-applies the two call-time keys
1825
- // (teamJoinAllowlist / teamScopeInfo); announce is boot-time
1826
- // publishing, so its change lands on the next host restart and the
1827
- // reply says so. Same key gate as every write face.
1828
- ctx.effect(() => webServer.register({
1829
- kind: 'exact',
1830
- path: '/__dsh_a2a/owner-settings',
1831
- handler: controlRoute((req, res) => {
1832
- if (req.method === 'GET') {
1833
- const payload = JSON.stringify({
1834
- ok: true,
1835
- teamJoinAllowlist: [...ownerAllowlist],
1836
- teamScopeInfo: ownerTeamScopeInfo,
1837
- announce: config.announce === true,
1838
- announceRestartPending: ownerAnnounceRestartPending,
1839
- });
1840
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1841
- res.end(payload);
1842
- return;
1843
- }
1844
- readJsonBody(req, res, (rawBody) => {
1845
- const body = rawBody;
1846
- const changes = {};
1847
- if (Array.isArray(body.teamJoinAllowlist)) {
1848
- const list = body.teamJoinAllowlist.filter((name) => typeof name === 'string' && name !== '' && name.length <= 128);
1849
- changes.teamJoinAllowlist = [...new Set(list)];
1850
- }
1851
- if (typeof body.teamScopeInfo === 'boolean')
1852
- changes.teamScopeInfo = body.teamScopeInfo;
1853
- if (typeof body.announce === 'boolean')
1854
- changes.announce = body.announce;
1855
- if (Object.keys(changes).length === 0) {
1856
- const payload = JSON.stringify({ ok: false, error: 'no recognized keys: teamJoinAllowlist (string[]), teamScopeInfo (bool), announce (bool)' });
1857
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1858
- res.end(payload);
1859
- return;
1860
- }
1861
- try {
1862
- const patchPath = join(home, 'cordis.patch.yml');
1863
- const current = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
1864
- const { yaml, restartNeeded } = applyOwnerSettings(current, changes);
1865
- mkdirSync(home, { recursive: true });
1866
- writeFileSync(patchPath, yaml, 'utf8');
1867
- // Hot-apply the two call-time keys.
1868
- if (changes.teamJoinAllowlist !== undefined)
1869
- ownerAllowlist = [...changes.teamJoinAllowlist];
1870
- if (changes.teamScopeInfo !== undefined)
1871
- ownerTeamScopeInfo = changes.teamScopeInfo;
1872
- if (changes.announce !== undefined && changes.announce !== (config.announce === true))
1873
- ownerAnnounceRestartPending = true;
1874
- if (changes.announce !== undefined && changes.announce === (config.announce === true))
1875
- ownerAnnounceRestartPending = false;
1876
- logger.info(`a2a: owner settings updated (${Object.keys(changes).join(', ')}${restartNeeded ? ' — restart pending for announce' : ''})`);
1877
- const payload = JSON.stringify({ ok: true, restartNeeded, keys: Object.keys(changes) });
1878
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1879
- res.end(payload);
1880
- }
1881
- catch (error) {
1882
- const payload = JSON.stringify({ ok: false, error: `patch-layer write failed: ${error instanceof Error ? error.message : String(error)}` });
1883
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1884
- res.end(payload);
1885
- }
1886
- });
1887
- }),
1888
- }), 'a2a: owner-settings route');
1889
- // Groups control: create/remove named groups and assign sessions to
1890
- // them (an empty name unassigns). Same guard as join/leave — the
1891
- // group taxonomy enumerates session ids, so it stays behind the key.
1892
- // Deprecated as of 0.6.6 (canvas teams supersede manual grouping);
1893
- // kept working for old panels.
1894
- ctx.effect(() => webServer.register({
1895
- kind: 'exact',
1896
- path: '/__dsh_a2a/groups',
1897
- handler: controlRoute((req, res) => {
1898
- readJsonBody(req, res, (body) => {
1899
- const action = body.action;
1900
- let payload;
1901
- if (action === 'create') {
1902
- const name = typeof body.name === 'string' ? body.name : '';
1903
- const stored = groupStore.create(name);
1904
- payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
1905
- }
1906
- else if (action === 'remove') {
1907
- const name = typeof body.name === 'string' ? body.name : '';
1908
- payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
1909
- }
1910
- else if (action === 'assign') {
1911
- const id = typeof body.id === 'string' ? body.id : '';
1912
- const name = typeof body.name === 'string' ? body.name : undefined;
1913
- if (id === '' || name === undefined) {
1914
- payload = JSON.stringify({ ok: false, error: 'id and name are required' });
1915
- }
1916
- else {
1917
- payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
1918
- }
1919
- }
1920
- else {
1921
- payload = JSON.stringify({ ok: false, error: 'unknown action' });
1922
- }
1923
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1924
- res.end(payload);
1925
- });
1926
- }),
1927
- }), 'a2a: session-group route');
1928
- // Canvas control: create/remove named multi-member teams and manage
1929
- // membership. Same key guard as join/leave — membership enumerates
1930
- // session ids. A member must be a joined session (live node or
1931
- // remembered intent): canvas routing only reaches sessions the user
1932
- // put on the network by gesture — no routing backdoor over unjoined
1933
- // sessions.
1934
- ctx.effect(() => webServer.register({
1935
- kind: 'exact',
1936
- path: '/__dsh_a2a/canvas',
1937
- handler: controlRoute((req, res) => {
1938
- readJsonBody(req, res, (body) => {
1939
- const action = body.action;
1940
- let payload;
1941
- if (action === 'create') {
1942
- const name = typeof body.name === 'string' ? body.name : '';
1943
- const stored = canvasStore.create(name);
1944
- payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or team cap reached' } : { ok: true, name: stored, teams: canvasStore.list() });
1945
- }
1946
- else if (action === 'remove') {
1947
- const name = typeof body.name === 'string' ? body.name : '';
1948
- payload = JSON.stringify({ ok: canvasStore.remove(name), teams: canvasStore.list() });
1949
- }
1950
- else if (action === 'add-member') {
1951
- const name = typeof body.name === 'string' ? body.name : '';
1952
- const id = typeof body.id === 'string' ? body.id : '';
1953
- // Dual member shape: a local session id must be a joined node;
1954
- // a remote ref is a zone-qualified team handle whose shape this
1955
- // gate checks and whose existence the UI picker (directory
1956
- // rows) vouches for. Humans compose both via the workbench.
1957
- const memberValid = isRemoteMemberRef(id)
1958
- ? validRemoteMemberRef(id)
1959
- : id !== '' && (sessionNodes.has(id) || joinedSessions.has(id));
1960
- if (name === '' || !memberValid) {
1961
- payload = JSON.stringify({ ok: false, error: 'name and a member (joined session id, or a zone-qualified remote team handle like zone/a1b2c3d4) are required' });
1962
- }
1963
- else {
1964
- canvasStore.create(name);
1965
- payload = JSON.stringify({ ok: canvasStore.addMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
1966
- }
1967
- }
1968
- else if (action === 'remove-member') {
1969
- const name = typeof body.name === 'string' ? body.name : '';
1970
- const id = typeof body.id === 'string' ? body.id : '';
1971
- if (name === '' || id === '') {
1972
- payload = JSON.stringify({ ok: false, error: 'name and id are required' });
1973
- }
1974
- else {
1975
- payload = JSON.stringify({ ok: canvasStore.removeMember(name, id), teams: canvasStore.list(), members: canvasStore.membersOf(name) });
1976
- }
1977
- }
1978
- else {
1979
- payload = JSON.stringify({ ok: false, error: 'unknown action' });
1980
- }
1981
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1982
- res.end(payload);
1983
- });
1984
- }),
1985
- }), 'a2a: session-canvas route');
1986
- // Layout face (contract v2): whole-document GET + save/reset for the
1987
- // stage's spatial presentation state. Validated/clamped server-side by
1988
- // LayoutStore so a buggy client cannot poison persisted geometry.
1989
- ctx.effect(() => webServer.register({
1990
- kind: 'exact',
1991
- path: '/__dsh_a2a/canvas-layout',
1992
- handler: controlRoute((req, res) => {
1993
- if (req.method === 'GET') {
1994
- const payload = JSON.stringify({ ok: true, layout: layoutStore.get() });
1995
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
1996
- res.end(payload);
1997
- return;
1998
- }
1999
- readJsonBody(req, res, (body) => {
2000
- const action = body.action;
2001
- if (action === 'reset') {
2002
- layoutStore.reset();
2003
- const payload = JSON.stringify({ ok: true, layout: null });
2004
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2005
- res.end(payload);
2006
- return;
2007
- }
2008
- if (action === 'save') {
2009
- const accepted = layoutStore.save(body.layout);
2010
- const payload = JSON.stringify(accepted ? { ok: true, layout: layoutStore.get() } : { ok: false, error: 'payload is not a version-1 layout document' });
2011
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2012
- res.end(payload);
2013
- return;
2014
- }
2015
- const payload = JSON.stringify({ ok: false, error: 'unknown action' });
2016
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2017
- res.end(payload);
2018
- }, MAX_LAYOUT_BODY_BYTES);
2019
- }),
2020
- }), 'a2a: canvas-layout route');
2021
- // The dsh-a2a-munder-difflin floor stage: a same-origin full-page
2022
- // surface adapted from the Munder Difflin office renderer (stage/,
2023
- // NOTICE). Serves the built Vite tree with a traversal-guarded map.
2024
- ctx.effect(() => webServer.register({
2025
- kind: 'prefix',
2026
- path: '/__dsh_a2a_canvas',
2027
- handler: (req, res) => {
2028
- const rootDir = fileURLToPath(new URL('../assets/stageDist', import.meta.url));
2029
- // Directory-classic resolution: the bare mount 301s onto itself
2030
- // with a slash, so the shell's `./assets` references resolve
2031
- // inside this tree instead of 404ing against the web root.
2032
- const MOUNT = '/__dsh_a2a_canvas';
2033
- const resolved = resolveStageMount(req.url ?? '/', MOUNT);
2034
- if (resolved.redirectTo !== undefined) {
2035
- res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store', 'X-A2A-Stage': 'redirect' });
2036
- res.end();
2037
- return;
2038
- }
2039
- const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
2040
- if (rel.includes('..')) {
2041
- res.writeHead(403, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'traversal' });
2042
- res.end();
2043
- return;
2044
- }
2045
- try {
2046
- const file = readFileSync(join(rootDir, rel));
2047
- const ext = rel.slice(rel.lastIndexOf('.') + 1);
2048
- const types = {
2049
- html: 'text/html; charset=utf-8',
2050
- js: 'text/javascript; charset=utf-8',
2051
- mjs: 'text/javascript; charset=utf-8',
2052
- css: 'text/css; charset=utf-8',
2053
- png: 'image/png',
2054
- json: 'application/json; charset=utf-8',
2055
- svg: 'image/svg+xml',
2056
- };
2057
- res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store', 'X-A2A-Stage': 'ok:' + ext });
2058
- res.end(file);
2059
- }
2060
- catch (err) {
2061
- res.writeHead(404, { 'Content-Type': 'text/plain', 'X-A2A-Stage': 'miss' });
2062
- res.end('stage-miss rel=' + rel + ' rootDir=' + rootDir + ' err=' + String(err));
2063
- }
2064
- },
2065
- }), 'a2a: canvas-stage page');
2066
- // The dsh-a2a-nexus stage: Three.js infinite-canvas topology viewer.
2067
- // Serves the built Vite tree from assets/nexusDist with a traversal-
2068
- // guarded file map (same discipline as canvas-stage).
2069
- ctx.effect(() => webServer.register({
2070
- kind: 'prefix',
2071
- path: '/__dsh_a2a_nexus',
2072
- handler: (req, res) => {
2073
- const rootDirNexus = fileURLToPath(new URL('../assets/nexusDist', import.meta.url));
2074
- // Same directory-classic resolution as the canvas mount: the bare
2075
- // `/__dsh_a2a_nexus` form used to serve the shell verbatim, whose
2076
- // `./assets` then 404'd at the web root — a silent black viewer.
2077
- const MOUNT = '/__dsh_a2a_nexus';
2078
- const resolved = resolveStageMount(req.url ?? '/', MOUNT);
2079
- if (resolved.redirectTo !== undefined) {
2080
- res.writeHead(301, { Location: resolved.redirectTo, 'Cache-Control': 'no-store' });
2081
- res.end();
2082
- return;
2083
- }
2084
- const rel = decodeURIComponent(resolved.rel).replace(/\\/g, '/');
2085
- if (rel.includes('..')) {
2086
- res.writeHead(403, { 'Content-Type': 'text/plain' });
2087
- res.end();
2088
- return;
2089
- }
2090
- try {
2091
- const file = readFileSync(join(rootDirNexus, rel));
2092
- const ext = rel.slice(rel.lastIndexOf('.') + 1);
2093
- const types = {
2094
- html: 'text/html; charset=utf-8',
2095
- js: 'text/javascript; charset=utf-8',
2096
- mjs: 'text/javascript; charset=utf-8',
2097
- css: 'text/css; charset=utf-8',
2098
- png: 'image/png',
2099
- json: 'application/json; charset=utf-8',
2100
- svg: 'image/svg+xml',
2101
- };
2102
- res.writeHead(200, { 'Content-Type': types[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-store' });
2103
- // gh#9 §10 GUI key plumbing (REVIEW M3 fix): the shell learns
2104
- // the control-plane key ONLY on the loopback leniency face —
2105
- // a remote bare GET of the page serves the shell WITHOUT the
2106
- // key (the reviewer proved the pre-fix shape leaked the
2107
- // credential to any network-reachable fetcher on a
2108
- // 0.0.0.0-bound host, contradicting §10's purpose). Loopback
2109
- // localhost is exactly §10.2's stated leniency posture; keyed
2110
- // hosts behind tunnels use originClaimUrl-style operator
2111
- // plumbing instead.
2112
- if (ext === 'html' && config.apiKey !== '' && controlTrustedWithoutKey(req)) {
2113
- const injected = file.toString('utf8').replace('</head>', `<script>window.__DSH_A2A_KEY__=${JSON.stringify(config.apiKey)};</script></head>`);
2114
- res.end(Buffer.from(injected, 'utf8'));
2115
- return;
2116
- }
2117
- res.end(file);
2118
- }
2119
- catch {
2120
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2121
- res.end();
2122
- }
2123
- },
2124
- }), 'a2a: nexus-stage page');
1427
+ // gh#77 B1: the admin faces live in src/faces/admin.ts.
1428
+ registerAdminFaces(webServer, {
1429
+ ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships,
1430
+ indexUrl: import.meta.url,
1431
+ get cardCache() { return cardCache; },
1432
+ get remoteRowsCache() { return remoteRowsCache; },
1433
+ get legacyInboundCount() { return legacyInboundCount; },
1434
+ get ownCardUrl() { return ownCardUrl; },
1435
+ get ownerAllowlist() { return ownerAllowlist; },
1436
+ set ownerAllowlist(v) { ownerAllowlist = v; },
1437
+ get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
1438
+ set ownerTeamScopeInfo(v) { ownerTeamScopeInfo = v; },
1439
+ get ownerAnnounceRestartPending() { return ownerAnnounceRestartPending; },
1440
+ set ownerAnnounceRestartPending(v) { ownerAnnounceRestartPending = v; },
1441
+ });
2125
1442
  });
2126
1443
  }
2127
1444
  if (config.announce) {
@@ -2971,6 +2288,24 @@ ${message}`;
2971
2288
  if (canvasStore.remoteMembersOf(name).includes(caller))
2972
2289
  declared.add(canvasTeamOf(name));
2973
2290
  }
2291
+ // gh#71 fix ②: the warm card cache's peer canvasOrg is the same-zone
2292
+ // cross-host basis too — an org entry naming BOTH the caller's handle
2293
+ // and one of THIS host's own handles is a shared team (双向识别: the
2294
+ // composition gesture happened on the other host's board; both ends
2295
+ // are named). Read-only over the cache; a caller org that never names
2296
+ // an own handle still refuses (V5 preserved).
2297
+ const ownHandles = new Set([config.team, ...joinedSessions.list().map(id => `${config.team}/${id8(id)}`)]);
2298
+ for (const cached of cardCache.values()) {
2299
+ const org = cached.card?.canvasOrg;
2300
+ if (!Array.isArray(org))
2301
+ continue;
2302
+ for (const e of org) {
2303
+ if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
2304
+ continue;
2305
+ if (e.members.includes(caller) && e.members.some(m => typeof m === 'string' && ownHandles.has(m)))
2306
+ declared.add(e.team);
2307
+ }
2308
+ }
2974
2309
  }
2975
2310
  const routable = new Set([config.team]);
2976
2311
  for (const id of joinedSessions.list())
@@ -3011,6 +2346,20 @@ ${message}`;
3011
2346
  }
3012
2347
  for (const name of canvasStore.teamsOf(targetSession))
3013
2348
  targetLinks.add(canvasTeamOf(name));
2349
+ // gh#71 fix ②: the warm cache's org basis at target level too —
2350
+ // an org entry naming BOTH the caller and the target handle is
2351
+ // the shared team (same 双向识别 rule as the host-level basis).
2352
+ for (const cached of cardCache.values()) {
2353
+ const org = cached.card?.canvasOrg;
2354
+ if (!Array.isArray(org))
2355
+ continue;
2356
+ for (const e of org) {
2357
+ if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
2358
+ continue;
2359
+ if (e.members.includes(caller) && e.members.includes(targetTeam))
2360
+ targetLinks.add(e.team);
2361
+ }
2362
+ }
3014
2363
  }
3015
2364
  }
3016
2365
  else {
@@ -3406,12 +2755,12 @@ ${message}`;
3406
2755
  }
3407
2756
  legacyInboundCount += 1;
3408
2757
  }
2758
+ if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
2759
+ return;
3409
2760
  // Server-side idempotency (P3/B3): one key, one execution inside
3410
- // the window. Same key + same payload fingerprint ⇒ replay (409,
3411
- // -32003, replay:true — the prior attempt stays authoritative);
3412
- // same key + different payload ⇒ hard conflict (409, -32002).
3413
- // Checked BEFORE any steering or ledger correlation: a replay must
3414
- // never re-enter the target session, no matter how it settles.
2761
+ // the window. Same key + same payload ⇒ replay; different payload
2762
+ // ⇒ hard conflict (both 409). A replay must never re-enter the
2763
+ // target session, no matter how it settles.
3415
2764
  const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
3416
2765
  const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
3417
2766
  if (idemVerdict !== 'fresh') {
@@ -4170,6 +3519,18 @@ ${message}`;
4170
3519
  async function fetchPeerCard(peer) {
4171
3520
  return settlePeerCard(peer, await client.fetchCardDetail(peer));
4172
3521
  }
3522
+ /**
3523
+ * Apply-scope shared card cache: every caller (teams listing, route
3524
+ * candidate walks, panel sweeps) shares one TTL window per URL, so a
3525
+ * poll-happy consumer cannot re-fetch a peer's card — or re-punish a dead
3526
+ * one — more than once per window. Cards live for days and re-sign at
3527
+ * TTL/4, so a 60s serve window is semantically safe. Negative results
3528
+ * (unreachable/invalid) cache for a shorter window so a restarting peer
3529
+ * reappears quickly. Scoring stays with the real network fetch only: a
3530
+ * cache hit never re-scores, which is what keeps the debounced peer-store
3531
+ * writes rare. Concurrent callers for one URL join a single in-flight
3532
+ * fetch (single-flight) instead of racing N requests.
3533
+ */
4173
3534
  const cardCache = new Map();
4174
3535
  const cardCacheInFlight = new Map();
4175
3536
  /**
@@ -4439,7 +3800,7 @@ ${message}`;
4439
3800
  allowlist: {
4440
3801
  type: 'object',
4441
3802
  additionalProperties: true,
4442
- description: 'The owner-curated teamJoinAllowlist patterns and their net effect (deny-all / curated / allow-all).',
3803
+ description: 'The owner-curated teamJoinAllowlist: exact entries (the only join authority, gh#11) plus pattern entries kept readable but carrying no mint rights (deny-all / curated; allow-all only via explicit exact entries).',
4443
3804
  },
4444
3805
  teamScopeRouting: { type: 'boolean' },
4445
3806
  note: { type: 'string', description: 'What join does and does not gate, in one sentence.' },
@@ -5177,11 +4538,12 @@ ${message}`;
5177
4538
  // node team when it has joined, else the node label), so the receiver
5178
4539
  // can answer with one a2a_route instead of an unroutable display label.
5179
4540
  a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
5180
- // S3 (team-scoped routing, off by default): when the owner turns it
5181
- // on, collaboration happens inside teams. A session caller must have
5182
- // declared membership in the target team before it may address it,
5183
- // and a caller with no teams at all has no network — a teamless node
5184
- // is, by the same ruling, an unjoined one. Host-side calls (no agent)
4541
+ // S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
4542
+ // ruling; set false to restore the open outbound seam): a session
4543
+ // caller must have declared membership in the target team before it
4544
+ // may address it, and a caller with no teams at all has no network —
4545
+ // a teamless node is, by the same ruling, an unjoined one. Host-side
4546
+ // calls (no agent)
5185
4547
  // and the initiator face stay exempt, like the join gate; discovery
5186
4548
  // (a2a_teams) stays open so a teamless node can still see what to join.
5187
4549
  if (config.teamScopeRouting && agent !== undefined) {
@@ -5194,13 +4556,48 @@ ${message}`;
5194
4556
  if (!initiator) {
5195
4557
  const callerTeams = teamMemberships.teamsOf(scopeCaller);
5196
4558
  const team = String(args.team ?? '').trim();
5197
- if (callerTeams.length === 0) {
4559
+ // gh#71: the SHARED-CANVAS basis and the warm cache's peer-org
4560
+ // basis (an org entry naming BOTH the caller and the target
4561
+ // handle) count as declared here too — the same link the
4562
+ // admission core honors on the receiving side; without this the
4563
+ // tool face refused routes the canvas itself had authorized.
4564
+ // Canvas members mix two shapes: LOCAL sessions sit by session
4565
+ // id, cross-host members by zone/id8 handle — match either.
4566
+ const callerHandle = `${config.team}/${id8(scopeCaller)}`;
4567
+ // gh#45 末修 (66abc2cf defect): a CANVAS GROUP NAME target
4568
+ // (`<team>/canvas/<name>`) never matches member handles — a
4569
+ // whole-group dispatch from a group member was refused. The
4570
+ // group's own team handle (canvasTeamOf(name)) is the match
4571
+ // (the caller sits in the very group being addressed).
4572
+ const sharedCanvas = canvasStore.list().some(name => {
4573
+ const members = canvasStore.membersOf(name);
4574
+ const inGroup = members.includes(scopeCaller) || members.includes(callerHandle);
4575
+ return inGroup ? members.includes(team) || canvasTeamOf(name) === team : false;
4576
+ });
4577
+ let orgShared = false;
4578
+ for (const cached of cardCache.values()) {
4579
+ const org = cached.card?.canvasOrg;
4580
+ if (!Array.isArray(org))
4581
+ continue;
4582
+ for (const e of org) {
4583
+ if (typeof e.team !== 'string' || e.team === '' || !Array.isArray(e.members))
4584
+ continue;
4585
+ const inOrg = e.members.includes(scopeCaller) || e.members.includes(callerHandle);
4586
+ if (inOrg && (e.members.includes(team) || e.team === team)) {
4587
+ orgShared = true;
4588
+ break;
4589
+ }
4590
+ }
4591
+ if (orgShared)
4592
+ break;
4593
+ }
4594
+ if (callerTeams.length === 0 && !sharedCanvas && !orgShared) {
5198
4595
  return {
5199
4596
  ok: false,
5200
4597
  error: `team-scoped routing is on and this session (${id8(scopeCaller)}) has declared no team membership — a teamless node has no network. Declare one with a2a_team_join (subject to the owner's teamJoinAllowlist)`,
5201
4598
  };
5202
4599
  }
5203
- if (!callerTeams.includes(team)) {
4600
+ if (!callerTeams.includes(team) && !sharedCanvas && !orgShared) {
5204
4601
  return {
5205
4602
  ok: false,
5206
4603
  error: `team-scoped routing is on: routing to "${team}" requires declared membership. This session's teams: ${callerTeams.join(', ')}. a2a_team_join can declare it (subject to the owner's teamJoinAllowlist)`,
@@ -6218,8 +5615,13 @@ ${message}`;
6218
5615
  // and learns its referrals, exactly like a discovery sweep would,
6219
5616
  // while the outcome's stage names the failure for the report.
6220
5617
  const card = settlePeerCard(url, outcome);
6221
- if (card !== undefined)
5618
+ if (card !== undefined) {
5619
+ // gh#71 fix ①: a successful full-face fetch persists into the warm
5620
+ // cache — the probe is often the FIRST contact (no directory sweep
5621
+ // has run), and the state face's peerOrg reads exactly this cache.
5622
+ cardCache.set(url, { at: Date.now(), card });
6222
5623
  return { url, reachable: true, ms, team: card.team };
5624
+ }
6223
5625
  if (outcome.ok)
6224
5626
  return { url, reachable: false, ms, error: 'dropped: the card lists this node back as its own referral' };
6225
5627
  return outcome.stage === 'unreachable'