a2acalling 0.6.70 → 0.6.71

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.6.70",
3
- "installed_at": "2026-02-26T06:18:02.969Z",
3
+ "installed_at": "2026-02-26T07:50:29.975Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "CLAUDE.md",
@@ -56,7 +56,8 @@
56
56
  },
57
57
  {
58
58
  "path": ".codex/AGENTS.md",
59
- "action": "skipped"
59
+ "action": "error",
60
+ "detail": "Source file not found"
60
61
  },
61
62
  {
62
63
  "path": ".a2a-manifest.json",
package/CONVENTIONS.md CHANGED
@@ -99,13 +99,20 @@ close() {
99
99
  ```
100
100
  - `close()` must be idempotent (safe to call multiple times)
101
101
  - `close()` must be a no-op when DB was never initialized (`this.db === null`)
102
- - The `server.js` `shutdown()` function closes all stores on SIGTERM/SIGINT
102
+ - The `server.js` `shutdown()` function closes all stores on SIGTERM/SIGINT: `serverConvStore`, `eventStore`, `callbookStore` (A2A-59), and logger stores
103
+ - All stores should be created at the `server.js` module level and passed to route factories — do NOT create stores inside route factories (A2A-59)
103
104
  - Test teardown should call `store.close()` to prevent SQLite handle leaks
104
105
 
105
106
  ## Permission Tiers
106
107
 
107
108
  Tokens have a tier (`public`, `friends`, `family`) and a disclosure level (`public`, `minimal`, `none`). These are enforced at the route level in `src/routes/a2a.js`.
108
109
 
110
+ ## Route Hardening (A2A-53)
111
+
112
+ - Rate limit Map has eviction: entries are swept when Map exceeds 1000 entries (stale >24h first, then oldest by insertion order)
113
+ - Admin token comparison uses `timingSafeTokenEqual()` from `src/routes/a2a.js` — do NOT use `!==` for secret comparison
114
+ - Query parameter parsing follows the dashboard.js pattern: `Math.min(max, Math.max(min, Number.parseInt(String(value), 10) || defaultValue))`
115
+
109
116
  ## Anti-Patterns
110
117
 
111
118
  - Do NOT use `console.log` — use the structured logger
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.70",
3
+ "version": "0.6.71",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1351,10 +1351,13 @@ function renderSidebarPreview(tierId) {
1351
1351
  function renderSidebarLists(tier) {
1352
1352
  const topicContainer = document.getElementById('sidebar-topics');
1353
1353
  const goalContainer = document.getElementById('sidebar-goals');
1354
- if (!topicContainer || !goalContainer) return;
1354
+ if (!topicContainer || !goalContainer || !tier) return;
1355
1355
 
1356
- // Collect ALL topics across ALL tiers for the sidebar
1357
- const allTiers = state.settings?.tiers || [];
1356
+ // Collect ALL topics/goals across tiers, but prioritize the currently
1357
+ // rendered tier first so sidebar drag payloads carry the right metadata.
1358
+ // This also ensures unsaved in-DOM items from create/drop appear immediately.
1359
+ const baseTiers = state.settings?.tiers || [];
1360
+ const allTiers = [tier, ...baseTiers.filter(t => t.id !== tier.id)];
1358
1361
  const allTopicMap = new Map();
1359
1362
  const allGoalMap = new Map();
1360
1363
  for (const t of allTiers) {
@@ -1362,17 +1365,24 @@ function renderSidebarLists(tier) {
1362
1365
  const fTopics = t.topics || [];
1363
1366
  const topics = mTopics.length > 0 ? mTopics : fTopics.map(x => ({ topic: x, description: '' }));
1364
1367
  for (const item of topics) {
1365
- if (item.topic && !allTopicMap.has(item.topic)) {
1366
- allTopicMap.set(item.topic, item.description || '');
1368
+ if (!item.topic) continue;
1369
+ const existingDesc = allTopicMap.get(item.topic);
1370
+ const nextDesc = item.description || '';
1371
+ if (!allTopicMap.has(item.topic) || (!existingDesc && nextDesc)) {
1372
+ allTopicMap.set(item.topic, nextDesc);
1367
1373
  }
1368
1374
  }
1375
+
1369
1376
  const mGoals = t.manifest?.objectives || [];
1370
1377
  const fGoals = t.goals || [];
1371
1378
  const goals = mGoals.length > 0 ? mGoals : fGoals.map(x => ({ topic: x, description: '' }));
1372
1379
  for (const item of goals) {
1373
1380
  const label = item.objective || item.topic;
1374
- if (label && !allGoalMap.has(label)) {
1375
- allGoalMap.set(label, item.description || '');
1381
+ if (!label) continue;
1382
+ const existingDesc = allGoalMap.get(label);
1383
+ const nextDesc = item.description || '';
1384
+ if (!allGoalMap.has(label) || (!existingDesc && nextDesc)) {
1385
+ allGoalMap.set(label, nextDesc);
1376
1386
  }
1377
1387
  }
1378
1388
  }
@@ -1428,53 +1438,128 @@ function renderSidebarLists(tier) {
1428
1438
  `;
1429
1439
  }
1430
1440
 
1431
- // A2A-48: Debounced auto-save replaces explicit Save Tier button.
1432
- // 250ms delay prevents excessive API calls during rapid changes.
1441
+ // A2A-61: Snapshot permissions payload from live DOM at mutation time.
1442
+ // This prevents polling/re-render races from dropping unsaved drag changes.
1443
+ function collectTierPayloadFromDom() {
1444
+ const toggles = document.querySelectorAll('#tool-toggles .toggle-switch input');
1445
+ const allowed_tools = Array.from(toggles).filter(t => t.checked).map(t => t.dataset.tool);
1446
+
1447
+ const topicCards = document.querySelectorAll('#active-topics-zone .active-item-card');
1448
+ // A2A-48: uses dataset.topic for BOTH topics and goals (NOT dataset.objective)
1449
+ // because parseTopicObjects() in dashboard.js only reads entry.topic.
1450
+ const topics = Array.from(topicCards).map(c => c.dataset.topic).filter(Boolean);
1451
+ const manifestTopics = Array.from(topicCards).map(c => ({
1452
+ topic: c.dataset.topic,
1453
+ description: c.dataset.description || ''
1454
+ })).filter(t => t.topic);
1455
+
1456
+ const goalCards = document.querySelectorAll('#active-goals-zone .active-item-card');
1457
+ const goals = Array.from(goalCards).map(c => c.dataset.topic).filter(Boolean);
1458
+ const manifestObjectives = Array.from(goalCards).map(c => ({
1459
+ topic: c.dataset.topic,
1460
+ description: c.dataset.description || ''
1461
+ })).filter(g => g.topic);
1462
+
1463
+ return {
1464
+ allowed_tools,
1465
+ topics,
1466
+ goals,
1467
+ manifest: {
1468
+ topics: manifestTopics,
1469
+ objectives: manifestObjectives
1470
+ }
1471
+ };
1472
+ }
1473
+
1474
+ // A2A-61: Build a tier view using live DOM state so sidebar active badges
1475
+ // stay accurate immediately after create/drop/delete (before round-trip save).
1476
+ function getTierSnapshotFromDom() {
1477
+ const tierId = state.activeTierId;
1478
+ const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
1479
+ if (!tier) return null;
1480
+ const payload = collectTierPayloadFromDom();
1481
+ return {
1482
+ ...tier,
1483
+ allowed_tools: payload.allowed_tools,
1484
+ topics: payload.topics,
1485
+ goals: payload.goals,
1486
+ manifest: {
1487
+ ...(tier.manifest || {}),
1488
+ topics: payload.manifest.topics,
1489
+ objectives: payload.manifest.objectives
1490
+ }
1491
+ };
1492
+ }
1493
+
1494
+ function renderSidebarListsFromDom() {
1495
+ const snapshot = getTierSnapshotFromDom();
1496
+ if (snapshot) renderSidebarLists(snapshot);
1497
+ }
1498
+
1499
+ // A2A-61: Debounced auto-save with per-tier queueing.
1500
+ // Snapshot is captured at mutation time; timer only controls network cadence.
1433
1501
  let _autoSaveTimer = null;
1434
- function autoSaveTier() {
1435
- clearTimeout(_autoSaveTimer);
1436
- _autoSaveTimer = setTimeout(async () => {
1437
- const tierId = state.activeTierId;
1438
- if (!tierId) return;
1502
+ const _pendingTierSaves = new Map();
1503
+ let _tierSaveQueue = [];
1504
+ let _tierSaveInFlight = false;
1439
1505
 
1440
- // Collect tools from toggle states
1441
- const toggles = document.querySelectorAll('#tool-toggles .toggle-switch input');
1442
- const allowed_tools = Array.from(toggles).filter(t => t.checked).map(t => t.dataset.tool);
1443
-
1444
- // Collect topics from active zone
1445
- const topicCards = document.querySelectorAll('#active-topics-zone .active-item-card');
1446
- // A2A-48: uses dataset.topic for BOTH topics and goals (NOT dataset.objective)
1447
- // because parseTopicObjects() in dashboard.js:160 only reads entry.topic.
1448
- // The semantic distinction (objective vs topic) is UI-only; storage layer
1449
- // uses {topic, description} uniformly for both types.
1450
- const topics = Array.from(topicCards).map(c => c.dataset.topic).filter(Boolean);
1451
- const manifestTopics = Array.from(topicCards).map(c => ({
1452
- topic: c.dataset.topic,
1453
- description: c.dataset.description || ''
1454
- })).filter(t => t.topic);
1455
-
1456
- // Collect goals from active zone
1457
- const goalCards = document.querySelectorAll('#active-goals-zone .active-item-card');
1458
- const goals = Array.from(goalCards).map(c => c.dataset.topic).filter(Boolean);
1459
- const manifestObjectives = Array.from(goalCards).map(c => ({
1460
- topic: c.dataset.topic,
1461
- description: c.dataset.description || ''
1462
- })).filter(g => g.topic);
1463
-
1464
- const body = { allowed_tools, topics, goals, manifest: { topics: manifestTopics, objectives: manifestObjectives } };
1465
- // A2A-48: Refresh state inside try block so that a failed PUT does not
1466
- // trigger an unhandled rejection from the subsequent GET.
1467
- try {
1468
- await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1469
- method: 'PUT', body: JSON.stringify(body)
1470
- });
1471
- showNotice('Saved');
1472
- // Refresh state from server to stay in sync after auto-save
1473
- const payload = await request('/settings');
1474
- state.settings = payload;
1475
- } catch (err) {
1476
- showNotice(`Save failed: ${err.message}`);
1506
+ function hasPendingTierSaves() {
1507
+ return Boolean(_autoSaveTimer) || _tierSaveInFlight || _tierSaveQueue.length > 0 || _pendingTierSaves.size > 0;
1508
+ }
1509
+
1510
+ function queueTierSave(tierId, payload) {
1511
+ if (!tierId || !payload) return;
1512
+ if (!_pendingTierSaves.has(tierId)) {
1513
+ _tierSaveQueue.push(tierId);
1514
+ }
1515
+ _pendingTierSaves.set(tierId, payload);
1516
+ }
1517
+
1518
+ async function flushTierSaveQueue() {
1519
+ if (_tierSaveInFlight) return;
1520
+ _tierSaveInFlight = true;
1521
+
1522
+ try {
1523
+ while (_tierSaveQueue.length > 0) {
1524
+ const tierId = _tierSaveQueue.shift();
1525
+ const body = _pendingTierSaves.get(tierId);
1526
+ _pendingTierSaves.delete(tierId);
1527
+ if (!tierId || !body) continue;
1528
+
1529
+ try {
1530
+ await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1531
+ method: 'PUT',
1532
+ body: JSON.stringify(body)
1533
+ });
1534
+ showNotice('Saved');
1535
+
1536
+ // Refresh canonical settings after each successful save so future
1537
+ // renders and sidebar catalogs stay in sync with persisted state.
1538
+ const payload = await request('/settings');
1539
+ state.settings = payload;
1540
+ } catch (err) {
1541
+ showNotice(`Save failed: ${err.message}`);
1542
+ }
1477
1543
  }
1544
+ } finally {
1545
+ _tierSaveInFlight = false;
1546
+ if (_tierSaveQueue.length > 0) {
1547
+ flushTierSaveQueue().catch(() => {});
1548
+ }
1549
+ }
1550
+ }
1551
+
1552
+ function autoSaveTier(snapshotPayload = null) {
1553
+ const tierId = state.activeTierId;
1554
+ if (!tierId) return;
1555
+
1556
+ const payload = snapshotPayload || collectTierPayloadFromDom();
1557
+ queueTierSave(tierId, payload);
1558
+
1559
+ clearTimeout(_autoSaveTimer);
1560
+ _autoSaveTimer = setTimeout(() => {
1561
+ _autoSaveTimer = null;
1562
+ flushTierSaveQueue().catch(() => {});
1478
1563
  }, 250);
1479
1564
  }
1480
1565
 
@@ -1486,13 +1571,24 @@ function autoSaveTier() {
1486
1571
  function handleZoneDrop(zone, e) {
1487
1572
  e.preventDefault();
1488
1573
  zone.classList.remove('drag-over');
1574
+
1489
1575
  let data;
1490
1576
  try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1491
- if (!data.name) return;
1492
1577
 
1493
- // A2A-50: Route to correct zone by data.type, NOT by which zone received
1494
- // the drop. Falls back to zone.id routing if data.type is missing (defensive).
1495
- const itemType = data.type || (zone.id === 'active-topics-zone' ? 'topic' : 'goal');
1578
+ const name = String(data?.name || '').trim();
1579
+ if (!name) return;
1580
+
1581
+ // A2A-61: strict payload validation. Unknown explicit types are rejected
1582
+ // instead of silently defaulting into the goals column.
1583
+ let itemType = null;
1584
+ if (data.type === 'topic' || data.type === 'goal') {
1585
+ itemType = data.type;
1586
+ } else if (!data.type) {
1587
+ itemType = zone.id === 'active-topics-zone' ? 'topic' : 'goal';
1588
+ } else {
1589
+ return;
1590
+ }
1591
+
1496
1592
  const targetZoneId = itemType === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
1497
1593
  const targetZone = document.getElementById(targetZoneId);
1498
1594
  if (!targetZone) return;
@@ -1504,24 +1600,18 @@ function handleZoneDrop(zone, e) {
1504
1600
  // Check if already in target zone
1505
1601
  const existing = targetZone.querySelectorAll('.active-item-card');
1506
1602
  for (const card of existing) {
1507
- if (card.dataset.topic === data.name) return; // already active
1603
+ if (card.dataset.topic === name) return;
1508
1604
  }
1509
1605
 
1510
1606
  // A2A-50: Shared helper builds the card HTML to avoid duplication with
1511
1607
  // the create-item-submit handler. Both paths now use buildItemCard().
1512
- const card = buildItemCard(data.name, data.description || '', accentClass, typeLabel, removeAttr);
1608
+ const card = buildItemCard(name, String(data.description || ''), accentClass, typeLabel, removeAttr);
1513
1609
  const placeholder = targetZone.querySelector('.drop-placeholder');
1514
- targetZone.insertBefore(card, placeholder);
1515
- autoSaveTier();
1610
+ if (placeholder) targetZone.insertBefore(card, placeholder);
1611
+ else targetZone.appendChild(card);
1516
1612
 
1517
- // A2A-48: Re-fetch tier from state instead of using captured reference,
1518
- // since autoSaveTier() may refresh state.settings asynchronously.
1519
- setTimeout(() => {
1520
- const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1521
- if (freshTier) {
1522
- renderSidebarLists(freshTier);
1523
- }
1524
- }, 300);
1613
+ autoSaveTier();
1614
+ renderSidebarListsFromDom();
1525
1615
  }
1526
1616
 
1527
1617
  // A2A-50: Shared helper to build an active-item card DOM element.
@@ -1791,10 +1881,28 @@ function bindPermissionsActions() {
1791
1881
  [topicZone, goalZone].forEach(zone => {
1792
1882
  if (!zone) return;
1793
1883
  let dragCounter = 0;
1794
- zone.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; zone.classList.add('drag-over'); });
1884
+ const clearZoneDragState = () => {
1885
+ dragCounter = 0;
1886
+ zone.classList.remove('drag-over');
1887
+ };
1888
+
1889
+ zone.addEventListener('dragenter', (e) => {
1890
+ e.preventDefault();
1891
+ dragCounter += 1;
1892
+ zone.classList.add('drag-over');
1893
+ });
1795
1894
  zone.addEventListener('dragover', (e) => { e.preventDefault(); });
1796
- zone.addEventListener('dragleave', () => { dragCounter--; if (dragCounter === 0) zone.classList.remove('drag-over'); });
1797
- zone.addEventListener('drop', (e) => { dragCounter = 0; handleZoneDrop(zone, e); });
1895
+ zone.addEventListener('dragleave', () => {
1896
+ dragCounter = Math.max(0, dragCounter - 1);
1897
+ if (dragCounter <= 0) zone.classList.remove('drag-over');
1898
+ });
1899
+ zone.addEventListener('drop', (e) => {
1900
+ clearZoneDragState();
1901
+ handleZoneDrop(zone, e);
1902
+ });
1903
+
1904
+ // A2A-61: Ensure highlight never gets stuck if drag ends outside zone.
1905
+ window.addEventListener('dragend', clearZoneDragState);
1798
1906
  });
1799
1907
 
1800
1908
  // A2A-61: Delegated drag listeners on .perm-sidebar — survives innerHTML
@@ -1809,12 +1917,13 @@ function bindPermissionsActions() {
1809
1917
  const itemType = item.dataset.itemType || 'topic';
1810
1918
  const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
1811
1919
  const desc = item.dataset.description || '';
1920
+ e.dataTransfer.effectAllowed = 'move';
1812
1921
  e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
1813
- item.style.opacity = '0.5';
1922
+ item.classList.add('dragging');
1814
1923
  });
1815
1924
  sidebar.addEventListener('dragend', (e) => {
1816
1925
  const item = e.target.closest('.sidebar-item[draggable="true"]');
1817
- if (item) item.style.opacity = '';
1926
+ if (item) item.classList.remove('dragging');
1818
1927
  });
1819
1928
  }
1820
1929
 
@@ -1852,6 +1961,16 @@ function bindPermissionsActions() {
1852
1961
  const typeLabel = type === 'topic' ? 'Topic' : 'Goal';
1853
1962
  const removeAttr = type === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
1854
1963
 
1964
+ // A2A-61: Match drop-path dedupe behavior for create flow.
1965
+ const existing = zone.querySelectorAll('.active-item-card');
1966
+ for (const existingCard of existing) {
1967
+ if (existingCard.dataset.topic === title) {
1968
+ dialog.hide();
1969
+ showNotice(`Already active: ${title}`);
1970
+ return;
1971
+ }
1972
+ }
1973
+
1855
1974
  const card = buildItemCard(title, desc, accentClass, typeLabel, removeAttr);
1856
1975
  const placeholder = zone.querySelector('.drop-placeholder');
1857
1976
  if (placeholder) zone.insertBefore(card, placeholder);
@@ -1859,6 +1978,7 @@ function bindPermissionsActions() {
1859
1978
 
1860
1979
  dialog.hide();
1861
1980
  autoSaveTier();
1981
+ renderSidebarListsFromDom();
1862
1982
  });
1863
1983
 
1864
1984
  // A2A-48: Create Item dialog — cancel handler
@@ -1915,6 +2035,7 @@ function bindPermissionsActions() {
1915
2035
  }
1916
2036
  dialog.hide();
1917
2037
  autoSaveTier();
2038
+ renderSidebarListsFromDom();
1918
2039
  });
1919
2040
 
1920
2041
  // A2A-50: Delete confirm dialog — cancel handler
@@ -2310,9 +2431,12 @@ const tabLoaders = {
2310
2431
  contacts: loadContacts,
2311
2432
  calls: loadCalls,
2312
2433
  logs: () => { loadLogs(); loadLogStats(); },
2313
- // A2A-48: Load fresh settings data when switching to Permissions tab.
2314
- // Previously a no-op now ensures data is current on tab switch.
2315
- permissions: loadSettings,
2434
+ // A2A-48/A2A-61: Load fresh settings data when switching to Permissions,
2435
+ // but skip polling refresh while local drag/create/delete saves are pending.
2436
+ permissions: () => {
2437
+ if (hasPendingTierSaves()) return Promise.resolve();
2438
+ return loadSettings();
2439
+ },
2316
2440
  invites: loadInvites,
2317
2441
  health: loadHealth,
2318
2442
  // A2A-50: Settings tab loads dashboard status, auto-update, and callbook data
@@ -981,6 +981,10 @@ table tbody tr:hover td {
981
981
  border-color: rgba(255, 255, 255, 0.18);
982
982
  }
983
983
 
984
+ .sidebar-item.dragging {
985
+ opacity: 0.5;
986
+ }
987
+
984
988
  /* A2A-48: Active items in the sidebar are dimmed with dashed border and
985
989
  non-draggable to indicate they are already in the active zone. */
986
990
  .sidebar-item.active-in-zone {