agentchannel 0.7.25 → 0.7.26

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 (3) hide show
  1. package/package.json +1 -1
  2. package/ui/app.js +196 -6
  3. package/ui/index.html +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentchannel",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "Encrypted cross-network messaging for AI coding agents via MCP",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
package/ui/app.js CHANGED
@@ -38,6 +38,9 @@ var collapsedGroups = { "AgentChannel": true };
38
38
  var onlineMembers = {}; // channel -> Set of names
39
39
  var channelMetas = {}; // channel name -> meta object
40
40
 
41
+ var dmChannels = {}; // theirFingerprint -> {key, hash, name, theirFp}
42
+ var dmNames = {}; // theirFingerprint -> display name
43
+
41
44
  var encoder = new TextEncoder();
42
45
  var decoder = new TextDecoder();
43
46
 
@@ -87,6 +90,22 @@ async function hashSubWeb(channelKey, subName) {
87
90
  return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
88
91
  }
89
92
 
93
+ async function deriveDmKeyWeb(fpA, fpB) {
94
+ var sorted = [fpA, fpB].sort();
95
+ var ikm = sorted[0] + sorted[1];
96
+ var prk = await hkdfExtract(ikm);
97
+ var keyBytes = await hkdfExpand(prk, "acp1:dm", 32);
98
+ return crypto.subtle.importKey("raw", keyBytes, {name:"AES-GCM",length:256}, false, ["encrypt","decrypt"]);
99
+ }
100
+
101
+ async function hashDmWeb(fpA, fpB) {
102
+ var sorted = [fpA, fpB].sort();
103
+ var ikm = sorted[0] + sorted[1];
104
+ var prk = await hkdfExtract(ikm);
105
+ var topicBytes = await hkdfExpand(prk, "acp1:topic:dm", 16);
106
+ return Array.from(topicBytes).map(function(b) { return b.toString(16).padStart(2, "0"); }).join("");
107
+ }
108
+
90
109
  async function decryptPayload(payload, key) {
91
110
  var p = JSON.parse(payload);
92
111
  var iv = Uint8Array.from(atob(p.iv), function(c) { return c.charCodeAt(0); });
@@ -172,7 +191,7 @@ function richText(t) {
172
191
  function render() {
173
192
  var filtered;
174
193
  if (activeChannel === "all") {
175
- filtered = allMessages.slice();
194
+ filtered = allMessages.filter(function(m) { return !m.channel || !m.channel.startsWith("dm:"); });
176
195
  } else if (activeChannel === "@me") {
177
196
  filtered = allMessages.filter(function(m) {
178
197
  return m.content && CONFIG.name && m.content.indexOf("@" + CONFIG.name) !== -1;
@@ -303,6 +322,39 @@ function renderSidebar() {
303
322
  el.appendChild(meDiv);
304
323
  }
305
324
 
325
+ // Direct Messages section
326
+ var dmKeys = Object.keys(dmChannels);
327
+ if (dmKeys.length > 0) {
328
+ var dmHeader = document.createElement("div");
329
+ dmHeader.style.cssText = "font-size:0.6rem;color:var(--text-muted);padding:12px 12px 4px;text-transform:uppercase;letter-spacing:0.05em;font-weight:600";
330
+ dmHeader.textContent = "Direct Messages";
331
+ el.appendChild(dmHeader);
332
+
333
+ for (var di = 0; di < dmKeys.length; di++) {
334
+ var dmFp = dmKeys[di];
335
+ var dmInfo = dmChannels[dmFp];
336
+ var dmCid = dmInfo.channelId;
337
+ var dmDisplayName = dmNames[dmFp] || dmFp.slice(0, 8);
338
+ var dmDiv = document.createElement("div");
339
+ dmDiv.className = "sidebar__channel" + (activeChannel === dmCid ? " active" : "");
340
+ var dmCount = unreadCounts[dmCid] || 0;
341
+ dmDiv.innerHTML = '<span style="color:var(--accent);margin-right:2px;opacity:0.7">@</span>' + esc(dmDisplayName) + '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:3px;opacity:0.8">(' + dmFp.slice(0, 4) + ')</span>' + (dmCount ? '<span class="badge">' + dmCount + '</span>' : "");
342
+ (function(fp, channelId, displayName) {
343
+ dmDiv.onclick = function() {
344
+ activeChannel = channelId;
345
+ unreadCounts[channelId] = 0;
346
+ headerName.textContent = "@" + displayName;
347
+ headerDesc.textContent = "DM with " + fp.slice(0, 8);
348
+ document.title = "AgentChannel — DM";
349
+ renderSidebar();
350
+ render();
351
+ if (window.renderMembers) window.renderMembers();
352
+ };
353
+ })(dmFp, dmCid, dmDisplayName);
354
+ el.appendChild(dmDiv);
355
+ }
356
+ }
357
+
306
358
  // Render each parent + children
307
359
  for (var pi = 0; pi < parents.length; pi++) {
308
360
  var ch = parents[pi];
@@ -709,10 +761,12 @@ async function init() {
709
761
  var isOnline = memberMap[name].online;
710
762
  var isYou = name === CONFIG.name;
711
763
  var memberInfo = Object.values(window.cloudMembers || {}).flat().find(function(m) { return m.name === name; });
712
- var fpStr = memberInfo && memberInfo.fingerprint
713
- ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + memberInfo.fingerprint.slice(0, 4) + ')</span>'
764
+ var fp = memberInfo && memberInfo.fingerprint ? memberInfo.fingerprint : '';
765
+ var fpStr = fp
766
+ ? '<span style="color:var(--text-muted);font-size:0.6rem;margin-left:2px">(' + fp.slice(0, 4) + ')</span>'
714
767
  : '';
715
- return '<div class="members__item"><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
768
+ var dmClick = (!isYou && fp) ? ' onclick="window.openDm(\x27' + fp + '\x27,\x27' + esc(name).replace(/'/g, '') + '\x27)" style="cursor:pointer" title="Open DM"' : '';
769
+ return '<div class="members__item"' + dmClick + '><span class="members__dot" style="background:' + (isOnline ? "#22c55e" : "#666") + '"></span><span class="members__name">' + esc(name) + fpStr + '</span>' + (isYou ? '<span class="members__role">you</span>' : '') + '</div>';
716
770
  }).join("");
717
771
 
718
772
  list.innerHTML = html;
@@ -812,12 +866,17 @@ async function init() {
812
866
  }
813
867
  var total = Object.values(unreadCounts).reduce(function(a, b) { return a + b; }, 0);
814
868
  if (total > 0) document.title = "(" + total + ") AgentChannel";
815
- var nlabel = ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name;
869
+ var nlabel = ch.isDm ? "DM" : (ch.sub ? "#" + ch.name + " ##" + ch.sub : "#" + ch.name);
816
870
  if (Notification.permission === "granted" && (document.hidden || activeChannel !== chKeyName)) {
817
871
  var n = new Notification(nlabel + " @" + msg.sender, {body: msg.content});
818
872
  n.onclick = function() {
819
873
  window.focus();
820
- if (ch.sub) { window.switchToSub(ch.sub); }
874
+ if (ch.isDm) {
875
+ activeChannel = ch.name;
876
+ unreadCounts[ch.name] = 0;
877
+ renderSidebar();
878
+ render();
879
+ } else if (ch.sub) { window.switchToSub(ch.sub); }
821
880
  else { window.switchToChannel(ch.name); }
822
881
  };
823
882
  }
@@ -859,6 +918,81 @@ async function init() {
859
918
  renderMembers();
860
919
  };
861
920
 
921
+ window.openDm = async function(theirFp, theirName) {
922
+ if (!CONFIG.fingerprint || theirFp === CONFIG.fingerprint) return;
923
+ // Derive DM key and hash
924
+ if (!dmChannels[theirFp]) {
925
+ var dmKey = await deriveDmKeyWeb(CONFIG.fingerprint, theirFp);
926
+ var dmHash = await hashDmWeb(CONFIG.fingerprint, theirFp);
927
+ var sorted = [CONFIG.fingerprint, theirFp].sort();
928
+ var dmCid = "dm:" + sorted[0] + ":" + sorted[1];
929
+ dmChannels[theirFp] = {key: dmKey, hash: dmHash, channelId: dmCid, theirFp: theirFp};
930
+ // Register in channels map for MQTT handling
931
+ channels[dmCid] = {key: dmKey, hash: dmHash, name: dmCid, isDm: true, theirFp: theirFp};
932
+ // Subscribe to DM topic
933
+ client.subscribe("ac/1/" + dmHash);
934
+ // Load DM history from cloud
935
+ try {
936
+ var dres = await fetch("https://api.agentchannel.workers.dev/messages?channel_hash=" + dmHash + "&since=0&limit=30");
937
+ var drows = await dres.json();
938
+ for (var dri = 0; dri < drows.length; dri++) {
939
+ try {
940
+ var dmsg = JSON.parse(await decryptPayload(drows[dri].ciphertext, dmKey));
941
+ dmsg.channel = dmCid;
942
+ if (dmsg.type !== "channel_meta") {
943
+ // Avoid duplicates
944
+ var isDup = allMessages.some(function(m) { return m.id === dmsg.id; });
945
+ if (!isDup) allMessages.push(dmsg);
946
+ }
947
+ } catch(e) {}
948
+ }
949
+ allMessages.sort(function(a, b) { return a.timestamp - b.timestamp; });
950
+ } catch(e) {}
951
+ }
952
+ if (theirName) dmNames[theirFp] = theirName;
953
+ var dmCid = dmChannels[theirFp].channelId;
954
+ activeChannel = dmCid;
955
+ unreadCounts[dmCid] = 0;
956
+ headerName.textContent = "@" + (theirName || theirFp.slice(0, 8));
957
+ headerDesc.textContent = "DM with " + theirFp.slice(0, 8);
958
+ document.title = "AgentChannel — DM";
959
+ renderSidebar();
960
+ render();
961
+ renderMembers();
962
+ };
963
+
964
+ // DM send via web UI — encrypt and publish to DM topic
965
+ window.sendDmMessage = async function(theirFp, content) {
966
+ if (!dmChannels[theirFp]) return;
967
+ var dm = dmChannels[theirFp];
968
+ var iv = crypto.getRandomValues(new Uint8Array(12));
969
+ var plaintext = JSON.stringify({
970
+ id: Array.from(crypto.getRandomValues(new Uint8Array(8))).map(function(b){return b.toString(16).padStart(2,"0")}).join(""),
971
+ channel: dm.channelId,
972
+ sender: CONFIG.name,
973
+ content: content,
974
+ timestamp: Date.now(),
975
+ type: "chat",
976
+ senderKey: CONFIG.fingerprint
977
+ });
978
+ var encoded = encoder.encode(plaintext);
979
+ var encrypted = await crypto.subtle.encrypt({name:"AES-GCM", iv:iv}, dm.key, encoded);
980
+ var cipherData = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16));
981
+ var tag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16));
982
+ var payload = JSON.stringify({
983
+ iv: btoa(String.fromCharCode.apply(null, iv)),
984
+ data: btoa(String.fromCharCode.apply(null, cipherData)),
985
+ tag: btoa(String.fromCharCode.apply(null, tag))
986
+ });
987
+ client.publish("ac/1/" + dm.hash, payload, {qos: 1});
988
+ // Also store to cloud
989
+ fetch("https://api.agentchannel.workers.dev/messages", {
990
+ method: "POST",
991
+ headers: {"Content-Type": "application/json"},
992
+ body: JSON.stringify({channel_hash: dm.hash, id: JSON.parse(plaintext).id, ciphertext: payload, timestamp: Date.now()})
993
+ }).catch(function(){});
994
+ };
995
+
862
996
  renderMembers();
863
997
  loadCloudMembers();
864
998
  }
@@ -943,6 +1077,62 @@ function saveName() {
943
1077
  }
944
1078
  window.saveName = saveName;
945
1079
 
1080
+ // ── Input: send message + @autocomplete ──────────────────
1081
+
1082
+ function sendMsg() {
1083
+ var input = document.getElementById('msg-input');
1084
+ if (!input || !input.value.trim() || activeChannel === '@me') return;
1085
+ fetch('/api/send', {
1086
+ method: 'POST',
1087
+ headers: { 'Content-Type': 'application/json' },
1088
+ body: JSON.stringify({ channel: activeChannel, message: input.value.trim() })
1089
+ }).then(function() { input.value = ''; }).catch(function(e) { console.error('Send failed:', e); });
1090
+ }
1091
+ window.sendMsg = sendMsg;
1092
+
1093
+ var acSelected = 0;
1094
+ function onInputChange(input) {
1095
+ var val = input.value;
1096
+ var atIdx = val.lastIndexOf('@');
1097
+ var ac = document.getElementById('autocomplete');
1098
+ if (atIdx === -1 || atIdx < val.length - 20) { ac.style.display = 'none'; return; }
1099
+ var query = val.slice(atIdx + 1).toLowerCase();
1100
+ if (query.indexOf(' ') !== -1) { ac.style.display = 'none'; return; }
1101
+ var members = [];
1102
+ var cm = window.cloudMembers || {};
1103
+ for (var k in cm) { for (var i = 0; i < cm[k].length; i++) { var m = cm[k][i]; if (members.indexOf(m.name) === -1) members.push(m.name); } }
1104
+ var filtered = members.filter(function(n) { return n.toLowerCase().indexOf(query) === 0 && n !== CONFIG.name; });
1105
+ if (!filtered.length) { ac.style.display = 'none'; return; }
1106
+ acSelected = 0;
1107
+ ac.style.display = 'block';
1108
+ ac.innerHTML = filtered.slice(0, 6).map(function(n, i) {
1109
+ return '<div style="padding:6px 10px;cursor:pointer;border-radius:4px;font-size:0.8rem;color:var(--text)' + (i === 0 ? ';background:var(--bg-alt)' : '') + '" onmousedown="insertMention(\'' + n + '\')">' + n + '</div>';
1110
+ }).join('');
1111
+ }
1112
+ window.onInputChange = onInputChange;
1113
+
1114
+ function insertMention(name) {
1115
+ var input = document.getElementById('msg-input');
1116
+ var val = input.value;
1117
+ input.value = val.slice(0, val.lastIndexOf('@')) + '@' + name + ' ';
1118
+ input.focus();
1119
+ document.getElementById('autocomplete').style.display = 'none';
1120
+ }
1121
+ window.insertMention = insertMention;
1122
+
1123
+ function onInputKey(e) {
1124
+ if (e.key === 'Enter' && document.getElementById('autocomplete').style.display === 'none') { sendMsg(); return; }
1125
+ var ac = document.getElementById('autocomplete');
1126
+ if (ac.style.display === 'none') return;
1127
+ var items = ac.children;
1128
+ if (e.key === 'ArrowDown') { acSelected = Math.min(acSelected + 1, items.length - 1); e.preventDefault(); }
1129
+ if (e.key === 'ArrowUp') { acSelected = Math.max(acSelected - 1, 0); e.preventDefault(); }
1130
+ if (e.key === 'Tab' || e.key === 'Enter') { if (items[acSelected]) { insertMention(items[acSelected].textContent); e.preventDefault(); } }
1131
+ if (e.key === 'Escape') { ac.style.display = 'none'; }
1132
+ for (var i = 0; i < items.length; i++) items[i].style.background = i === acSelected ? 'var(--bg-alt)' : '';
1133
+ }
1134
+ window.onInputKey = onInputKey;
1135
+
946
1136
  // Restore saved theme (default: dark)
947
1137
  var savedTheme = localStorage.getItem('ac-theme') || 'dark';
948
1138
  document.documentElement.classList.add(savedTheme);
package/ui/index.html CHANGED
@@ -32,6 +32,13 @@
32
32
  <div class="empty">Waiting for messages...</div>
33
33
  </div>
34
34
  </div>
35
+ <div style="position:relative">
36
+ <div id="autocomplete" style="display:none;position:absolute;bottom:100%;left:24px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:4px;max-height:150px;overflow-y:auto;box-shadow:0 -4px 12px rgba(0,0,0,0.2);z-index:100"></div>
37
+ <div style="padding:8px 24px;border-top:1px solid var(--border);display:flex;gap:8px">
38
+ <input type="text" id="msg-input" placeholder="Type a message... (@name to mention)" style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:0.85rem;background:var(--bg);color:var(--text);outline:none" oninput="onInputChange(this)" onkeydown="onInputKey(event)">
39
+ <button onclick="sendMsg()" style="padding:8px 16px;border:none;border-radius:8px;background:#1a1a1a;color:#fff;font-size:0.85rem;cursor:pointer;font-weight:600">Send</button>
40
+ </div>
41
+ </div>
35
42
  </div>
36
43
  <div class="members" id="members-panel">
37
44
  <div class="members__header">Members</div>