@delt/claude-alarm 0.8.1 → 0.9.0

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.
@@ -187,6 +187,7 @@
187
187
  outline: none;
188
188
  font-family: inherit;
189
189
  }
190
+ .name-edit-input.invalid { border-color: var(--red); }
190
191
  .session-name {
191
192
  font-size: 14px;
192
193
  font-weight: 500;
@@ -400,6 +401,39 @@
400
401
  }
401
402
  .drag-overlay.active { display: flex; }
402
403
 
404
+ .mention-error {
405
+ display: none;
406
+ padding: 6px 20px 0;
407
+ color: var(--red);
408
+ font-size: 12px;
409
+ }
410
+ .mention-error.show { display: block; }
411
+ .message-input-area { position: relative; }
412
+ .mention-popup {
413
+ display: none;
414
+ position: absolute;
415
+ bottom: calc(100% + 4px);
416
+ left: 20px;
417
+ min-width: 220px;
418
+ max-height: 200px;
419
+ overflow-y: auto;
420
+ background: var(--surface);
421
+ border: 1px solid var(--border);
422
+ border-radius: 6px;
423
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
424
+ z-index: 20;
425
+ }
426
+ .mention-popup.open { display: block; }
427
+ .mention-item {
428
+ display: flex;
429
+ justify-content: space-between;
430
+ gap: 12px;
431
+ padding: 6px 10px;
432
+ font-size: 13px;
433
+ cursor: pointer;
434
+ }
435
+ .mention-item.active, .mention-item:hover { background: var(--border); }
436
+ .mention-folder { color: var(--text-dim); font-size: 11px; }
403
437
  .message-input-area {
404
438
  border-top: 1px solid var(--border);
405
439
  padding: 12px 20px;
@@ -617,12 +651,35 @@
617
651
  border-radius: 5px;
618
652
  padding: 6px 10px;
619
653
  margin-top: 5px;
620
- max-height: 60px;
621
- overflow-y: auto;
654
+ max-height: 220px;
655
+ overflow: auto;
622
656
  white-space: pre-wrap;
623
657
  word-break: break-all;
624
658
  line-height: 1.4;
625
659
  }
660
+ .perm-preview pre {
661
+ margin: 0;
662
+ white-space: pre;
663
+ font-family: inherit;
664
+ font-size: inherit;
665
+ }
666
+ .perm-preview code { font-family: inherit; }
667
+ .perm-preview.prose {
668
+ font-family: inherit;
669
+ white-space: normal;
670
+ word-break: normal;
671
+ }
672
+ .perm-preview.prose pre,
673
+ .perm-preview.prose code { font-family: monospace; }
674
+ .perm-preview.prose ul { margin: 4px 0; padding-left: 20px; }
675
+ .perm-preview.prose li { margin: 2px 0; }
676
+ .perm-preview.prose strong { font-weight: 600; }
677
+ .perm-preview.prose h2,
678
+ .perm-preview.prose h3,
679
+ .perm-preview.prose h4 { font-size: 12px; font-weight: 600; margin: 4px 0; }
680
+ .perm-preview.prose table { border-collapse: collapse; margin: 4px 0; font-size: 11px; }
681
+ .perm-preview.prose th,
682
+ .perm-preview.prose td { border: 1px solid var(--border); padding: 3px 6px; text-align: left; }
626
683
  .perm-actions {
627
684
  display: flex;
628
685
  gap: 10px;
@@ -940,7 +997,9 @@
940
997
  <span class="preview-name" id="previewName"></span>
941
998
  <button class="preview-cancel" id="previewCancel">&times;</button>
942
999
  </div>
1000
+ <div class="mention-error" id="mentionError"></div>
943
1001
  <div class="message-input-area">
1002
+ <div class="mention-popup" id="mentionPopup"></div>
944
1003
  <button class="attach-btn" id="attachBtn" disabled title="Attach image">&#128206;</button>
945
1004
  <input type="file" id="fileInput" accept="image/*" style="display:none">
946
1005
  <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
@@ -1072,6 +1131,146 @@
1072
1131
  return state.sessionMeta.names[s.id] || s.displayName || s.name;
1073
1132
  }
1074
1133
 
1134
+ function normName(s) {
1135
+ return String(s).normalize('NFC').trim().toLowerCase();
1136
+ }
1137
+
1138
+ function folderName(s) {
1139
+ return s.displayName || s.name;
1140
+ }
1141
+
1142
+ function renameError(val, id) {
1143
+ if (/[\[\]@]/.test(val)) return 'Name cannot contain [, ] or @';
1144
+ const key = normName(val);
1145
+ const taken = Object.values(state.sessions).some(o => o.id !== id &&
1146
+ (normName(sessionDisplayName(o)) === key || normName(folderName(o)) === key));
1147
+ return taken ? 'Name already used by another session' : null;
1148
+ }
1149
+
1150
+ function sessionNames(s) {
1151
+ return [state.sessionMeta.names[s.id], folderName(s), s.peerName].filter(Boolean);
1152
+ }
1153
+
1154
+ // Built-in SendMessage only reaches sessions on the hub's own machine.
1155
+ function mentionTargets() {
1156
+ const selected = state.sessions[state.selectedSession];
1157
+ if (!selected || !selected.isLocal) return [];
1158
+ return Object.values(state.sessions).filter(s => s.id !== selected.id && s.isLocal && s.peerName);
1159
+ }
1160
+
1161
+ function resolveMention(name) {
1162
+ const key = normName(name);
1163
+ const matches = mentionTargets().filter(s => sessionNames(s).some(n => normName(n) === key));
1164
+ return matches.length === 1 ? matches[0] : null;
1165
+ }
1166
+
1167
+ function mentionLabel(s) {
1168
+ return [sessionDisplayName(s), folderName(s), s.peerName].find(n => resolveMention(n) === s) || s.peerName;
1169
+ }
1170
+
1171
+ // A token only counts as a mention when it names (or starts with) some
1172
+ // session, so pasted code like @Override or npm @types/x is left alone.
1173
+ function looksLikeSessionMention(name) {
1174
+ const key = normName(name);
1175
+ return Object.values(state.sessions).some(s => sessionNames(s).some(n => key.startsWith(normName(n))));
1176
+ }
1177
+
1178
+ function extractMentions(text) {
1179
+ const prose = text.replace(/```[\s\S]*?(```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ');
1180
+ const re = /(^|\s)@(?:\[([^\]\n]+)\]|([^\s\[\]]+))/g;
1181
+ const found = [];
1182
+ let m;
1183
+ while ((m = re.exec(prose))) {
1184
+ const bracketed = m[2] !== undefined;
1185
+ const name = bracketed ? m[2] : m[3].replace(/[.,!?;:)]+$/, '');
1186
+ found.push({ raw: m[0].trim(), name, bracketed });
1187
+ }
1188
+ return found;
1189
+ }
1190
+
1191
+ // The routing line is the contract with the channel instructions in
1192
+ // src/channel/server.ts (ROUTING) — change both together.
1193
+ function buildRouting(text) {
1194
+ const mentions = extractMentions(text);
1195
+ if (!mentions.length) return { ok: true, content: text };
1196
+ const unknown = [];
1197
+ const lines = [];
1198
+ const seen = new Set();
1199
+ for (const m of mentions) {
1200
+ const s = resolveMention(m.name);
1201
+ if (!s) {
1202
+ if (m.bracketed || looksLikeSessionMention(m.name)) unknown.push(m.raw);
1203
+ continue;
1204
+ }
1205
+ if (seen.has(s.id)) continue;
1206
+ seen.add(s.id);
1207
+ lines.push(`[claude-alarm] @${m.name} = SendMessage to "${s.peerName}"`);
1208
+ }
1209
+ if (unknown.length) return { ok: false, unknown };
1210
+ if (!lines.length) return { ok: true, content: text };
1211
+ return { ok: true, content: `${text}\n\n${lines.join('\n')}` };
1212
+ }
1213
+
1214
+ function showMentionError(msg) {
1215
+ const el = $('#mentionError');
1216
+ el.textContent = msg || '';
1217
+ el.classList.toggle('show', !!msg);
1218
+ }
1219
+
1220
+ let mentionState = { open: false, items: [], index: 0, start: -1 };
1221
+
1222
+ function currentMentionQuery(input) {
1223
+ const upto = input.value.slice(0, input.selectionStart);
1224
+ const m = upto.match(/(^|\s)@(\[[^\]\n]*|[^\s\[\]]*)$/);
1225
+ if (!m) return null;
1226
+ return { start: upto.length - m[2].length - 1, query: m[2].replace(/^\[/, '') };
1227
+ }
1228
+
1229
+ function closeMentionPopup() {
1230
+ mentionState = { open: false, items: [], index: 0, start: -1 };
1231
+ $('#mentionPopup').classList.remove('open');
1232
+ }
1233
+
1234
+ function renderMentionPopup() {
1235
+ $('#mentionPopup').innerHTML = mentionState.items.map((s, i) =>
1236
+ `<div class="mention-item${i === mentionState.index ? ' active' : ''}" data-idx="${i}">` +
1237
+ `<span>${esc(sessionDisplayName(s))}</span><span class="mention-folder">${esc(folderName(s))}</span></div>`
1238
+ ).join('');
1239
+ }
1240
+
1241
+ function updateMentionPopup() {
1242
+ const q = currentMentionQuery($('#msgInput'));
1243
+ if (!q) return closeMentionPopup();
1244
+ const key = normName(q.query);
1245
+ const items = mentionTargets().filter(s =>
1246
+ normName(sessionDisplayName(s)).includes(key) || normName(folderName(s)).includes(key));
1247
+ if (!items.length) return closeMentionPopup();
1248
+ const index = mentionState.open ? Math.min(mentionState.index, items.length - 1) : 0;
1249
+ mentionState = { open: true, items, index, start: q.start };
1250
+ renderMentionPopup();
1251
+ $('#mentionPopup').classList.add('open');
1252
+ }
1253
+
1254
+ function applyMention(s) {
1255
+ const input = $('#msgInput');
1256
+ const label = mentionLabel(s);
1257
+ const token = /\s/.test(label) ? `@[${label}] ` : `@${label} `;
1258
+ const before = input.value.slice(0, mentionState.start);
1259
+ const after = input.value.slice(input.selectionStart);
1260
+ input.value = before + token + after;
1261
+ const pos = before.length + token.length;
1262
+ input.setSelectionRange(pos, pos);
1263
+ closeMentionPopup();
1264
+ input.focus();
1265
+ }
1266
+
1267
+ $('#mentionPopup').addEventListener('mousedown', (e) => {
1268
+ const item = e.target.closest('.mention-item');
1269
+ if (!item) return;
1270
+ e.preventDefault();
1271
+ applyMention(mentionState.items[Number(item.dataset.idx)]);
1272
+ });
1273
+
1075
1274
  // --- Theme ---
1076
1275
  function initTheme() {
1077
1276
  const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
@@ -1284,12 +1483,18 @@
1284
1483
  input.select();
1285
1484
 
1286
1485
  let done = false;
1287
- const commit = (save) => {
1486
+ const commit = (save, fromBlur) => {
1288
1487
  if (done) return;
1488
+ const val = input.value.trim();
1489
+ const baseName = s.displayName || s.name;
1490
+ const err = save && val && val !== baseName ? renameError(val, id) : null;
1491
+ if (err && !fromBlur) {
1492
+ input.classList.add('invalid');
1493
+ input.title = err;
1494
+ return;
1495
+ }
1289
1496
  done = true;
1290
- if (save) {
1291
- const val = input.value.trim();
1292
- const baseName = s.displayName || s.name;
1497
+ if (save && !err) {
1293
1498
  if (!val || val === baseName) delete state.sessionMeta.names[id];
1294
1499
  else state.sessionMeta.names[id] = val;
1295
1500
  saveSessionMeta();
@@ -1298,10 +1503,11 @@
1298
1503
  };
1299
1504
  input.addEventListener('keydown', (e) => {
1300
1505
  e.stopPropagation();
1301
- if (e.key === 'Enter') { e.preventDefault(); commit(true); }
1302
- else if (e.key === 'Escape') { e.preventDefault(); commit(false); }
1506
+ if (e.key === 'Enter') { e.preventDefault(); commit(true, false); }
1507
+ else if (e.key === 'Escape') { e.preventDefault(); commit(false, false); }
1303
1508
  });
1304
- input.addEventListener('blur', () => commit(true));
1509
+ input.addEventListener('input', () => { input.classList.remove('invalid'); input.title = ''; });
1510
+ input.addEventListener('blur', () => commit(true, true));
1305
1511
  input.addEventListener('click', (e) => e.stopPropagation());
1306
1512
  }
1307
1513
 
@@ -1554,6 +1760,13 @@
1554
1760
  if (!content && !hasImage) return;
1555
1761
  if (!state.selectedSession || !state.ws) return;
1556
1762
 
1763
+ const routing = buildRouting(content);
1764
+ if (!routing.ok) {
1765
+ showMentionError(`Unknown session: ${routing.unknown.join(', ')} — pick from the @ list, or put a space right after the name.`);
1766
+ return;
1767
+ }
1768
+ showMentionError(null);
1769
+
1557
1770
  if (hasImage) {
1558
1771
  // Send image + text together
1559
1772
  state.ws.send(JSON.stringify({
@@ -1562,11 +1775,11 @@
1562
1775
  imageData: state.pendingImage.data,
1563
1776
  mimeType: state.pendingImage.mimeType,
1564
1777
  originalName: state.pendingImage.name,
1565
- content: content || '',
1778
+ content: routing.content || '',
1566
1779
  }));
1567
1780
  } else if (content) {
1568
1781
  // Text only
1569
- state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content }));
1782
+ state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content: routing.content }));
1570
1783
  }
1571
1784
 
1572
1785
  // Add to local messages
@@ -1581,6 +1794,7 @@
1581
1794
  saveMessages();
1582
1795
 
1583
1796
  input.value = '';
1797
+ closeMentionPopup();
1584
1798
  input.style.height = 'auto';
1585
1799
  clearPendingImage();
1586
1800
  state.waitingReply[state.selectedSession] = true;
@@ -1589,11 +1803,24 @@
1589
1803
 
1590
1804
  $('#sendBtn').addEventListener('click', sendMessage);
1591
1805
  $('#msgInput').addEventListener('keydown', (e) => {
1806
+ if (mentionState.open) {
1807
+ // Enter while an IME (e.g. Korean) is composing only commits the syllable.
1808
+ if (e.isComposing) return;
1809
+ const n = mentionState.items.length;
1810
+ if (e.key === 'ArrowDown') { e.preventDefault(); mentionState.index = (mentionState.index + 1) % n; renderMentionPopup(); return; }
1811
+ if (e.key === 'ArrowUp') { e.preventDefault(); mentionState.index = (mentionState.index - 1 + n) % n; renderMentionPopup(); return; }
1812
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyMention(mentionState.items[mentionState.index]); return; }
1813
+ if (e.key === 'Escape') { e.preventDefault(); closeMentionPopup(); return; }
1814
+ }
1592
1815
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1593
1816
  });
1817
+ $('#msgInput').addEventListener('blur', closeMentionPopup);
1818
+ $('#msgInput').addEventListener('click', updateMentionPopup);
1594
1819
  $('#msgInput').addEventListener('input', function() {
1820
+ showMentionError(null);
1595
1821
  this.style.height = 'auto';
1596
1822
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1823
+ updateMentionPopup();
1597
1824
  });
1598
1825
 
1599
1826
  function relativeTime(ts) {
@@ -1670,9 +1897,26 @@
1670
1897
  return toolName;
1671
1898
  }
1672
1899
 
1673
- // Returns { text } — dashboard shows full content, no truncation
1900
+ const LANG_BY_EXT = {
1901
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
1902
+ ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', go: 'go',
1903
+ java: 'java', kt: 'kotlin', rs: 'rust', php: 'php', cs: 'csharp',
1904
+ sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell',
1905
+ json: 'json', yml: 'yaml', yaml: 'yaml', xml: 'xml', sql: 'sql',
1906
+ md: 'markdown', html: 'html', vue: 'html', css: 'css', scss: 'scss',
1907
+ };
1908
+ function langOf(path) {
1909
+ const m = /\.([a-z0-9]+)$/i.exec(path || '');
1910
+ return m ? (LANG_BY_EXT[m[1].toLowerCase()] || '') : '';
1911
+ }
1912
+
1913
+ // Returns { text, kind, lang } — dashboard shows full content, no truncation.
1914
+ // kind 'code' must stay byte-exact: markdown rendering would swallow the ** and `
1915
+ // characters that the approver needs to see to judge what actually gets executed.
1916
+ // Only notify-style messages (prose Claude wrote) are safe to render as markdown.
1674
1917
  function formatPermPreview(toolName, raw) {
1675
- if (!raw) return { text: '' };
1918
+ if (!raw) return { text: '', kind: 'code', lang: '' };
1919
+ const isNotify = toolName === 'notify' || toolName.endsWith('__notify');
1676
1920
  let p = null;
1677
1921
  try { p = JSON.parse(raw); } catch {
1678
1922
  // Truncated JSON - extract known fields with regex (closing quote optional for truncated strings)
@@ -1682,43 +1926,52 @@
1682
1926
  const contentMatch = raw.match(/"content"\s*:\s*"((?:[^"\\]|\\.)*)"?/);
1683
1927
  const titleMatch = raw.match(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"?/);
1684
1928
  const msgMatch = raw.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"?/);
1685
- if (titleMatch && msgMatch) return { text: msgMatch[1] };
1686
- if (cmdMatch) return { text: '$ ' + cmdMatch[1] };
1687
- if (fileMatch) return { text: fileMatch[1] };
1688
- if (patternMatch) return { text: patternMatch[1] };
1689
- if (contentMatch) return { text: contentMatch[1] };
1690
- return { text: raw };
1929
+ if (titleMatch && msgMatch) return { text: msgMatch[1], kind: 'prose', lang: '' };
1930
+ if (cmdMatch) return { text: '$ ' + cmdMatch[1], kind: 'code', lang: 'bash' };
1931
+ if (fileMatch) return { text: fileMatch[1], kind: 'code', lang: langOf(fileMatch[1]) };
1932
+ if (patternMatch) return { text: patternMatch[1], kind: 'code', lang: '' };
1933
+ if (contentMatch) return { text: contentMatch[1], kind: 'code', lang: '' };
1934
+ return { text: raw, kind: 'code', lang: '' };
1691
1935
  }
1692
1936
  let text;
1937
+ let kind = 'code';
1938
+ let lang = '';
1693
1939
  switch (toolName) {
1694
1940
  case 'Bash':
1695
- text = p.command ? '$ ' + p.command : raw; break;
1941
+ text = p.command ? '$ ' + p.command : raw; lang = 'bash'; break;
1696
1942
  case 'Write':
1697
1943
  case 'Edit':
1698
1944
  if (p.file_path) {
1699
1945
  text = p.file_path;
1946
+ lang = langOf(p.file_path);
1700
1947
  if (p.new_string) text += '\n+ ' + p.new_string;
1701
1948
  else if (p.content) text += '\n' + p.content;
1702
1949
  } else { text = raw; }
1703
1950
  break;
1704
1951
  case 'Read':
1705
- text = p.file_path || raw; break;
1952
+ text = p.file_path || raw; lang = langOf(p.file_path); break;
1706
1953
  case 'Glob':
1707
1954
  text = p.pattern || raw; break;
1708
1955
  case 'Grep':
1709
1956
  text = (p.pattern || '') + (p.path ? ' in ' + p.path : ''); break;
1710
1957
  default:
1711
1958
  if (p.title && p.message) {
1712
- text = p.message;
1959
+ text = p.message; kind = 'prose';
1713
1960
  } else if (p.content && typeof p.content === 'string') {
1714
- text = p.content;
1961
+ text = p.content; kind = isNotify ? 'prose' : 'code';
1715
1962
  } else {
1716
1963
  const entries = Object.entries(p);
1717
1964
  if (entries.length <= 3) text = entries.map(([k, v]) => k + ': ' + String(v)).join('\n');
1718
1965
  else text = JSON.stringify(p, null, 1);
1719
1966
  }
1720
1967
  }
1721
- return { text: text || '' };
1968
+ return { text: text || '', kind, lang };
1969
+ }
1970
+
1971
+ function renderPermPreview(text, kind, lang) {
1972
+ if (kind === 'prose') return `<div class="perm-preview prose">${renderMarkdown(text)}</div>`;
1973
+ const cls = lang ? ` class="lang-${esc(lang)}"` : '';
1974
+ return `<div class="perm-preview"><pre><code${cls}>${esc(text)}</code></pre></div>`;
1722
1975
  }
1723
1976
 
1724
1977
  function sendPermissionVerdict(sessionId, requestId, behavior) {
@@ -1742,7 +1995,7 @@
1742
1995
  if (!reqs.length) { bar.classList.remove('active'); bar.innerHTML = ''; return; }
1743
1996
  bar.classList.add('active');
1744
1997
  bar.innerHTML = reqs.map(r => {
1745
- const { text: previewText } = formatPermPreview(r.toolName, r.inputPreview);
1998
+ const { text: previewText, kind: previewKind, lang: previewLang } = formatPermPreview(r.toolName, r.inputPreview);
1746
1999
  const displayName = formatToolName(r.toolName, r.inputPreview);
1747
2000
  return `<div class="perm-item">
1748
2001
  <div class="perm-icon">&#9888;</div>
@@ -1750,7 +2003,7 @@
1750
2003
  <div class="perm-header">
1751
2004
  <span class="perm-tool">${esc(displayName)}</span>
1752
2005
  </div>
1753
- ${previewText ? `<div class="perm-preview">${esc(previewText)}</div>` : ''}
2006
+ ${previewText ? renderPermPreview(previewText, previewKind, previewLang) : ''}
1754
2007
  </div>
1755
2008
  <div class="perm-actions">
1756
2009
  <button class="perm-allow" data-req-id="${esc(r.requestId)}" data-session-id="${esc(sid)}">Allow <span class="perm-kbd">(Enter)</span></button>
@@ -41,6 +41,12 @@ var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
41
41
  var WS_PATH_CHANNEL = "/ws/channel";
42
42
  var WS_PATH_DASHBOARD = "/ws/dashboard";
43
43
 
44
+ // src/shared/peer-name.ts
45
+ var SAFE_PEER_NAME = /^[^\u0000-\u001f\u007f"\\[\]]{1,128}$/;
46
+ function isValidPeerName(name) {
47
+ return typeof name === "string" && name.trim() !== "" && SAFE_PEER_NAME.test(name);
48
+ }
49
+
44
50
  // src/hub/session-manager.ts
45
51
  var SessionManager = class {
46
52
  sessions = /* @__PURE__ */ new Map();
@@ -64,7 +70,7 @@ var SessionManager = class {
64
70
  } else {
65
71
  session.displayName = baseName;
66
72
  }
67
- this.sessions.set(session.id, { ...session });
73
+ this.sessions.set(session.id, { ...session, peerName: isValidPeerName(session.peerName) ? session.peerName : void 0 });
68
74
  }
69
75
  unregister(sessionId) {
70
76
  const session = this.sessions.get(sessionId);
@@ -79,6 +85,13 @@ var SessionManager = class {
79
85
  }
80
86
  return session;
81
87
  }
88
+ setPeerName(sessionId, peerName) {
89
+ const session = this.sessions.get(sessionId);
90
+ if (session) {
91
+ session.peerName = isValidPeerName(peerName) ? peerName : void 0;
92
+ }
93
+ return session;
94
+ }
82
95
  updateActivity(sessionId) {
83
96
  const session = this.sessions.get(sessionId);
84
97
  if (session) {
@@ -210,7 +223,7 @@ import fs from "fs";
210
223
  import path2 from "path";
211
224
  import { randomUUID } from "crypto";
212
225
  var TELEGRAM_API = "https://api.telegram.org/bot";
213
- var TelegramBot = class {
226
+ var TelegramBot = class _TelegramBot {
214
227
  config;
215
228
  offset = 0;
216
229
  polling = false;
@@ -442,33 +455,45 @@ ${this.mdToHtml(message)}`;
442
455
  }
443
456
  /** Send a permission request with inline buttons */
444
457
  async sendPermissionRequest(sessionId, sessionLabel, requestId, toolName, description, inputPreview) {
458
+ const isNotify = toolName.endsWith("__notify") || toolName === "notify";
445
459
  let preview = inputPreview;
460
+ let kind = "code";
461
+ let lang = "";
446
462
  let truncated = false;
447
463
  try {
448
464
  const p = JSON.parse(inputPreview);
449
- if (p.command) preview = `$ ${p.command}`;
450
- else if (p.title && p.message) preview = p.message;
451
- else if (p.file_path) {
465
+ if (p.command) {
466
+ preview = `$ ${p.command}`;
467
+ lang = "bash";
468
+ } else if (p.title && p.message) {
469
+ preview = p.message;
470
+ kind = "prose";
471
+ } else if (p.file_path) {
452
472
  preview = p.file_path;
473
+ lang = this.langOf(p.file_path);
453
474
  if (p.content) {
454
475
  preview += "\n" + p.content.slice(0, 3e3);
455
476
  if (p.content.length > 500) truncated = true;
456
477
  }
457
478
  } else if (p.content && typeof p.content === "string") {
458
479
  preview = p.content.slice(0, 3e3);
480
+ if (isNotify) kind = "prose";
459
481
  if (p.content.length > 500) truncated = true;
460
482
  }
461
483
  } catch {
462
484
  truncated = true;
463
485
  const cmdMatch = inputPreview.match(/"command"\s*:\s*"((?:[^"\\]|\\.)*)"/);
464
486
  const contentMatch = inputPreview.match(/"content"\s*:\s*"((?:[^"\\]|\\.)*)"/);
465
- if (cmdMatch) preview = `$ ${cmdMatch[1]}`;
466
- else if (contentMatch) preview = contentMatch[1].slice(0, 3e3);
487
+ if (cmdMatch) {
488
+ preview = `$ ${cmdMatch[1]}`;
489
+ lang = "bash";
490
+ } else if (contentMatch) preview = contentMatch[1].slice(0, 3e3);
467
491
  }
468
492
  const truncNote = truncated ? "\n\n<i>...truncated</i>" : "";
469
493
  const previewSlice = preview.slice(0, 3e3);
470
494
  const isShort = !previewSlice.includes("\n") && previewSlice.length < 100;
471
- const previewHtml = isShort ? `<code>${this.escHtml(previewSlice)}</code>` : this.escHtml(previewSlice);
495
+ const langAttr = lang ? ` class="language-${lang}"` : "";
496
+ const previewHtml = kind === "prose" ? this.mdToHtml(previewSlice) : isShort ? `<code>${this.escHtml(previewSlice)}</code>` : `<pre><code${langAttr}>${this.escHtml(previewSlice)}</code></pre>`;
472
497
  let displayTool = toolName;
473
498
  if ((toolName.endsWith("__notify") || toolName === "notify") && inputPreview) {
474
499
  try {
@@ -568,6 +593,40 @@ ${label}`);
568
593
  escHtml(s) {
569
594
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
570
595
  }
596
+ static LANG_BY_EXT = {
597
+ js: "javascript",
598
+ mjs: "javascript",
599
+ cjs: "javascript",
600
+ jsx: "javascript",
601
+ ts: "typescript",
602
+ tsx: "typescript",
603
+ py: "python",
604
+ rb: "ruby",
605
+ go: "go",
606
+ java: "java",
607
+ kt: "kotlin",
608
+ rs: "rust",
609
+ php: "php",
610
+ cs: "csharp",
611
+ sh: "bash",
612
+ bash: "bash",
613
+ zsh: "bash",
614
+ ps1: "powershell",
615
+ json: "json",
616
+ yml: "yaml",
617
+ yaml: "yaml",
618
+ xml: "xml",
619
+ sql: "sql",
620
+ md: "markdown",
621
+ html: "html",
622
+ vue: "html",
623
+ css: "css",
624
+ scss: "scss"
625
+ };
626
+ langOf(path5) {
627
+ const m = /\.([a-z0-9]+)$/i.exec(path5 || "");
628
+ return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
629
+ }
571
630
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
572
631
  mdToHtml(s) {
573
632
  let text = s.replace(
@@ -995,7 +1054,7 @@ var HubServer = class {
995
1054
  this.sessions.register(session);
996
1055
  this.channelSockets.set(session.id, ws);
997
1056
  if (isLocal) this.localChannels.add(session.id);
998
- logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false})`);
1057
+ logger.info(`Session registered: ${session.id} (${session.name}, channel: ${session.channelEnabled ?? false}, peer: ${session.peerName ?? "-"})`);
999
1058
  this.broadcastToDashboards({
1000
1059
  type: isReregister ? "session_updated" : "session_connected",
1001
1060
  session
@@ -1009,6 +1068,15 @@ var HubServer = class {
1009
1068
  }
1010
1069
  break;
1011
1070
  }
1071
+ case "peer_name": {
1072
+ if (this.channelSockets.get(msg.sessionId) !== ws) break;
1073
+ const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1074
+ if (updated) {
1075
+ logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
1076
+ this.broadcastToDashboards({ type: "session_updated", session: updated });
1077
+ }
1078
+ break;
1079
+ }
1012
1080
  case "notify": {
1013
1081
  this.sessions.updateActivity(msg.sessionId);
1014
1082
  const notifySession = this.sessions.get(msg.sessionId);