a2acalling 0.6.58 → 0.6.60

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.58",
3
+ "version": "0.6.60",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -37,12 +37,19 @@ const SKILL_FILES = [
37
37
  // Copied to .claude/ so Claude Code discovers it naturally without grepping
38
38
  // node_modules. This is opt-in context: only loaded when the agent looks.
39
39
  { src: 'SKILL.md', dest: '.claude/a2a-skill-reference.md' },
40
- // Claude Code slash commands
40
+ // Claude Code slash commands — core (A2A-28)
41
41
  { src: '.claude/commands/a2a-call.md', dest: '.claude/commands/a2a-call.md' },
42
42
  { src: '.claude/commands/a2a-invite.md', dest: '.claude/commands/a2a-invite.md' },
43
43
  { src: '.claude/commands/a2a-contacts.md', dest: '.claude/commands/a2a-contacts.md' },
44
44
  { src: '.claude/commands/a2a-status.md', dest: '.claude/commands/a2a-status.md' },
45
45
  { src: '.claude/commands/a2a-setup.md', dest: '.claude/commands/a2a-setup.md' },
46
+ // Claude Code slash commands — extended (A2A-43)
47
+ { src: '.claude/commands/a2a-update.md', dest: '.claude/commands/a2a-update.md' },
48
+ { src: '.claude/commands/a2a-uninstall.md', dest: '.claude/commands/a2a-uninstall.md' },
49
+ { src: '.claude/commands/a2a-app.md', dest: '.claude/commands/a2a-app.md' },
50
+ { src: '.claude/commands/a2a-conversations.md', dest: '.claude/commands/a2a-conversations.md' },
51
+ { src: '.claude/commands/a2a-gui.md', dest: '.claude/commands/a2a-gui.md' },
52
+ { src: '.claude/commands/a2a-skills.md', dest: '.claude/commands/a2a-skills.md' },
46
53
  // Codex agent instructions
47
54
  { src: '.codex/AGENTS.md', dest: '.codex/AGENTS.md' }
48
55
  ];
@@ -261,7 +261,10 @@ function bindTabs() {
261
261
 
262
262
  // Deep-link support: activate the tab matching the URL hash
263
263
  const activateFromHash = () => {
264
- const hash = window.location.hash.slice(1);
264
+ let hash = window.location.hash.slice(1);
265
+ // A2A-41: backward-compat alias — old bookmarks/links using #settings
266
+ // still work after rename to #permissions
267
+ if (hash === 'settings') hash = 'permissions';
265
268
  if (hash) {
266
269
  // Use try/catch in case the tab group isn't fully ready
267
270
  try { tabGroup.show(hash); } catch (err) {}
@@ -1095,6 +1098,198 @@ async function loadTrace(traceId) {
1095
1098
  renderTraceDetail();
1096
1099
  }
1097
1100
 
1101
+ // A2A-41: emoji map for visual tier differentiation. Standard tiers get
1102
+ // recognizable icons; custom/user-created tiers get a wrench.
1103
+ const TIER_EMOJIS = { public: '\u{1F310}', friends: '\u{1F46B}', family: '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}' };
1104
+
1105
+ // A2A-41: tool descriptions for the checkbox UI. These match the tools
1106
+ // available in Claude Code that an agent owner might want to expose to callers.
1107
+ const TOOL_DESCRIPTIONS = {
1108
+ 'Bash': 'Execute shell commands \u2014 full access, can run anything',
1109
+ 'Bash(readonly)': 'Execute read-only shell commands \u2014 no writes, no installs',
1110
+ 'Read': 'Read files from the workspace',
1111
+ 'Grep': 'Search file contents with regex patterns',
1112
+ 'Glob': 'Find files by name patterns',
1113
+ 'WebSearch': 'Search the web for information',
1114
+ 'WebFetch': 'Fetch and read web page content'
1115
+ };
1116
+
1117
+ // A2A-41: standard tier order for inheritance. Custom tiers are not in this list.
1118
+ const TIER_ORDER = ['public', 'friends', 'family'];
1119
+
1120
+ // A2A-41: renders tool checkboxes instead of a textarea. Each tool gets
1121
+ // a checkbox with its description. Checked state comes from tier.allowed_tools.
1122
+ function renderToolCheckboxes(allowedTools) {
1123
+ const container = document.getElementById('tier-tools-list');
1124
+ container.innerHTML = Object.entries(TOOL_DESCRIPTIONS).map(([tool, desc]) => {
1125
+ const checked = (allowedTools || []).includes(tool) ? 'checked' : '';
1126
+ return `<sl-checkbox value="${esc(tool)}" ${checked}><strong>${esc(tool)}</strong> \u2014 <span class="tool-desc">${esc(desc)}</span></sl-checkbox>`;
1127
+ }).join('');
1128
+ }
1129
+
1130
+ // A2A-41: renders topics as expandable card rows with descriptions.
1131
+ // Data comes from tier.manifest.topics (array of {topic, description} objects).
1132
+ // Falls back to tier.topics (flat string array) for topics without manifest data.
1133
+ function renderTopicList(tier) {
1134
+ const container = document.getElementById('tier-topics-list');
1135
+ const manifestTopics = tier.manifest?.topics || [];
1136
+ const flatTopics = tier.topics || [];
1137
+
1138
+ // A2A-41: prefer manifest data (has descriptions), fall back to flat array
1139
+ const allTopics = manifestTopics.length > 0
1140
+ ? manifestTopics.map(t => ({ label: t.topic, desc: t.description || '' }))
1141
+ : flatTopics.map(t => ({ label: t, desc: '' }));
1142
+
1143
+ const rowsHtml = allTopics.map(t => `
1144
+ <div class="topic-row" data-topic="${esc(t.label)}" data-type="topic">
1145
+ <span class="drag-handle">\u2807</span>
1146
+ <div class="topic-content">
1147
+ <div class="topic-header">
1148
+ <strong class="topic-label">${esc(t.label)}</strong>
1149
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1150
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1151
+ </div>
1152
+ <div class="topic-description" style="display:none;">
1153
+ <p class="topic-desc-text">${esc(t.desc) || '<em>No description</em>'}</p>
1154
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(t.desc)}"></sl-input>
1155
+ </div>
1156
+ </div>
1157
+ </div>
1158
+ `).join('');
1159
+
1160
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="topic">+ Add topic</button>`;
1161
+ }
1162
+
1163
+ // A2A-41: renders goals as expandable card rows, identical pattern to topics.
1164
+ // Data from tier.manifest.objectives (array of {objective, description}).
1165
+ function renderGoalList(tier) {
1166
+ const container = document.getElementById('tier-goals-list');
1167
+ const manifestGoals = tier.manifest?.objectives || [];
1168
+ const flatGoals = tier.goals || [];
1169
+
1170
+ const allGoals = manifestGoals.length > 0
1171
+ ? manifestGoals.map(g => ({ label: g.objective || g.topic, desc: g.description || '' }))
1172
+ : flatGoals.map(g => ({ label: g, desc: '' }));
1173
+
1174
+ const rowsHtml = allGoals.map(g => `
1175
+ <div class="topic-row" data-topic="${esc(g.label)}" data-type="goal">
1176
+ <span class="drag-handle">\u2807</span>
1177
+ <div class="topic-content">
1178
+ <div class="topic-header">
1179
+ <strong class="topic-label">${esc(g.label)}</strong>
1180
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1181
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1182
+ </div>
1183
+ <div class="topic-description" style="display:none;">
1184
+ <p class="topic-desc-text">${esc(g.desc) || '<em>No description</em>'}</p>
1185
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(g.desc)}"></sl-input>
1186
+ </div>
1187
+ </div>
1188
+ </div>
1189
+ `).join('');
1190
+
1191
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="goal">+ Add goal</button>`;
1192
+ }
1193
+
1194
+ // A2A-41: event delegation for topic and goal list interactions.
1195
+ // Uses a single click handler on each container instead of per-row binding,
1196
+ // preventing listener accumulation when topics are added dynamically.
1197
+ function bindItemListDelegation() {
1198
+ ['tier-topics-list', 'tier-goals-list'].forEach(containerId => {
1199
+ const container = document.getElementById(containerId);
1200
+ if (!container) return;
1201
+
1202
+ container.addEventListener('click', (e) => {
1203
+ // Expand/collapse
1204
+ const expandBtn = e.target.closest('.topic-expand-btn');
1205
+ if (expandBtn) {
1206
+ const row = expandBtn.closest('.topic-row');
1207
+ const desc = row.querySelector('.topic-description');
1208
+ if (desc) {
1209
+ const isHidden = desc.style.display === 'none';
1210
+ desc.style.display = isHidden ? '' : 'none';
1211
+ expandBtn.name = isHidden ? 'chevron-up' : 'chevron-down';
1212
+ }
1213
+ return;
1214
+ }
1215
+
1216
+ // Delete
1217
+ const deleteBtn = e.target.closest('.topic-delete-btn');
1218
+ if (deleteBtn) {
1219
+ deleteBtn.closest('.topic-row').remove();
1220
+ return;
1221
+ }
1222
+
1223
+ // Add new item
1224
+ const addBtn = e.target.closest('.add-item-btn');
1225
+ if (addBtn) {
1226
+ const type = addBtn.dataset.type;
1227
+ const label = type === 'topic' ? 'Topic name' : 'Goal name';
1228
+ const newRow = document.createElement('div');
1229
+ newRow.className = 'topic-row';
1230
+ newRow.dataset.type = type;
1231
+ newRow.innerHTML = `
1232
+ <span class="drag-handle">\u2807</span>
1233
+ <div class="topic-content">
1234
+ <sl-input class="new-item-label" size="small" placeholder="${label}" autofocus></sl-input>
1235
+ <sl-input class="new-item-desc" size="small" placeholder="Description (optional)"></sl-input>
1236
+ <div class="row" style="margin-top:0.3rem;">
1237
+ <sl-button size="small" variant="primary" class="confirm-add-btn">Add</sl-button>
1238
+ <sl-button size="small" class="cancel-add-btn">Cancel</sl-button>
1239
+ </div>
1240
+ </div>
1241
+ `;
1242
+ container.insertBefore(newRow, addBtn);
1243
+ return;
1244
+ }
1245
+
1246
+ // Confirm add
1247
+ const confirmBtn = e.target.closest('.confirm-add-btn');
1248
+ if (confirmBtn) {
1249
+ const row = confirmBtn.closest('.topic-row');
1250
+ const nameInput = row.querySelector('.new-item-label');
1251
+ const descInput = row.querySelector('.new-item-desc');
1252
+ const name = nameInput.value.trim();
1253
+ if (!name) { nameInput.focus(); return; }
1254
+
1255
+ row.dataset.topic = name;
1256
+ row.innerHTML = `
1257
+ <span class="drag-handle">\u2807</span>
1258
+ <div class="topic-content">
1259
+ <div class="topic-header">
1260
+ <strong class="topic-label">${esc(name)}</strong>
1261
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1262
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1263
+ </div>
1264
+ <div class="topic-description" style="display:none;">
1265
+ <p class="topic-desc-text">${esc(descInput.value)}</p>
1266
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(descInput.value)}"></sl-input>
1267
+ </div>
1268
+ </div>
1269
+ `;
1270
+ return;
1271
+ }
1272
+
1273
+ // Cancel add
1274
+ const cancelBtn = e.target.closest('.cancel-add-btn');
1275
+ if (cancelBtn) {
1276
+ cancelBtn.closest('.topic-row').remove();
1277
+ return;
1278
+ }
1279
+ });
1280
+
1281
+ // Description edit via sl-change (Shoelace event, delegated)
1282
+ container.addEventListener('sl-change', (e) => {
1283
+ const input = e.target.closest('.topic-desc-edit');
1284
+ if (input) {
1285
+ const row = input.closest('.topic-row');
1286
+ const textEl = row.querySelector('.topic-desc-text');
1287
+ if (textEl) textEl.textContent = input.value || '';
1288
+ }
1289
+ });
1290
+ });
1291
+ }
1292
+
1098
1293
  function fillTierSelects() {
1099
1294
  const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1100
1295
  const tierSelect = document.getElementById('tier-select');
@@ -1102,21 +1297,23 @@ function fillTierSelects() {
1102
1297
  const newTierCopy = document.getElementById('new-tier-copy-from');
1103
1298
  const inviteTier = document.getElementById('invite-tier');
1104
1299
 
1105
- // Build options HTML for sl-select elements
1106
- const optionsHtml = tiers.map(tier =>
1107
- `<sl-option value="${esc(tier.id)}">${esc(tier.id)} (${esc(tier.name || tier.id)})</sl-option>`
1108
- ).join('');
1300
+ const optionsHtml = tiers.map(tier => {
1301
+ const emoji = TIER_EMOJIS[tier.id] || '\u{1F527}';
1302
+ return `<sl-option value="${esc(tier.id)}">${emoji} ${esc(tier.name || tier.id)}</sl-option>`;
1303
+ }).join('');
1109
1304
 
1110
1305
  tierSelect.innerHTML = optionsHtml;
1111
1306
  copyFrom.innerHTML = optionsHtml;
1112
1307
  inviteTier.innerHTML = optionsHtml;
1113
1308
  newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1114
1309
 
1115
- if (tiers.length > 0) {
1116
- tierSelect.value = tiers[0].id;
1117
- copyFrom.value = tiers[0].id;
1118
- inviteTier.value = tiers[0].id;
1119
- renderTierEditor(tiers[0].id);
1310
+ // A2A-41: default to 'public' — it's the base tier and most commonly edited
1311
+ const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
1312
+ if (defaultTier) {
1313
+ tierSelect.value = defaultTier;
1314
+ copyFrom.value = defaultTier;
1315
+ inviteTier.value = defaultTier;
1316
+ renderTierEditor(defaultTier);
1120
1317
  }
1121
1318
  }
1122
1319
 
@@ -1124,13 +1321,273 @@ function renderTierEditor(tierId) {
1124
1321
  const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
1125
1322
  if (!tier) return;
1126
1323
 
1127
- document.getElementById('tier-id').value = tier.id;
1128
1324
  document.getElementById('tier-name').value = tier.name || tier.id;
1129
1325
  document.getElementById('tier-description').value = tier.description || '';
1130
- document.getElementById('tier-disclosure').value = tier.disclosure || 'minimal';
1131
- document.getElementById('tier-tools').value = toLines(tier.allowed_tools || []);
1132
- document.getElementById('tier-topics').value = toLines(tier.topics || []);
1133
- document.getElementById('tier-goals').value = toLines(tier.goals || []);
1326
+ renderToolCheckboxes(tier.allowed_tools);
1327
+ renderTopicList(tier);
1328
+ renderGoalList(tier);
1329
+ renderTierWarnings(tier);
1330
+ renderTierColumns();
1331
+ }
1332
+
1333
+ // A2A-41: renders the three-column drag zone showing all standard tiers
1334
+ // side-by-side. Inherited topics shown as grayed-out non-draggable rows.
1335
+ // Custom tiers are not shown here — they don't have a defined inheritance
1336
+ // hierarchy. HTML5 drag-and-drop does NOT work on touch devices (mobile).
1337
+ function renderTierColumns() {
1338
+ const container = document.getElementById('tier-columns');
1339
+ if (!container) return;
1340
+ const tiers = state.settings?.tiers || [];
1341
+ const toggle = document.getElementById('show-drag-columns');
1342
+ container.style.display = toggle?.checked ? '' : 'none';
1343
+
1344
+ const html = TIER_ORDER.map(tierId => {
1345
+ const tier = tiers.find(t => t.id === tierId);
1346
+ if (!tier) return '';
1347
+
1348
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1349
+ const tierIdx = TIER_ORDER.indexOf(tierId);
1350
+
1351
+ // Inherited topics from lower tiers
1352
+ let inheritedRows = '';
1353
+ for (let i = 0; i < tierIdx; i++) {
1354
+ const lowerTier = tiers.find(t => t.id === TIER_ORDER[i]);
1355
+ if (!lowerTier) continue;
1356
+ const lowerTopics = lowerTier.manifest?.topics?.length
1357
+ ? lowerTier.manifest.topics
1358
+ : (lowerTier.topics || []).map(t => ({ topic: t, description: '' }));
1359
+ lowerTopics.forEach(t => {
1360
+ inheritedRows += `
1361
+ <div class="topic-row inherited" data-topic="${esc(t.topic)}" data-tier="${esc(TIER_ORDER[i])}">
1362
+ <div class="topic-content">
1363
+ <div class="topic-header">
1364
+ <strong class="topic-label">${esc(t.topic)}</strong>
1365
+ <span class="inherited-badge">from ${esc(TIER_ORDER[i])}</span>
1366
+ </div>
1367
+ </div>
1368
+ </div>`;
1369
+ });
1370
+ }
1371
+
1372
+ // Own topics — draggable
1373
+ const ownTopics = tier.manifest?.topics?.length
1374
+ ? tier.manifest.topics
1375
+ : (tier.topics || []).map(t => ({ topic: t, description: '' }));
1376
+ const ownRows = ownTopics.map(t => `
1377
+ <div class="topic-row" draggable="true" data-topic="${esc(t.topic)}" data-tier="${esc(tierId)}">
1378
+ <span class="drag-handle">\u2807</span>
1379
+ <div class="topic-content">
1380
+ <div class="topic-header">
1381
+ <strong class="topic-label">${esc(t.topic)}</strong>
1382
+ </div>
1383
+ </div>
1384
+ </div>
1385
+ `).join('');
1386
+
1387
+ return `
1388
+ <div class="tier-column" data-tier="${esc(tierId)}">
1389
+ <h4>${emoji} ${esc(tier.name || tierId)}</h4>
1390
+ <div class="tier-drop-zone" data-tier="${esc(tierId)}">
1391
+ ${inheritedRows}${ownRows}
1392
+ </div>
1393
+ </div>`;
1394
+ }).join('');
1395
+
1396
+ container.innerHTML = html;
1397
+ bindDragEvents();
1398
+ }
1399
+
1400
+ // A2A-41: HTML5 drag-and-drop handlers for moving topics between tier columns.
1401
+ // On drop, both tiers are saved via Promise.all() to prevent data loss if one
1402
+ // request fails. On error, state is reloaded from server to reset UI.
1403
+ function bindDragEvents() {
1404
+ const zones = document.querySelectorAll('.tier-drop-zone');
1405
+
1406
+ document.querySelectorAll('.tier-columns .topic-row[draggable="true"]').forEach(row => {
1407
+ row.addEventListener('dragstart', (e) => {
1408
+ e.dataTransfer.setData('application/json', JSON.stringify({
1409
+ topic: row.dataset.topic,
1410
+ sourceTier: row.dataset.tier
1411
+ }));
1412
+ row.classList.add('dragging');
1413
+ });
1414
+ row.addEventListener('dragend', () => row.classList.remove('dragging'));
1415
+ });
1416
+
1417
+ zones.forEach(zone => {
1418
+ zone.addEventListener('dragover', (e) => {
1419
+ e.preventDefault();
1420
+ zone.classList.add('drag-over');
1421
+ });
1422
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1423
+ zone.addEventListener('drop', async (e) => {
1424
+ e.preventDefault();
1425
+ zone.classList.remove('drag-over');
1426
+
1427
+ let data;
1428
+ try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1429
+ const { topic, sourceTier } = data;
1430
+ const targetTier = zone.dataset.tier;
1431
+
1432
+ if (!topic || !sourceTier || !targetTier || sourceTier === targetTier) return;
1433
+
1434
+ const sourceTierData = (state.settings?.tiers || []).find(t => t.id === sourceTier);
1435
+ const targetTierData = (state.settings?.tiers || []).find(t => t.id === targetTier);
1436
+ if (!sourceTierData || !targetTierData) return;
1437
+
1438
+ const sourceTopics = (sourceTierData.topics || []).filter(t => t !== topic);
1439
+ const sourceManifestTopics = (sourceTierData.manifest?.topics || []).filter(t => t.topic !== topic);
1440
+ const movedManifest = (sourceTierData.manifest?.topics || []).find(t => t.topic === topic);
1441
+ const targetTopics = [...(targetTierData.topics || []), topic];
1442
+ const targetManifestTopics = [...(targetTierData.manifest?.topics || []), movedManifest || { topic, description: '' }];
1443
+
1444
+ // A2A-41: save both tiers atomically with Promise.all to prevent
1445
+ // data loss if one request fails. On error, reload from server.
1446
+ try {
1447
+ await Promise.all([
1448
+ request(`/settings/tiers/${encodeURIComponent(sourceTier)}`, {
1449
+ method: 'PUT',
1450
+ body: JSON.stringify({
1451
+ topics: sourceTopics,
1452
+ manifest: { topics: sourceManifestTopics, objectives: sourceTierData.manifest?.objectives || [] }
1453
+ })
1454
+ }),
1455
+ request(`/settings/tiers/${encodeURIComponent(targetTier)}`, {
1456
+ method: 'PUT',
1457
+ body: JSON.stringify({
1458
+ topics: targetTopics,
1459
+ manifest: { topics: targetManifestTopics, objectives: targetTierData.manifest?.objectives || [] }
1460
+ })
1461
+ })
1462
+ ]);
1463
+ showNotice(`Moved "${topic}" from ${sourceTier} to ${targetTier}`);
1464
+ } catch (err) {
1465
+ showNotice(`Move failed: ${err.message}. Reloading...`);
1466
+ }
1467
+ await loadSettings();
1468
+ });
1469
+ });
1470
+ }
1471
+
1472
+ // A2A-41: contextual validation warnings for the currently selected tier.
1473
+ // Warns about empty tiers, dangerous tool grants, and inverted tier sizes.
1474
+ function renderTierWarnings(tier) {
1475
+ const container = document.getElementById('tier-warnings');
1476
+ if (!container) return;
1477
+ const warnings = [];
1478
+
1479
+ // A2A-41: use manifest OR flat topics (not both) to avoid double-counting.
1480
+ // Manifest is preferred when non-empty; flat array is the fallback.
1481
+ // NOTE: can't use || for this because empty arrays are truthy in JS.
1482
+ const mTopics = tier.manifest?.topics;
1483
+ const topicCount = (mTopics && mTopics.length > 0 ? mTopics : (tier.topics || [])).length;
1484
+ if (topicCount === 0) {
1485
+ warnings.push({ level: 'warn', text: "This tier has no topics \u2014 callers won't have conversation context." });
1486
+ }
1487
+
1488
+ if (tier.id === 'public' && (tier.allowed_tools || []).includes('Bash')) {
1489
+ warnings.push({ level: 'danger', text: 'Bash (full access) is granted to the public tier \u2014 any caller can execute commands.' });
1490
+ }
1491
+
1492
+ if (tier.id === 'family') {
1493
+ const allTiers = state.settings?.tiers || [];
1494
+ const friends = allTiers.find(t => t.id === 'friends');
1495
+ if (friends) {
1496
+ const mFam = tier.manifest?.topics;
1497
+ const familyOwn = (mFam && mFam.length > 0 ? mFam : (tier.topics || [])).length;
1498
+ const mFri = friends.manifest?.topics;
1499
+ const friendsOwn = (mFri && mFri.length > 0 ? mFri : (friends.topics || [])).length;
1500
+ if (familyOwn < friendsOwn) {
1501
+ warnings.push({ level: 'info', text: 'Family tier has fewer topics than Friends \u2014 usually Family is the most open tier.' });
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ container.innerHTML = warnings.map(w =>
1507
+ `<div class="tier-warning ${w.level}">${esc(w.text)}</div>`
1508
+ ).join('');
1509
+ }
1510
+
1511
+ // A2A-41: merges topics/goals/tools from the selected tier and all lower tiers,
1512
+ // mirroring the backend's getTopicsForTier() inheritance. Used by the preview dialog.
1513
+ function getPreviewData(tierId) {
1514
+ const selectedIndex = TIER_ORDER.indexOf(tierId);
1515
+ const tiers = state.settings?.tiers || [];
1516
+ const merged = { topics: [], objectives: [], tools: new Set(), do_not_discuss: [], never_disclose: [] };
1517
+
1518
+ // A2A-41: for custom tiers not in TIER_ORDER, show only own data.
1519
+ // No inheritance is applied because custom tiers have no defined hierarchy.
1520
+ if (selectedIndex < 0) {
1521
+ const t = tiers.find(t => t.id === tierId);
1522
+ if (t) {
1523
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: tierId }));
1524
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: tierId }));
1525
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1526
+ }
1527
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1528
+ return merged;
1529
+ }
1530
+
1531
+ for (let i = 0; i <= selectedIndex; i++) {
1532
+ const t = tiers.find(t => t.id === TIER_ORDER[i]);
1533
+ if (!t) continue;
1534
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: TIER_ORDER[i] }));
1535
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: TIER_ORDER[i] }));
1536
+ (t.manifest?.do_not_discuss || []).forEach(item => {
1537
+ if (!merged.do_not_discuss.includes(item)) merged.do_not_discuss.push(item);
1538
+ });
1539
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1540
+ }
1541
+
1542
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1543
+ return merged;
1544
+ }
1545
+
1546
+ // A2A-41: opens the caller preview dialog showing the merged effective view
1547
+ // for the selected tier. Helps the agent owner understand what a caller sees.
1548
+ function openCallerPreview() {
1549
+ const tierId = document.getElementById('tier-select').value;
1550
+ const data = getPreviewData(tierId);
1551
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1552
+ const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
1553
+
1554
+ const dialog = document.getElementById('preview-dialog');
1555
+ dialog.label = `\u{1F441} Caller Preview \u2014 ${emoji} ${tierName}`;
1556
+
1557
+ const topicsList = data.topics.length > 0
1558
+ ? data.topics.map(t => `<li><strong>${esc(t.topic)}</strong>${t.description ? ` \u2014 ${esc(t.description)}` : ''}</li>`).join('')
1559
+ : '<li><em>None configured</em></li>';
1560
+
1561
+ const goalsList = data.objectives.length > 0
1562
+ ? data.objectives.map(g => `<li><strong>${esc(g.objective || g.topic)}</strong>${g.description ? ` \u2014 ${esc(g.description)}` : ''}</li>`).join('')
1563
+ : '<li><em>None configured</em></li>';
1564
+
1565
+ const toolsList = data.tools.size > 0
1566
+ ? Array.from(data.tools).map(t => `<li><strong>${esc(t)}</strong>${TOOL_DESCRIPTIONS[t] ? ` \u2014 ${esc(TOOL_DESCRIPTIONS[t])}` : ''}</li>`).join('')
1567
+ : '<li><em>None configured</em></li>';
1568
+
1569
+ const dndList = data.do_not_discuss.length > 0
1570
+ ? data.do_not_discuss.map(d => `<li>${esc(typeof d === 'string' ? d : d.topic || '')}</li>`).join('')
1571
+ : '<li><em>None configured</em></li>';
1572
+
1573
+ const neverList = data.never_disclose.length > 0
1574
+ ? data.never_disclose.map(n => `<li>${esc(n)}</li>`).join('')
1575
+ : '<li><em>None configured</em></li>';
1576
+
1577
+ document.getElementById('preview-content').innerHTML = `
1578
+ <h4>Topics this caller can discuss:</h4>
1579
+ <ul>${topicsList}</ul>
1580
+ <h4>Goals:</h4>
1581
+ <ul>${goalsList}</ul>
1582
+ <h4>Tools available:</h4>
1583
+ <ul>${toolsList}</ul>
1584
+ <h4>Will not discuss:</h4>
1585
+ <ul>${dndList}</ul>
1586
+ <h4>Never disclosed (any tier):</h4>
1587
+ <ul>${neverList}</ul>
1588
+ `;
1589
+
1590
+ dialog.show();
1134
1591
  }
1135
1592
 
1136
1593
  function bindSettingsActions() {
@@ -1140,14 +1597,43 @@ function bindSettingsActions() {
1140
1597
 
1141
1598
  document.getElementById('tier-form').addEventListener('submit', async (e) => {
1142
1599
  e.preventDefault();
1143
- const tierId = document.getElementById('tier-id').value;
1600
+ const tierId = document.getElementById('tier-select').value;
1601
+
1602
+ // A2A-41: collect tools from checkboxes
1603
+ const toolCheckboxes = document.querySelectorAll('#tier-tools-list sl-checkbox');
1604
+ const allowed_tools = Array.from(toolCheckboxes)
1605
+ .filter(cb => cb.checked)
1606
+ .map(cb => cb.value);
1607
+
1608
+ // A2A-41: collect topics from row elements
1609
+ const topicRows = document.querySelectorAll('#tier-topics-list .topic-row[data-topic]');
1610
+ const topics = Array.from(topicRows).map(row => row.dataset.topic).filter(Boolean);
1611
+ const manifestTopics = Array.from(topicRows).map(row => ({
1612
+ topic: row.dataset.topic,
1613
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
1614
+ })).filter(t => t.topic);
1615
+
1616
+ // A2A-41: collect goals from row elements. IMPORTANT: use 'topic' key (NOT
1617
+ // 'objective') because parseTopicObjects() in dashboard.js:160 only reads
1618
+ // entry.topic. The semantic distinction 'objective' vs 'topic' is UI-only;
1619
+ // the storage layer uses {topic, description} uniformly for both.
1620
+ const goalRows = document.querySelectorAll('#tier-goals-list .topic-row[data-topic]');
1621
+ const goals = Array.from(goalRows).map(row => row.dataset.topic).filter(Boolean);
1622
+ const manifestObjectives = Array.from(goalRows).map(row => ({
1623
+ topic: row.dataset.topic,
1624
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
1625
+ })).filter(g => g.topic);
1626
+
1144
1627
  const body = {
1145
1628
  name: document.getElementById('tier-name').value,
1146
1629
  description: document.getElementById('tier-description').value,
1147
- disclosure: document.getElementById('tier-disclosure').value,
1148
- allowed_tools: fromLines(document.getElementById('tier-tools').value),
1149
- topics: fromLines(document.getElementById('tier-topics').value),
1150
- goals: fromLines(document.getElementById('tier-goals').value)
1630
+ allowed_tools,
1631
+ topics,
1632
+ goals,
1633
+ manifest: {
1634
+ topics: manifestTopics,
1635
+ objectives: manifestObjectives
1636
+ }
1151
1637
  };
1152
1638
  await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1153
1639
  method: 'PUT',
@@ -1158,7 +1644,7 @@ function bindSettingsActions() {
1158
1644
  });
1159
1645
 
1160
1646
  document.getElementById('copy-tier-btn').addEventListener('click', async () => {
1161
- const toTier = document.getElementById('tier-id').value;
1647
+ const toTier = document.getElementById('tier-select').value;
1162
1648
  const fromTier = document.getElementById('copy-from-tier').value;
1163
1649
  if (!toTier || !fromTier || toTier === fromTier) return;
1164
1650
  await request(`/settings/tiers/${encodeURIComponent(toTier)}/copy-from/${encodeURIComponent(fromTier)}`, {
@@ -1206,12 +1692,23 @@ function bindSettingsActions() {
1206
1692
  document.getElementById('tier-select').value = tierId;
1207
1693
  renderTierEditor(tierId);
1208
1694
  });
1695
+
1696
+ // A2A-41: toggle for three-column tier view
1697
+ document.getElementById('show-drag-columns')?.addEventListener('sl-change', () => {
1698
+ renderTierColumns();
1699
+ });
1700
+
1701
+ document.getElementById('preview-caller-btn')?.addEventListener('click', openCallerPreview);
1702
+ document.getElementById('preview-close-btn')?.addEventListener('click', () => {
1703
+ document.getElementById('preview-dialog').hide();
1704
+ });
1209
1705
  }
1210
1706
 
1211
1707
  async function loadSettings() {
1212
1708
  const payload = await request('/settings');
1213
1709
  state.settings = payload;
1214
1710
  fillTierSelects();
1711
+ renderTierColumns();
1215
1712
  document.getElementById('defaults-expiration').value = payload.defaults?.expiration || '7d';
1216
1713
  document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
1217
1714
  }
@@ -1581,7 +2078,7 @@ const tabLoaders = {
1581
2078
  contacts: loadContacts,
1582
2079
  calls: loadCalls,
1583
2080
  logs: () => { loadLogs(); loadLogStats(); },
1584
- settings: () => {},
2081
+ permissions: () => {},
1585
2082
  invites: loadInvites,
1586
2083
  };
1587
2084
 
@@ -1609,6 +2106,7 @@ async function bootstrap() {
1609
2106
  bindTabs();
1610
2107
  bindContactsActions();
1611
2108
  bindSettingsActions();
2109
+ bindItemListDelegation();
1612
2110
  bindCallbookActions();
1613
2111
  bindAutoUpdateActions();
1614
2112
  bindInviteActions();