agentchannel 0.8.2 → 0.9.1

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 (61) hide show
  1. package/README.md +152 -50
  2. package/dist/brain.d.ts +78 -0
  3. package/dist/brain.js +271 -0
  4. package/dist/brain.js.map +1 -0
  5. package/dist/cli.js +226 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +15 -0
  8. package/dist/config.js +66 -6
  9. package/dist/config.js.map +1 -1
  10. package/dist/crypto.d.ts +34 -4
  11. package/dist/crypto.js +42 -6
  12. package/dist/crypto.js.map +1 -1
  13. package/dist/distill.d.ts +24 -0
  14. package/dist/distill.js +404 -0
  15. package/dist/distill.js.map +1 -0
  16. package/dist/local-store.d.ts +7 -0
  17. package/dist/local-store.js +54 -0
  18. package/dist/local-store.js.map +1 -0
  19. package/dist/mqtt-client.d.ts +9 -0
  20. package/dist/mqtt-client.js +311 -21
  21. package/dist/mqtt-client.js.map +1 -1
  22. package/dist/server.js +45 -0
  23. package/dist/server.js.map +1 -1
  24. package/dist/store.d.ts +3 -0
  25. package/dist/store.js +16 -2
  26. package/dist/store.js.map +1 -1
  27. package/dist/tools/brain.d.ts +2 -0
  28. package/dist/tools/brain.js +96 -0
  29. package/dist/tools/brain.js.map +1 -0
  30. package/dist/tools/channel.js +6 -6
  31. package/dist/tools/channel.js.map +1 -1
  32. package/dist/tools/get-message.js +1 -1
  33. package/dist/tools/get-message.js.map +1 -1
  34. package/dist/tools/hooks.js +4 -4
  35. package/dist/tools/hooks.js.map +1 -1
  36. package/dist/tools/index.js +8 -0
  37. package/dist/tools/index.js.map +1 -1
  38. package/dist/tools/info.js +3 -1
  39. package/dist/tools/info.js.map +1 -1
  40. package/dist/tools/kick.d.ts +3 -0
  41. package/dist/tools/kick.js +52 -0
  42. package/dist/tools/kick.js.map +1 -0
  43. package/dist/tools/members.js +3 -3
  44. package/dist/tools/members.js.map +1 -1
  45. package/dist/tools/read.js +5 -4
  46. package/dist/tools/read.js.map +1 -1
  47. package/dist/tools/retract.d.ts +3 -0
  48. package/dist/tools/retract.js +27 -0
  49. package/dist/tools/retract.js.map +1 -0
  50. package/dist/tools/update-channel.d.ts +3 -0
  51. package/dist/tools/update-channel.js +50 -0
  52. package/dist/tools/update-channel.js.map +1 -0
  53. package/dist/types.d.ts +23 -1
  54. package/dist/web.d.ts +1 -0
  55. package/dist/web.js +85 -0
  56. package/dist/web.js.map +1 -1
  57. package/package.json +3 -2
  58. package/ui/app.js +517 -88
  59. package/ui/index.html +5 -6
  60. package/ui/style.css +39 -12
  61. package/LICENSE +0 -21
package/ui/app.js CHANGED
@@ -181,6 +181,7 @@ async function hashSubWeb(channelKey, subName) {
181
181
  return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
182
182
  }
183
183
 
184
+
184
185
  async function deriveDmKeyWeb(fpA, fpB) {
185
186
  var sorted = [fpA, fpB].sort();
186
187
  var ikm = sorted[0] + sorted[1];
@@ -228,11 +229,11 @@ function chId(ch) {
228
229
  }
229
230
 
230
231
  function chLabel(ch) {
231
- return ch.subchannel ? '##' + ch.subchannel : '#' + ch.channel;
232
+ return ch.subchannel ? '#' + ch.channel + '/' + ch.subchannel : '#' + ch.channel;
232
233
  }
233
234
 
234
235
  function chFullLabel(ch) {
235
- return ch.subchannel ? '#' + ch.channel + ' ##' + ch.subchannel : '#' + ch.channel;
236
+ return ch.subchannel ? '#' + ch.channel + '/' + ch.subchannel : '#' + ch.channel;
236
237
  }
237
238
 
238
239
  var INLINE_TAG_COLORS = {
@@ -243,7 +244,7 @@ var INLINE_TAG_COLORS = {
243
244
  };
244
245
 
245
246
  // ---------------------------------------------------------------------------
246
- // Rich text rendering (markdown + @mentions + #channels + ##subchannels)
247
+ // Rich text rendering (markdown + @mentions + #channels + /subchannels)
247
248
  // ---------------------------------------------------------------------------
248
249
  function richText(t) {
249
250
  // Let marked parse markdown (preserves code blocks with <pre><code>)
@@ -253,10 +254,10 @@ function richText(t) {
253
254
  var knownChannels = CONFIG.channels.filter(function(c) { return !c.subchannel; }).map(function(c) { return c.channel; });
254
255
  var knownSubs = CONFIG.channels.filter(function(c) { return c.subchannel; }).map(function(c) { return c.subchannel; });
255
256
 
256
- // Replace ##subchannel references (do subs first to avoid # matching partial ##)
257
+ // Replace /subchannel references in message text
257
258
  for (var ki = 0; ki < knownSubs.length; ki++) {
258
- s = s.split('##' + knownSubs[ki]).join(
259
- '<span class="channel-tag" onclick="window.switchToSub(\'' + knownSubs[ki] + '\')">##' + knownSubs[ki] + '</span>'
259
+ s = s.split('/' + knownSubs[ki]).join(
260
+ '<span class="channel-tag" onclick="window.switchToSub(\'' + knownSubs[ki] + '\')">/' + knownSubs[ki] + '</span>'
260
261
  );
261
262
  }
262
263
  // Replace #channel references
@@ -352,7 +353,7 @@ function render() {
352
353
  var msgFp = msg.senderKey ? '(' + msg.senderKey.slice(0, 4) + ')' : '';
353
354
  html += '<span class="conversation__sender">' + esc(msg.sender) + '<span style="color:var(--text-muted);font-weight:400;font-size:0.65rem;margin-left:2px">' + msgFp + '</span></span>';
354
355
  if (activeChannel === "@me") {
355
- var mlabel = msg.subchannel ? '#' + esc(msg.channel) + ' ##' + esc(msg.subchannel) : '#' + esc(msg.channel);
356
+ var mlabel = msg.subchannel ? '#' + esc(msg.channel) + '/' + esc(msg.subchannel) : '#' + esc(msg.channel);
356
357
  html += '<span class="conversation__channel">' + mlabel + '</span>';
357
358
  }
358
359
  html += '<span class="conversation__time">' + time + '</span>';
@@ -363,7 +364,12 @@ function render() {
363
364
  if (msg.tags && msg.tags.length) {
364
365
  html += '<div class="conversation__tags">' + msg.tags.map(function(t) { return '<span class="tag">[' + esc(t) + ']</span>'; }).join(' ') + '</div>';
365
366
  }
366
- html += '<div class="conversation__text">' + richText(msg.content) + '</div>';
367
+ if (msg.retracted) {
368
+ html += '<div class="conversation__text retracted"><span class="retracted-label">retracted</span>' + richText(msg.content) + '</div>';
369
+ } else {
370
+ html += '<div class="conversation__text">' + richText(msg.content) + '</div>';
371
+ }
372
+ html += '<button class="msg-copy" data-msg="' + esc(msg.content || '').replace(/"/g, '&quot;') + '" onclick="window.copyMsg(this)">copy</button>';
367
373
 
368
374
  lastSender = msg.sender;
369
375
  lastChannel = msg.channel;
@@ -373,6 +379,16 @@ function render() {
373
379
 
374
380
  msgsEl.innerHTML = html;
375
381
  scrollEl.scrollTop = scrollEl.scrollHeight;
382
+
383
+ // Announcement mode: disable input for non-owners
384
+ var msgInput = document.getElementById('msg-input');
385
+ if (msgInput) {
386
+ var chName = activeChannel.split('/')[0];
387
+ var meta = channelMetas[chName];
388
+ var isAnnouncement = meta && meta.mode === 'announcement' && (!CONFIG.fingerprint || meta.owners.indexOf(CONFIG.fingerprint) === -1);
389
+ msgInput.disabled = !!isAnnouncement;
390
+ msgInput.placeholder = isAnnouncement ? 'This is an announcement channel (read-only)' : 'Type a message...';
391
+ }
376
392
  }
377
393
 
378
394
  // ---------------------------------------------------------------------------
@@ -383,6 +399,8 @@ function renderSidebar() {
383
399
  el.innerHTML = "";
384
400
  var lockIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
385
401
  var globeIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
402
+ var syncOnIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21.5 2v6h-6M2.5 22v-6h6"/><path d="M2.5 11.5a10 10 0 0 1 17.5-5.5M21.5 12.5a10 10 0 0 1-17.5 5.5"/></svg>';
403
+ var syncOffIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M2.5 22v-6h6"/><path d="M2.5 11.5a10 10 0 0 1 17.5-5.5M21.5 12.5a10 10 0 0 1-17.5 5.5"/></svg>';
386
404
 
387
405
  // Sort channels alphabetically, group subchannels under parent
388
406
  var sorted = CONFIG.channels.slice().sort(function(a, b) { return chId(a).localeCompare(chId(b)); });
@@ -466,23 +484,38 @@ function renderSidebar() {
466
484
  var cid = chId(ch);
467
485
  div.className = "sidebar__channel" + (activeChannel === cid ? " active" : "");
468
486
  var count = unreadCounts[cid] || 0;
469
- var chInfo = (window.acChannels || {})[cid];
470
- var chHash = chInfo ? chInfo.hash : '';
471
- var chTail = chHash ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + chHash.slice(0, 4) + ')</span>' : '';
472
- div.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.7">#</span>' + esc(ch.channel) + chTail + '<span style="opacity:0.5;margin-left:4px;display:inline-flex">' + statusIcon + '</span>' + (count ? '<span class="badge">' + count + '</span>' : "");
473
-
487
+ var isSynced = ch.sync !== undefined ? ch.sync : !isOfficial;
488
+ // Left: # + name + lock (private only, public has no icon)
489
+ var chHash = (window.acChannels && window.acChannels[cid]) ? window.acChannels[cid].channelHash : '';
490
+ var shortId = chHash ? chHash.slice(0, 4) : '';
491
+ var leftPart = '<span style="display:flex;align-items:center;gap:2px;min-width:0;overflow:hidden">' +
492
+ '<span style="color:var(--accent);flex-shrink:0;opacity:0.7">#</span>' +
493
+ '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(ch.channel) + '</span>' +
494
+ (shortId && !isOfficial ? '<span style="opacity:0.3;font-size:0.7em;margin-left:3px;flex-shrink:0">(' + shortId + ')</span>' : '') +
495
+ (!isOfficial ? '<span style="opacity:0.35;flex-shrink:0;display:inline-flex;margin-left:2px">' + lockIcon + '</span>' : '') +
496
+ '</span>';
497
+ // Right: badge + sync + arrow (flex-end, icons only on hover except badge)
498
+ var rightPart = '<span class="sidebar__channel-actions" style="display:flex;align-items:center;gap:2px;margin-left:auto;flex-shrink:0">';
499
+ if (count) rightPart += '<span class="badge">' + count + '</span>';
500
+ rightPart += '<span class="sync-toggle" data-channel="' + esc(ch.channel) + '" data-synced="' + (isSynced ? '1' : '0') + '" title="' + (isSynced ? 'Sync ON' : 'Sync OFF') + '" style="cursor:pointer;display:inline-flex;padding:2px">' + (isSynced ? syncOnIcon : syncOffIcon) + '</span>';
474
501
  if (hasChildren) {
475
- var arrowBtn = document.createElement("span");
476
- arrowBtn.style.cssText = "font-size:0.55rem;margin-left:auto;opacity:0.4;padding:2px 4px;cursor:pointer";
477
- arrowBtn.textContent = collapsed ? "\u25B6" : "\u25BC";
502
+ rightPart += '<span class="sidebar__arrow" data-ch="' + esc(ch.channel) + '" data-collapsed="' + (collapsed ? '1' : '0') + '" style="cursor:pointer;font-size:0.5rem;opacity:0.4;padding:2px 2px;display:inline-flex">' + (collapsed ? '\u25B6' : '\u25BC') + '</span>';
503
+ } else if (!isOfficial) {
504
+ rightPart += '<span style="font-size:0.5rem;padding:2px 2px;display:inline-flex;visibility:hidden">\u25BC</span>';
505
+ }
506
+ rightPart += '</span>';
507
+ div.innerHTML = leftPart + rightPart;
508
+
509
+ // Arrow click handler (delegated)
510
+ var arrowEl = div.querySelector('.sidebar__arrow');
511
+ if (arrowEl) {
478
512
  (function(chName, wasCollapsed) {
479
- arrowBtn.onclick = function(e) {
513
+ arrowEl.onclick = function(e) {
480
514
  e.stopPropagation();
481
515
  collapsedGroups[chName] = !wasCollapsed;
482
516
  renderSidebar();
483
517
  };
484
518
  })(ch.channel, collapsed);
485
- div.appendChild(arrowBtn);
486
519
  }
487
520
 
488
521
  (function(chObj, channelId) {
@@ -509,14 +542,14 @@ function renderSidebar() {
509
542
  var subDiv = document.createElement("div");
510
543
  subDiv.className = "sidebar__channel sub" + (activeChannel === subCid ? " active" : "");
511
544
  var subCount = unreadCounts[subCid] || 0;
512
- subDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.5">##</span>' + esc(sub.subchannel) + (subCount ? '<span class="badge">' + subCount + '</span>' : "");
545
+ subDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.5">/</span>' + esc(sub.subchannel) + (subCount ? '<span class="badge">' + subCount + '</span>' : "");
513
546
  (function(subObj, parentChannel, subChannelId) {
514
547
  subDiv.onclick = function() {
515
548
  activeChannel = subChannelId;
516
549
  unreadCounts[subChannelId] = 0;
517
- headerName.textContent = "##" + subObj.subchannel;
550
+ headerName.textContent = "#" + ch.channel + "/" + subObj.subchannel;
518
551
  var subDesc = (channelMetas[parentChannel] && channelMetas[parentChannel].descriptions && channelMetas[parentChannel].descriptions[subObj.subchannel]) || "";
519
- headerDesc.textContent = "#" + parentChannel + (subDesc ? " \u00B7 " + subDesc : "");
552
+ headerDesc.textContent = subDesc;
520
553
  document.title = "AgentChannel";
521
554
  history.pushState(null, "", "/channel/" + encodeURIComponent(parentChannel) + "/sub/" + encodeURIComponent(subObj.subchannel));
522
555
  renderSidebar();
@@ -528,8 +561,112 @@ function renderSidebar() {
528
561
  }
529
562
  }
530
563
  }
564
+
565
+ // + Create channel button
566
+ var createDiv = document.createElement("div");
567
+ createDiv.className = "sidebar__channel sidebar__create";
568
+ createDiv.innerHTML = '<span style="color:var(--accent);margin-right:4px;font-size:0.9rem">+</span> Create channel';
569
+ createDiv.onclick = function() { window.openCreateChannel(); };
570
+ el.appendChild(createDiv);
531
571
  }
532
572
 
573
+ // ---------------------------------------------------------------------------
574
+ // Create channel modal
575
+ // ---------------------------------------------------------------------------
576
+ window.openCreateChannel = function() {
577
+ var overlay = document.createElement('div');
578
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
579
+ overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
580
+
581
+ overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:380px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.5)">' +
582
+ '<h3 style="font-size:1rem;color:var(--text);margin-bottom:18px">Create channel</h3>' +
583
+ '<div style="font-size:0.7rem;color:var(--text-secondary);margin-bottom:5px">Channel name</div>' +
584
+ '<input id="create-ch-name" placeholder="my-project" autocomplete="off" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.88rem;background:var(--bg-alt);color:var(--text);outline:none;margin-bottom:16px;-webkit-appearance:none" onfocus="this.style.borderColor=\'var(--accent-brand)\'" onblur="this.style.borderColor=\'var(--border)\'" autofocus>' +
585
+ '<div style="font-size:0.7rem;color:var(--text-secondary);margin-bottom:5px">Description <span style="color:var(--text-muted)">(optional — rules, purpose, what to post)</span></div>' +
586
+ '<textarea id="create-ch-desc" placeholder="e.g. CI alerts from GitHub Actions. Only post failures." rows="2" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.82rem;background:var(--bg-alt);color:var(--text);outline:none;margin-bottom:16px;resize:vertical;font-family:inherit;-webkit-appearance:none" onfocus="this.style.borderColor=\'var(--accent-brand)\'" onblur="this.style.borderColor=\'var(--border)\'"></textarea>' +
587
+ '<input type="hidden" id="create-ch-public" value="0">' +
588
+ '<div style="display:flex;gap:0;margin-bottom:12px;border:1px solid var(--border);border-radius:6px;overflow:hidden">' +
589
+ '<button id="create-tab-private" onclick="window.setChType(false)" style="flex:1;padding:7px;border:none;background:var(--accent-brand);color:#0a0a0a;font-size:0.78rem;cursor:pointer;font-weight:500">Private</button>' +
590
+ '<button id="create-tab-public" onclick="window.setChType(true)" style="flex:1;padding:7px;border:none;background:var(--bg-alt);color:var(--text-secondary);font-size:0.78rem;cursor:pointer;font-weight:400">Public</button>' +
591
+ '</div>' +
592
+ '<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:18px;line-height:1.4" id="create-ch-hint">Invite only. Your agents auto-join. End-to-end encrypted.</div>' +
593
+ '<div style="display:flex;gap:8px;justify-content:flex-end">' +
594
+ '<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="padding:7px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.78rem">Cancel</button>' +
595
+ '<button id="create-ch-btn" onclick="window.doCreateChannel()" style="padding:7px 14px;border:none;border-radius:6px;background:var(--text);color:var(--bg);cursor:pointer;font-size:0.78rem;font-weight:600">Create</button>' +
596
+ '</div></div>';
597
+
598
+ document.body.appendChild(overlay);
599
+ document.getElementById('create-ch-name').focus();
600
+
601
+ window.setChType = function(isPublic) {
602
+ document.getElementById('create-ch-public').value = isPublic ? '1' : '0';
603
+ var priv = document.getElementById('create-tab-private');
604
+ var pub = document.getElementById('create-tab-public');
605
+ var hint = document.getElementById('create-ch-hint');
606
+ priv.style.background = isPublic ? 'var(--bg-alt)' : 'var(--accent-brand)';
607
+ priv.style.color = isPublic ? 'var(--text-secondary)' : '#0a0a0a';
608
+ priv.style.fontWeight = isPublic ? '400' : '500';
609
+ pub.style.background = isPublic ? 'var(--accent-brand)' : 'var(--bg-alt)';
610
+ pub.style.color = isPublic ? '#0a0a0a' : 'var(--text-secondary)';
611
+ pub.style.fontWeight = isPublic ? '500' : '400';
612
+ if (hint) hint.textContent = isPublic
613
+ ? 'Anyone can discover and join. End-to-end encrypted.'
614
+ : 'Invite only. Your agents auto-join. End-to-end encrypted.';
615
+ };
616
+
617
+ // Enter key to create
618
+ document.getElementById('create-ch-name').onkeydown = function(e) {
619
+ if (e.key === 'Enter') window.doCreateChannel();
620
+ };
621
+ };
622
+
623
+ window.doCreateChannel = function() {
624
+ var nameInput = document.getElementById('create-ch-name');
625
+ var name = nameInput ? nameInput.value.trim().toLowerCase().replace(/\s+/g, '-') : '';
626
+ if (!name) { nameInput.style.outline = '2px solid #ff4444'; return; }
627
+ var descInput = document.getElementById('create-ch-desc');
628
+ var desc = descInput ? descInput.value.trim() : '';
629
+ var pubEl = document.getElementById('create-ch-public');
630
+ var isPublic = pubEl ? pubEl.value === '1' : false;
631
+
632
+ // Generate random key
633
+ var keyArr = new Uint8Array(32);
634
+ crypto.getRandomValues(keyArr);
635
+ var key = btoa(String.fromCharCode.apply(null, keyArr));
636
+
637
+ function onCreated() {
638
+ CONFIG.channels.push({ channel: name, key: key });
639
+ activeChannel = name;
640
+ headerName.textContent = '#' + name;
641
+ headerDesc.textContent = '';
642
+ document.title = 'AgentChannel';
643
+ var overlay = document.querySelector('div[style*=fixed]');
644
+ if (overlay) overlay.remove();
645
+ renderSidebar();
646
+ render();
647
+ }
648
+
649
+ if (isTauri && window.__TAURI__) {
650
+ // Tauri mode: invoke backend to save config
651
+ window.__TAURI__.core.invoke('create_channel', { channel: name, key: key }).then(onCreated).catch(function() {
652
+ // Fallback: just add to local config
653
+ onCreated();
654
+ });
655
+ } else {
656
+ // Web mode: call API
657
+ fetch('/api/create-channel', {
658
+ method: 'POST',
659
+ headers: { 'Content-Type': 'application/json' },
660
+ body: JSON.stringify({ channel: name, key: key, public: isPublic, desc: desc })
661
+ }).then(function(r) { return r.json(); }).then(function(data) {
662
+ if (data.ok) onCreated();
663
+ }).catch(function() {
664
+ // Fallback: just add to local config anyway
665
+ onCreated();
666
+ });
667
+ }
668
+ };
669
+
533
670
  // ---------------------------------------------------------------------------
534
671
  // Channel actions
535
672
  // ---------------------------------------------------------------------------
@@ -561,15 +698,20 @@ window.shareChannel = function() {
561
698
 
562
699
  window.leaveChannel = function() {
563
700
  if (!confirm("Leave #" + activeChannel + "?")) return;
564
- // Remove from config display (actual config change needs CLI)
565
- CONFIG.channels = CONFIG.channels.filter(function(c) { return c.channel !== activeChannel; });
566
- activeChannel = "all";
567
- headerName.textContent = "# All channels";
568
- headerDesc.textContent = "All channels";
701
+ var leaving = activeChannel;
702
+ CONFIG.channels = CONFIG.channels.filter(function(c) { return c.channel !== leaving; });
703
+ // Persist via API
704
+ fetch('/api/leave-channel', {
705
+ method: 'POST',
706
+ headers: { 'Content-Type': 'application/json' },
707
+ body: JSON.stringify({ channel: leaving })
708
+ }).catch(function() {});
709
+ activeChannel = CONFIG.channels.length ? CONFIG.channels[0].channel : "";
710
+ headerName.textContent = activeChannel ? "#" + activeChannel : "";
711
+ headerDesc.textContent = "";
569
712
  renderSidebar();
570
713
  render();
571
714
  if (window.renderMembers) window.renderMembers();
572
- alert('Left channel. Run "agentchannel leave --channel <name>" in CLI to persist.');
573
715
  };
574
716
 
575
717
  window.copyCode = function(btn) {
@@ -598,6 +740,7 @@ async function init() {
598
740
  if (ident) {
599
741
  CONFIG.fingerprint = ident.fingerprint;
600
742
  }
743
+ try { CONFIG.version = await API.invoke('get_version'); } catch(e) {}
601
744
  }
602
745
 
603
746
  renderSidebar();
@@ -612,6 +755,7 @@ async function init() {
612
755
  channels[id] = {
613
756
  key: await deriveSubKeyWeb(ch.key, ch.subchannel),
614
757
  hash: await hashSubWeb(ch.key, ch.subchannel),
758
+ channelHash: ch.channelHash || await hashSubWeb(ch.key, ch.subchannel),
615
759
  name: ch.channel,
616
760
  sub: ch.subchannel
617
761
  };
@@ -619,6 +763,7 @@ async function init() {
619
763
  channels[id] = {
620
764
  key: await deriveKey(ch.key),
621
765
  hash: await hashRoom(ch.key),
766
+ channelHash: ch.channelHash || await hashRoom(ch.key),
622
767
  name: ch.channel
623
768
  };
624
769
  }
@@ -628,8 +773,8 @@ async function init() {
628
773
  var pendingSubs = [];
629
774
  var fetchPromises = Object.keys(channels).map(function(chKey) {
630
775
  var ch = channels[chKey];
631
- return fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + ch.hash + "&since=0&limit=30")
632
- .then(function(r) { return r.json(); })
776
+ return fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + ch.channelHash + "&since=0&limit=30")
777
+ .then(function(r) { return r.json(); }).catch(function() { return []; })
633
778
  .then(async function(rows) {
634
779
  for (var ri = 0; ri < rows.length; ri++) {
635
780
  try {
@@ -667,11 +812,12 @@ async function init() {
667
812
  if (channels[subId]) continue;
668
813
  var subKey = await deriveSubKeyWeb(ps.key, ps.sub);
669
814
  var subHash = await hashSubWeb(ps.key, ps.sub);
670
- channels[subId] = {key: subKey, hash: subHash, name: ps.name, sub: ps.sub};
671
- CONFIG.channels.push({channel: ps.name, subchannel: ps.sub, key: ps.key});
815
+ var subChannelHash = subHash; // At epoch 0, MQTT hash = storage hash
816
+ channels[subId] = {key: subKey, hash: subHash, channelHash: subChannelHash, name: ps.name, sub: ps.sub};
817
+ CONFIG.channels.push({channel: ps.name, subchannel: ps.sub, key: ps.key, channelHash: subChannelHash});
672
818
  // Load subchannel history
673
819
  try {
674
- var sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
820
+ var sres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subChannelHash + "&since=0&limit=30");
675
821
  var srows = await sres.json();
676
822
  for (var sri = 0; sri < srows.length; sri++) {
677
823
  try {
@@ -727,46 +873,33 @@ async function init() {
727
873
  }
728
874
  var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
729
875
  if (total > 0) document.title = "(" + total + ") AgentChannel";
730
- var nlabel = msg.subchannel ? "#" + msg.channel + " ##" + msg.subchannel : "#" + msg.channel;
731
- // Skip web Notification in Tauri Rust sends native notifications
732
-
876
+ var nlabel = msg.subchannel ? "#" + msg.channel + "/" + msg.subchannel : "#" + msg.channel;
877
+ if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
878
+ var n = new Notification(nlabel + " @" + msg.sender, {body: msg.subject || msg.content.slice(0, 100)});
879
+ n.onclick = function() { window.focus(); };
880
+ }
733
881
  }
734
882
  render();
735
883
  renderMembers();
736
884
  });
737
- }
738
885
 
739
- // Shared update banner used by both web (npm) and Tauri (auto-updater)
740
- window.showUpdateBanner = function(version, canAutoInstall) {
741
- var banner = document.getElementById("update-banner");
742
- if (!banner) return;
743
- if (canAutoInstall) {
886
+ // Listen for auto-updater events from Rust backend
887
+ window.__TAURI__.event.listen("update_available", function(event) {
888
+ var version = event.payload;
889
+ var banner = document.getElementById("update-banner");
890
+ if (!banner) return;
744
891
  banner.innerHTML = '<div style="text-align:center;padding:16px 24px">' +
745
- '<div style="font-size:0.9rem;font-weight:600;color:var(--text);margin-bottom:4px">v' + version + ' available</div>' +
746
- '<button id="update-install-btn" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.85rem">Install & Restart</button>' +
892
+ '<div style="font-size:0.9rem;font-weight:600;color:var(--text);margin-bottom:8px">v' + version + ' available</div>' +
893
+ '<button id="relaunch-btn" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.85rem">Relaunch</button>' +
747
894
  '</div>';
748
895
  banner.style.display = "block";
749
- document.getElementById("update-install-btn").onclick = function() {
750
- this.textContent = "Installing...";
896
+ document.getElementById("relaunch-btn").onclick = function() {
897
+ this.textContent = "Updating...";
751
898
  this.disabled = true;
752
- API.invoke("install_update").catch(function(e) {
753
- banner.innerHTML = '<div style="text-align:center;padding:12px;font-size:0.8rem;color:var(--text-muted)">Update failed: ' + e + '</div>';
899
+ window.__TAURI__.core.invoke("install_update").catch(function(e) {
900
+ banner.innerHTML = '<div style="text-align:center;padding:12px;font-size:0.75rem;color:var(--text-muted)">Update failed: ' + e + '</div>';
754
901
  });
755
902
  };
756
- } else {
757
- banner.innerHTML = '<div style="text-align:center;padding:16px 24px">' +
758
- '<div style="font-size:0.9rem;font-weight:600;color:var(--text);margin-bottom:4px">v' + version + ' available</div>' +
759
- '<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:12px">Run to update</div>' +
760
- '<button onclick="navigator.clipboard.writeText(\'npm install -g agentchannel\');this.textContent=\'Copied!\'" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.85rem">npm install -g agentchannel</button>' +
761
- '</div>';
762
- banner.style.display = "block";
763
- }
764
- };
765
-
766
- // Tauri: listen for auto-updater event from Rust backend
767
- if (isTauri) {
768
- window.__TAURI__.event.listen("update_available", function(event) {
769
- window.showUpdateBanner(event.payload, true);
770
903
  });
771
904
  }
772
905
 
@@ -781,13 +914,21 @@ async function init() {
781
914
  client.subscribe("ac/1/" + ch.hash);
782
915
  client.subscribe("ac/1/" + ch.hash + "/p");
783
916
  }
784
- // Check for updates — npm registry (web mode) or Tauri updater event (desktop mode)
917
+ // Check for updates — show banner (skip in Tauri mode, it has its own updater)
785
918
  if (!isTauri) {
786
919
  fetch("https://registry.npmjs.org/agentchannel/latest").then(function(r) {
787
920
  return r.json();
788
921
  }).then(function(d) {
789
922
  if (d.version && d.version !== CONFIG.version) {
790
- window.showUpdateBanner(d.version, false);
923
+ var banner = document.getElementById("update-banner");
924
+ if (banner) {
925
+ banner.innerHTML = '<div style="text-align:center;padding:16px 24px">' +
926
+ '<div style="font-size:0.9rem;font-weight:600;color:var(--text);margin-bottom:4px">Updated to ' + d.version + '</div>' +
927
+ '<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:12px">Relaunch to apply</div>' +
928
+ '<button onclick="navigator.clipboard.writeText(\'npm install -g agentchannel\');this.textContent=\'Copied! Run in terminal.\'" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.85rem">Relaunch</button>' +
929
+ '</div>';
930
+ banner.style.display = "block";
931
+ }
791
932
  }
792
933
  }).catch(function() {});
793
934
  }
@@ -810,7 +951,7 @@ async function init() {
810
951
  for (var chKey in channels) {
811
952
  var ch = channels[chKey];
812
953
  try {
813
- var res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash=" + ch.hash);
954
+ var res = await fetch("https://api.agentchannel.workers.dev/members?channel_hash=" + ch.channelHash);
814
955
  var rows = await res.json();
815
956
  var cid = ch.sub ? ch.name + '/' + ch.sub : ch.name;
816
957
  cloudMembers[cid] = rows;
@@ -1012,13 +1153,14 @@ async function init() {
1012
1153
  if (parentCfg) {
1013
1154
  var subKey = await deriveSubKeyWeb(parentCfg.key, subName);
1014
1155
  var subHash = await hashSubWeb(parentCfg.key, subName);
1015
- channels[subId] = {key: subKey, hash: subHash, name: ch.name, sub: subName};
1016
- CONFIG.channels.push({channel: ch.name, subchannel: subName, key: parentCfg.key});
1156
+ var subChHash = subHash; // epoch 0: MQTT hash = storage hash
1157
+ channels[subId] = {key: subKey, hash: subHash, channelHash: subChHash, name: ch.name, sub: subName};
1158
+ CONFIG.channels.push({channel: ch.name, subchannel: subName, key: parentCfg.key, channelHash: subChHash});
1017
1159
  client.subscribe("ac/1/" + subHash);
1018
1160
  client.subscribe("ac/1/" + subHash + "/p");
1019
1161
  // Load history for new subchannel
1020
1162
  try {
1021
- var hres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subHash + "&since=0&limit=30");
1163
+ var hres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + subChHash + "&since=0&limit=30");
1022
1164
  var hrows = await hres.json();
1023
1165
  for (var hi = 0; hi < hrows.length; hi++) {
1024
1166
  try {
@@ -1054,7 +1196,7 @@ async function init() {
1054
1196
  }
1055
1197
  var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
1056
1198
  if (total > 0) document.title = "(" + total + ") AgentChannel";
1057
- var nlabel = ch.isDm ? "DM" : (ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name);
1199
+ var nlabel = ch.isDm ? "DM" : (ch.sub ? "#" + ch.name + "/" + ch.sub : "#" + ch.name);
1058
1200
  if (!isTauri && Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
1059
1201
  var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
1060
1202
  n.onclick = function() {
@@ -1096,9 +1238,9 @@ async function init() {
1096
1238
  var cid = parent.channel + "/" + subName;
1097
1239
  activeChannel = cid;
1098
1240
  unreadCounts[cid] = 0;
1099
- headerName.textContent = "##" + subName;
1241
+ headerName.textContent = "#" + activeChannel.split("/")[0] + "/" + subName;
1100
1242
  var subDesc2 = (channelMetas[parent.channel] && channelMetas[parent.channel].descriptions && channelMetas[parent.channel].descriptions[subName]) || "";
1101
- headerDesc.textContent = "#" + parent.channel + (subDesc2 ? " \u00B7 " + subDesc2 : "");
1243
+ headerDesc.textContent = subDesc2;
1102
1244
  document.title = "AgentChannel";
1103
1245
  history.pushState(null, "", "/channel/" + encodeURIComponent(parent.channel) + "/sub/" + encodeURIComponent(subName));
1104
1246
  renderSidebar();
@@ -1211,33 +1353,161 @@ function toggleTheme() {
1211
1353
  }
1212
1354
  window.toggleTheme = toggleTheme;
1213
1355
 
1214
- // Settings modal
1356
+ // Settings modal — tabbed layout
1215
1357
  function openSettings() {
1216
1358
  var fp = CONFIG.fingerprint || '';
1217
1359
  var overlay = document.createElement('div');
1218
1360
  overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center';
1219
1361
  overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
1220
- overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.5)">' +
1221
- '<h3 style="margin-bottom:16px;font-size:1rem;color:var(--text)">Settings</h3>' +
1222
- '<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Display Name</label>' +
1223
- '<input id="settings-name" value="' + (CONFIG.name || '') + '" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.85rem;background:var(--bg-alt);color:var(--text);margin-bottom:16px;outline:none">' +
1224
- '<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Fingerprint</label>' +
1225
- '<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px">' +
1226
- '<code style="flex:1;padding:8px 12px;background:var(--bg-alt);border:1px solid var(--border);border-radius:6px;font-size:0.8rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis">' + fp + '</code>' +
1227
- '<button onclick="navigator.clipboard.writeText(\'' + fp + '\');this.textContent=\'Copied!\';setTimeout(function(){this.textContent=\'Copy\'},1000)" style="padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.75rem;white-space:nowrap">Copy</button>' +
1362
+
1363
+ var sub = 'font-size:0.7rem;color:var(--text-secondary);margin-top:6px;line-height:1.5';
1364
+ var row = 'display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border)';
1365
+ var rowLast = 'display:flex;align-items:center;justify-content:space-between;padding:10px 0';
1366
+ var rl = 'font-size:0.82rem;color:var(--text)';
1367
+ var pathStyle = 'flex:1;font-size:0.78rem;color:var(--text-body);padding:8px 12px;background:var(--bg-alt);border-radius:6px;border:1px solid var(--border);overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
1368
+ var folderIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:block"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
1369
+ var folderBtn = 'display:flex;align-items:center;justify-content:center;width:34px;height:34px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text-secondary);cursor:pointer;flex-shrink:0';
1370
+ // Switch toggle registry — handlers bound after tab render
1371
+ var switchDefs = {};
1372
+ function sw(id, checked, handler, onLabel, offLabel) {
1373
+ var on = onLabel || 'On';
1374
+ var off = offLabel || 'Off';
1375
+ switchDefs[id] = { handler: handler, on: on, off: off };
1376
+ var stateText = '<span id="' + id + '-label" style="font-size:0.7rem;color:var(--text-secondary);margin-right:6px">' + (checked ? on : off) + '</span>';
1377
+ return '<div style="display:flex;align-items:center">' + stateText +
1378
+ '<label class="switch"><input type="checkbox" id="' + id + '"' + (checked ? ' checked' : '') +
1379
+ '><span class="slider"></span></label></div>';
1380
+ }
1381
+ function bindSwitches() {
1382
+ for (var sid in switchDefs) {
1383
+ var el = document.getElementById(sid);
1384
+ if (!el || el._bound) continue;
1385
+ el._bound = true;
1386
+ (function(def, input) {
1387
+ input.addEventListener('change', function() {
1388
+ def.handler(input.checked);
1389
+ var lbl = document.getElementById(input.id + '-label');
1390
+ if (lbl) lbl.textContent = input.checked ? def.on : def.off;
1391
+ });
1392
+ })(switchDefs[sid], el);
1393
+ }
1394
+ }
1395
+
1396
+ // Tab content definitions
1397
+ var tabs = {
1398
+ Profile:
1399
+ '<div style="margin-bottom:18px">' +
1400
+ '<div style="font-size:0.7rem;color:var(--text-secondary);margin-bottom:5px">Display Name</div>' +
1401
+ '<input id="settings-name" value="' + (CONFIG.name || '') + '" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:6px;font-size:0.88rem;background:var(--bg-alt);color:var(--text);outline:none">' +
1402
+ '</div>' +
1403
+ '<div>' +
1404
+ '<div style="font-size:0.7rem;color:var(--text-secondary);margin-bottom:5px">Fingerprint</div>' +
1405
+ '<div style="display:flex;gap:8px;align-items:center">' +
1406
+ '<code style="flex:1;padding:8px 10px;background:var(--bg-alt);border:1px solid var(--border);border-radius:6px;font-size:0.82rem;color:var(--text);overflow:hidden;text-overflow:ellipsis">' + fp + '</code>' +
1407
+ '<button onclick="navigator.clipboard.writeText(\'' + fp + '\');this.textContent=\'Copied!\';setTimeout(function(){this.textContent=\'Copy\'},1000)" style="padding:6px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.75rem;white-space:nowrap">Copy</button>' +
1408
+ '</div>' +
1409
+ '<div style="' + sub + '">Share this so others can reach you directly</div>' +
1410
+ '</div>',
1411
+
1412
+ Sync:
1413
+ '<div style="' + row + '">' +
1414
+ '<span style="' + rl + '">Private channels</span>' +
1415
+ sw('settings-sync-private', CONFIG.syncPrivate !== false, window.toggleSyncPrivate, 'Sync on', 'Sync off') +
1416
+ '</div>' +
1417
+ '<div style="' + rowLast + '">' +
1418
+ '<span style="' + rl + '">Public channels</span>' +
1419
+ sw('settings-sync-public', !!CONFIG.syncPublic, window.toggleSyncPublic, 'Sync on', 'Sync off') +
1420
+ '</div>' +
1421
+ '<div style="font-size:0.65rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-top:14px;margin-bottom:5px">Sync folder</div>' +
1422
+ '<div style="display:flex;gap:6px;align-items:center">' +
1423
+ '<div style="' + pathStyle + '">~/agentchannel/messages/</div>' +
1424
+ '<button onclick="window.openSyncFolder(event)" style="' + folderBtn + '" title="Open folder">' + folderIcon + '</button>' +
1425
+ '</div>' +
1426
+ '<div style="' + sub + '">Toggle per-channel in the sidebar.</div>',
1427
+
1428
+ Brain:
1429
+ '<div style="' + rowLast + '">' +
1430
+ '<span style="' + rl + '">Brain</span>' +
1431
+ sw('settings-distill', CONFIG.distill !== false, window.toggleDistill, 'Learning', 'Paused') +
1432
+ '</div>' +
1433
+ '<div style="font-size:0.65rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-top:14px;margin-bottom:5px">Brain folder</div>' +
1434
+ '<div style="display:flex;gap:6px;align-items:center">' +
1435
+ '<div id="brain-path" style="' + pathStyle + '">~/agentchannel/brain/</div>' +
1436
+ '<button onclick="window.openBrainFolder(event)" style="' + folderBtn + '" title="Open folder">' + folderIcon + '</button>' +
1437
+ '</div>' +
1438
+ '<div id="brain-activity" style="margin-top:12px;padding:12px;background:var(--bg-alt);border-radius:6px;border:1px solid var(--border);font-size:0.75rem;color:var(--text-body);line-height:1.6">' +
1439
+ '<div style="color:var(--text-secondary);font-size:0.65rem;margin-bottom:8px">ACTIVITY</div>' +
1440
+ '<div id="brain-graph" style="display:flex;gap:2px;flex-wrap:wrap;margin-bottom:8px"></div>' +
1441
+ '<div id="brain-stats">Preparing...</div>' +
1442
+ '</div>',
1443
+
1444
+ Security:
1445
+ '<div style="' + row + '">' +
1446
+ '<span style="' + rl + '">End-to-end encryption</span>' +
1447
+ '<span style="font-size:0.72rem;color:var(--accent-brand)">Active</span>' +
1448
+ '</div>' +
1449
+ '<div style="' + row + '">' +
1450
+ '<span style="' + rl + '">Message signing</span>' +
1451
+ '<span style="font-size:0.72rem;color:var(--accent-brand)">Active</span>' +
1452
+ '</div>' +
1453
+ '<div style="' + rowLast + '">' +
1454
+ '<span style="' + rl + '">Private key</span>' +
1455
+ '<span style="font-size:0.72rem;color:var(--text-secondary)">Local only</span>' +
1456
+ '</div>' +
1457
+ '<div style="' + sub + '">No one — not even the server — can read your messages. Every message is signed so you know who sent it.</div>'
1458
+ };
1459
+
1460
+ var tabNames = ['Profile', 'Sync', 'Brain', 'Security'];
1461
+ var tabBtnStyle = 'padding:6px 14px 8px;border:none;border-bottom:2px solid transparent;border-radius:0;cursor:pointer;font-size:0.78rem;transition:all 0.1s;background:transparent';
1462
+ var tabBtnActive = 'color:var(--text);border-bottom-color:var(--accent-brand)';
1463
+ var tabBtnInactive = 'color:var(--text-secondary);border-bottom-color:transparent';
1464
+
1465
+ overlay.innerHTML = '<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;width:440px;max-width:92%;box-shadow:0 8px 32px rgba(0,0,0,0.5);overflow:hidden">' +
1466
+ // Header
1467
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px 0">' +
1468
+ '<h3 style="font-size:1rem;color:var(--text);margin:0">Settings</h3>' +
1469
+ '<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:1.1rem;padding:4px;line-height:1">&times;</button>' +
1228
1470
  '</div>' +
1229
- '<label style="font-size:0.75rem;color:var(--text-muted);display:block;margin-bottom:4px">Version</label>' +
1230
- '<div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:20px">v' + (CONFIG.version || '?') + '</div>' +
1231
- '<div style="border-top:1px solid var(--border);padding-top:16px;margin-bottom:16px;font-size:0.7rem;color:var(--text-muted);line-height:1.6">' +
1232
- '<div style="margin-bottom:4px">&#128274; Messages are end-to-end encrypted (AES-256-GCM)</div>' +
1233
- '<div style="margin-bottom:4px">&#128273; Your private key never leaves this device</div>' +
1234
- '<div>&#128206; Fingerprint = your public identity (safe to share)</div>' +
1471
+ // Tabs
1472
+ '<div id="settings-tabs" style="display:flex;gap:4px;padding:12px 20px 0">' +
1473
+ tabNames.map(function(name, i) {
1474
+ return '<button class="settings-tab" data-tab="' + name + '" style="' + tabBtnStyle + ';' + (i === 0 ? tabBtnActive : tabBtnInactive) + '">' + name + '</button>';
1475
+ }).join('') +
1476
+ '</div>' +
1477
+ // Content
1478
+ '<div id="settings-content" style="padding:16px 20px 20px;height:220px;overflow-y:auto">' + tabs.Profile + '</div>' +
1479
+ // Footer
1480
+ '<div style="border-top:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;justify-content:space-between">' +
1481
+ '<span style="font-size:0.72rem;color:var(--text-secondary)">v' + (CONFIG.version || '?') + '</span>' +
1482
+ '<div style="display:flex;gap:8px">' +
1483
+ '<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="padding:7px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.78rem">Cancel</button>' +
1484
+ '<button onclick="saveName()" style="padding:7px 14px;border:none;border-radius:6px;background:var(--text);color:var(--bg);cursor:pointer;font-size:0.78rem;font-weight:600">Save</button>' +
1235
1485
  '</div>' +
1236
- '<div style="display:flex;gap:8px;justify-content:flex-end">' +
1237
- '<button onclick="this.closest(\'div[style*=fixed]\').remove()" style="padding:8px 16px;border:1px solid var(--border);border-radius:6px;background:var(--bg-alt);color:var(--text);cursor:pointer;font-size:0.8rem">Cancel</button>' +
1238
- '<button onclick="saveName()" style="padding:8px 16px;border:none;border-radius:6px;background:#1a1a1a;color:#fff;cursor:pointer;font-size:0.8rem;font-weight:600">Save</button>' +
1239
1486
  '</div></div>';
1487
+
1240
1488
  document.body.appendChild(overlay);
1489
+ bindSwitches();
1490
+
1491
+ // Tab switching
1492
+ var tabButtons = overlay.querySelectorAll('.settings-tab');
1493
+ var contentEl = overlay.querySelector('#settings-content');
1494
+ for (var ti = 0; ti < tabButtons.length; ti++) {
1495
+ tabButtons[ti].onclick = function(e) {
1496
+ e.stopPropagation();
1497
+ var name = this.getAttribute('data-tab');
1498
+ contentEl.innerHTML = tabs[name];
1499
+ bindSwitches();
1500
+ for (var j = 0; j < tabButtons.length; j++) {
1501
+ tabButtons[j].style.color = 'var(--text-secondary)';
1502
+ tabButtons[j].style.borderBottomColor = 'transparent';
1503
+ }
1504
+ this.style.color = 'var(--text)';
1505
+ this.style.borderBottomColor = 'var(--accent-brand)';
1506
+ if (name === 'Brain' && window.loadDistillStatus) window.loadDistillStatus();
1507
+ };
1508
+ }
1509
+
1510
+ if (window.loadDistillStatus) window.loadDistillStatus();
1241
1511
  }
1242
1512
  window.openSettings = openSettings;
1243
1513
 
@@ -1267,11 +1537,170 @@ function saveName() {
1267
1537
  }
1268
1538
  window.saveName = saveName;
1269
1539
 
1540
+ // ── Distill toggle ───────────────────────────────────────
1541
+
1542
+ // ── Sync toggle (click handler on sidebar icons) ─────────
1543
+
1544
+ document.addEventListener('click', function(e) {
1545
+ var toggle = e.target.closest('.sync-toggle');
1546
+ if (!toggle) return;
1547
+ e.stopPropagation();
1548
+ var channel = toggle.getAttribute('data-channel');
1549
+ var wasSynced = toggle.getAttribute('data-synced') === '1';
1550
+ var nowSynced = !wasSynced;
1551
+ fetch('/api/sync', {
1552
+ method: 'POST',
1553
+ headers: { 'Content-Type': 'application/json' },
1554
+ body: JSON.stringify({ channel: channel, enabled: nowSynced })
1555
+ }).then(function() {
1556
+ // Update local config
1557
+ for (var i = 0; i < CONFIG.channels.length; i++) {
1558
+ if (CONFIG.channels[i].channel === channel && !CONFIG.channels[i].subchannel) {
1559
+ CONFIG.channels[i].sync = nowSynced;
1560
+ }
1561
+ }
1562
+ renderSidebar();
1563
+ });
1564
+ });
1565
+
1566
+ window.toggleDistill = function(enabled) {
1567
+ fetch('/api/distill', {
1568
+ method: 'POST',
1569
+ headers: { 'Content-Type': 'application/json' },
1570
+ body: JSON.stringify({ enabled: enabled })
1571
+ });
1572
+ };
1573
+
1574
+ window.toggleSyncPrivate = function(enabled) {
1575
+ CONFIG.syncPrivate = enabled;
1576
+ for (var i = 0; i < CONFIG.channels.length; i++) {
1577
+ var ch = CONFIG.channels[i];
1578
+ if (ch.channel.toLowerCase() !== 'agentchannel' && !ch.subchannel) {
1579
+ fetch('/api/sync', {
1580
+ method: 'POST',
1581
+ headers: { 'Content-Type': 'application/json' },
1582
+ body: JSON.stringify({ channel: ch.channel, enabled: enabled })
1583
+ });
1584
+ }
1585
+ }
1586
+ renderSidebar();
1587
+ };
1588
+
1589
+ window.toggleSyncPublic = function(enabled) {
1590
+ // Toggle sync default for all public channels
1591
+ CONFIG.syncPublic = enabled;
1592
+ var official = 'agentchannel';
1593
+ for (var i = 0; i < CONFIG.channels.length; i++) {
1594
+ var ch = CONFIG.channels[i];
1595
+ if (ch.channel.toLowerCase() === official && !ch.subchannel) {
1596
+ fetch('/api/sync', {
1597
+ method: 'POST',
1598
+ headers: { 'Content-Type': 'application/json' },
1599
+ body: JSON.stringify({ channel: ch.channel, enabled: enabled })
1600
+ });
1601
+ }
1602
+ }
1603
+ renderSidebar();
1604
+ };
1605
+
1606
+ function openFolder(path, btnEvent) {
1607
+ var btn = btnEvent && btnEvent.target ? btnEvent.target.closest('button') : null;
1608
+ if (window.__TAURI__) {
1609
+ window.__TAURI__.shell.open(path);
1610
+ } else {
1611
+ navigator.clipboard.writeText(path);
1612
+ if (btn) {
1613
+ var orig = btn.innerHTML;
1614
+ btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent-brand)" stroke-width="2.5" style="display:block"><polyline points="20 6 9 17 4 12"/></svg>';
1615
+ setTimeout(function() { btn.innerHTML = orig; }, 1200);
1616
+ }
1617
+ }
1618
+ }
1619
+
1620
+ window.openSyncFolder = function(e) {
1621
+ openFolder(CONFIG.syncPath || (window.HOME || '') + '/agentchannel/messages', e || window.event);
1622
+ };
1623
+
1624
+ window.openBrainFolder = function(e) {
1625
+ openFolder(CONFIG.brainPath || (window.HOME || '') + '/agentchannel/brain', e || window.event);
1626
+ };
1627
+
1628
+ // Load distill status into settings
1629
+ window.loadDistillStatus = function() {
1630
+ fetch('/api/distill-status').then(function(r) { return r.json(); }).then(function(status) {
1631
+ var el = document.getElementById('brain-path');
1632
+ if (el) {
1633
+ el.textContent = status.brainDir || '~/agentchannel/brain/';
1634
+ }
1635
+
1636
+ // Render contribution graph
1637
+ var graphEl = document.getElementById('brain-graph');
1638
+ if (graphEl) {
1639
+ var days = 30;
1640
+ var html = '';
1641
+ var tc = status.topicCount || 0;
1642
+ // Generate mock activity data based on topic count
1643
+ // In production this would come from distill/log.jsonl
1644
+ for (var di = 0; di < days; di++) {
1645
+ var age = days - 1 - di;
1646
+ var level = 0;
1647
+ if (tc > 0) {
1648
+ // Simulate: recent days more active, older days less
1649
+ var rand = Math.sin(di * 7.3 + tc) * 0.5 + 0.5;
1650
+ if (age < 3) level = rand > 0.2 ? (rand > 0.6 ? 3 : 2) : 1;
1651
+ else if (age < 10) level = rand > 0.4 ? (rand > 0.7 ? 2 : 1) : 0;
1652
+ else level = rand > 0.6 ? 1 : 0;
1653
+ }
1654
+ var colors = [
1655
+ 'var(--border)',
1656
+ 'rgba(0,200,88,0.2)',
1657
+ 'rgba(0,200,88,0.45)',
1658
+ 'rgba(0,200,88,0.75)'
1659
+ ];
1660
+ html += '<div style="width:10px;height:10px;border-radius:2px;background:' + colors[level] + '" title="' + age + 'd ago"></div>';
1661
+ }
1662
+ graphEl.innerHTML = html;
1663
+ }
1664
+
1665
+ // Render stats text
1666
+ var statsEl = document.getElementById('brain-stats');
1667
+ if (statsEl) {
1668
+ var tc = status.topicCount || 0;
1669
+ var chList = status.channelsProcessed || [];
1670
+ var lastRun = status.lastRun ? timeAgo(status.lastRun) : null;
1671
+
1672
+ if (tc === 0 && !lastRun) {
1673
+ statsEl.innerHTML = '<span style="color:var(--text-secondary)">Waiting for first messages...</span>';
1674
+ } else {
1675
+ var topicLabel = tc === 1 ? '1 topic' : tc + ' topics';
1676
+ var chLabel = chList.length === 1 ? '1 channel' : chList.length + ' channels';
1677
+ var parts = '<span style="color:var(--text);font-weight:600">' + topicLabel + '</span> from ' + chLabel;
1678
+ if (lastRun) parts += ' · ' + lastRun;
1679
+ statsEl.innerHTML = parts;
1680
+ }
1681
+ }
1682
+ }).catch(function() {});
1683
+ };
1684
+
1685
+ function timeAgo(ts) {
1686
+ var diff = Math.floor((Date.now() - ts) / 1000);
1687
+ if (diff < 60) return 'just now';
1688
+ if (diff < 3600) return Math.floor(diff / 60) + 'min ago';
1689
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
1690
+ return Math.floor(diff / 86400) + 'd ago';
1691
+ }
1692
+
1270
1693
  // ── Input: send message + @autocomplete ──────────────────
1271
1694
 
1272
1695
  function sendMsg() {
1273
1696
  var input = document.getElementById('msg-input');
1274
1697
  if (!input || !input.value.trim() || activeChannel === '@me') return;
1698
+ // Announcement channels: only owners can send
1699
+ var chName = activeChannel.split('/')[0];
1700
+ var meta = channelMetas[chName];
1701
+ if (meta && meta.mode === 'announcement' && (!CONFIG.fingerprint || meta.owners.indexOf(CONFIG.fingerprint) === -1)) {
1702
+ return;
1703
+ }
1275
1704
  // DM mode: use sendDmMessage
1276
1705
  if (activeChannel && activeChannel.indexOf('dm:') === 0) {
1277
1706
  var theirFp = null;