@delt/claude-alarm 0.8.0 → 0.8.2

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.
@@ -617,12 +617,35 @@
617
617
  border-radius: 5px;
618
618
  padding: 6px 10px;
619
619
  margin-top: 5px;
620
- max-height: 60px;
621
- overflow-y: auto;
620
+ max-height: 220px;
621
+ overflow: auto;
622
622
  white-space: pre-wrap;
623
623
  word-break: break-all;
624
624
  line-height: 1.4;
625
625
  }
626
+ .perm-preview pre {
627
+ margin: 0;
628
+ white-space: pre;
629
+ font-family: inherit;
630
+ font-size: inherit;
631
+ }
632
+ .perm-preview code { font-family: inherit; }
633
+ .perm-preview.prose {
634
+ font-family: inherit;
635
+ white-space: normal;
636
+ word-break: normal;
637
+ }
638
+ .perm-preview.prose pre,
639
+ .perm-preview.prose code { font-family: monospace; }
640
+ .perm-preview.prose ul { margin: 4px 0; padding-left: 20px; }
641
+ .perm-preview.prose li { margin: 2px 0; }
642
+ .perm-preview.prose strong { font-weight: 600; }
643
+ .perm-preview.prose h2,
644
+ .perm-preview.prose h3,
645
+ .perm-preview.prose h4 { font-size: 12px; font-weight: 600; margin: 4px 0; }
646
+ .perm-preview.prose table { border-collapse: collapse; margin: 4px 0; font-size: 11px; }
647
+ .perm-preview.prose th,
648
+ .perm-preview.prose td { border: 1px solid var(--border); padding: 3px 6px; text-align: left; }
626
649
  .perm-actions {
627
650
  display: flex;
628
651
  gap: 10px;
@@ -1670,9 +1693,26 @@
1670
1693
  return toolName;
1671
1694
  }
1672
1695
 
1673
- // Returns { text } — dashboard shows full content, no truncation
1696
+ const LANG_BY_EXT = {
1697
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
1698
+ ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', go: 'go',
1699
+ java: 'java', kt: 'kotlin', rs: 'rust', php: 'php', cs: 'csharp',
1700
+ sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell',
1701
+ json: 'json', yml: 'yaml', yaml: 'yaml', xml: 'xml', sql: 'sql',
1702
+ md: 'markdown', html: 'html', vue: 'html', css: 'css', scss: 'scss',
1703
+ };
1704
+ function langOf(path) {
1705
+ const m = /\.([a-z0-9]+)$/i.exec(path || '');
1706
+ return m ? (LANG_BY_EXT[m[1].toLowerCase()] || '') : '';
1707
+ }
1708
+
1709
+ // Returns { text, kind, lang } — dashboard shows full content, no truncation.
1710
+ // kind 'code' must stay byte-exact: markdown rendering would swallow the ** and `
1711
+ // characters that the approver needs to see to judge what actually gets executed.
1712
+ // Only notify-style messages (prose Claude wrote) are safe to render as markdown.
1674
1713
  function formatPermPreview(toolName, raw) {
1675
- if (!raw) return { text: '' };
1714
+ if (!raw) return { text: '', kind: 'code', lang: '' };
1715
+ const isNotify = toolName === 'notify' || toolName.endsWith('__notify');
1676
1716
  let p = null;
1677
1717
  try { p = JSON.parse(raw); } catch {
1678
1718
  // Truncated JSON - extract known fields with regex (closing quote optional for truncated strings)
@@ -1682,43 +1722,52 @@
1682
1722
  const contentMatch = raw.match(/"content"\s*:\s*"((?:[^"\\]|\\.)*)"?/);
1683
1723
  const titleMatch = raw.match(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"?/);
1684
1724
  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 };
1725
+ if (titleMatch && msgMatch) return { text: msgMatch[1], kind: 'prose', lang: '' };
1726
+ if (cmdMatch) return { text: '$ ' + cmdMatch[1], kind: 'code', lang: 'bash' };
1727
+ if (fileMatch) return { text: fileMatch[1], kind: 'code', lang: langOf(fileMatch[1]) };
1728
+ if (patternMatch) return { text: patternMatch[1], kind: 'code', lang: '' };
1729
+ if (contentMatch) return { text: contentMatch[1], kind: 'code', lang: '' };
1730
+ return { text: raw, kind: 'code', lang: '' };
1691
1731
  }
1692
1732
  let text;
1733
+ let kind = 'code';
1734
+ let lang = '';
1693
1735
  switch (toolName) {
1694
1736
  case 'Bash':
1695
- text = p.command ? '$ ' + p.command : raw; break;
1737
+ text = p.command ? '$ ' + p.command : raw; lang = 'bash'; break;
1696
1738
  case 'Write':
1697
1739
  case 'Edit':
1698
1740
  if (p.file_path) {
1699
1741
  text = p.file_path;
1742
+ lang = langOf(p.file_path);
1700
1743
  if (p.new_string) text += '\n+ ' + p.new_string;
1701
1744
  else if (p.content) text += '\n' + p.content;
1702
1745
  } else { text = raw; }
1703
1746
  break;
1704
1747
  case 'Read':
1705
- text = p.file_path || raw; break;
1748
+ text = p.file_path || raw; lang = langOf(p.file_path); break;
1706
1749
  case 'Glob':
1707
1750
  text = p.pattern || raw; break;
1708
1751
  case 'Grep':
1709
1752
  text = (p.pattern || '') + (p.path ? ' in ' + p.path : ''); break;
1710
1753
  default:
1711
1754
  if (p.title && p.message) {
1712
- text = p.message;
1755
+ text = p.message; kind = 'prose';
1713
1756
  } else if (p.content && typeof p.content === 'string') {
1714
- text = p.content;
1757
+ text = p.content; kind = isNotify ? 'prose' : 'code';
1715
1758
  } else {
1716
1759
  const entries = Object.entries(p);
1717
1760
  if (entries.length <= 3) text = entries.map(([k, v]) => k + ': ' + String(v)).join('\n');
1718
1761
  else text = JSON.stringify(p, null, 1);
1719
1762
  }
1720
1763
  }
1721
- return { text: text || '' };
1764
+ return { text: text || '', kind, lang };
1765
+ }
1766
+
1767
+ function renderPermPreview(text, kind, lang) {
1768
+ if (kind === 'prose') return `<div class="perm-preview prose">${renderMarkdown(text)}</div>`;
1769
+ const cls = lang ? ` class="lang-${esc(lang)}"` : '';
1770
+ return `<div class="perm-preview"><pre><code${cls}>${esc(text)}</code></pre></div>`;
1722
1771
  }
1723
1772
 
1724
1773
  function sendPermissionVerdict(sessionId, requestId, behavior) {
@@ -1742,7 +1791,7 @@
1742
1791
  if (!reqs.length) { bar.classList.remove('active'); bar.innerHTML = ''; return; }
1743
1792
  bar.classList.add('active');
1744
1793
  bar.innerHTML = reqs.map(r => {
1745
- const { text: previewText } = formatPermPreview(r.toolName, r.inputPreview);
1794
+ const { text: previewText, kind: previewKind, lang: previewLang } = formatPermPreview(r.toolName, r.inputPreview);
1746
1795
  const displayName = formatToolName(r.toolName, r.inputPreview);
1747
1796
  return `<div class="perm-item">
1748
1797
  <div class="perm-icon">&#9888;</div>
@@ -1750,7 +1799,7 @@
1750
1799
  <div class="perm-header">
1751
1800
  <span class="perm-tool">${esc(displayName)}</span>
1752
1801
  </div>
1753
- ${previewText ? `<div class="perm-preview">${esc(previewText)}</div>` : ''}
1802
+ ${previewText ? renderPermPreview(previewText, previewKind, previewLang) : ''}
1754
1803
  </div>
1755
1804
  <div class="perm-actions">
1756
1805
  <button class="perm-allow" data-req-id="${esc(r.requestId)}" data-session-id="${esc(sid)}">Allow <span class="perm-kbd">(Enter)</span></button>
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/hub/server.ts
4
4
  import http from "http";
5
- import fs3 from "fs";
5
+ import fs4 from "fs";
6
6
  import path4 from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { WebSocketServer, WebSocket } from "ws";
@@ -210,7 +210,7 @@ import fs from "fs";
210
210
  import path2 from "path";
211
211
  import { randomUUID } from "crypto";
212
212
  var TELEGRAM_API = "https://api.telegram.org/bot";
213
- var TelegramBot = class {
213
+ var TelegramBot = class _TelegramBot {
214
214
  config;
215
215
  offset = 0;
216
216
  polling = false;
@@ -442,33 +442,45 @@ ${this.mdToHtml(message)}`;
442
442
  }
443
443
  /** Send a permission request with inline buttons */
444
444
  async sendPermissionRequest(sessionId, sessionLabel, requestId, toolName, description, inputPreview) {
445
+ const isNotify = toolName.endsWith("__notify") || toolName === "notify";
445
446
  let preview = inputPreview;
447
+ let kind = "code";
448
+ let lang = "";
446
449
  let truncated = false;
447
450
  try {
448
451
  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) {
452
+ if (p.command) {
453
+ preview = `$ ${p.command}`;
454
+ lang = "bash";
455
+ } else if (p.title && p.message) {
456
+ preview = p.message;
457
+ kind = "prose";
458
+ } else if (p.file_path) {
452
459
  preview = p.file_path;
460
+ lang = this.langOf(p.file_path);
453
461
  if (p.content) {
454
462
  preview += "\n" + p.content.slice(0, 3e3);
455
463
  if (p.content.length > 500) truncated = true;
456
464
  }
457
465
  } else if (p.content && typeof p.content === "string") {
458
466
  preview = p.content.slice(0, 3e3);
467
+ if (isNotify) kind = "prose";
459
468
  if (p.content.length > 500) truncated = true;
460
469
  }
461
470
  } catch {
462
471
  truncated = true;
463
472
  const cmdMatch = inputPreview.match(/"command"\s*:\s*"((?:[^"\\]|\\.)*)"/);
464
473
  const contentMatch = inputPreview.match(/"content"\s*:\s*"((?:[^"\\]|\\.)*)"/);
465
- if (cmdMatch) preview = `$ ${cmdMatch[1]}`;
466
- else if (contentMatch) preview = contentMatch[1].slice(0, 3e3);
474
+ if (cmdMatch) {
475
+ preview = `$ ${cmdMatch[1]}`;
476
+ lang = "bash";
477
+ } else if (contentMatch) preview = contentMatch[1].slice(0, 3e3);
467
478
  }
468
479
  const truncNote = truncated ? "\n\n<i>...truncated</i>" : "";
469
480
  const previewSlice = preview.slice(0, 3e3);
470
481
  const isShort = !previewSlice.includes("\n") && previewSlice.length < 100;
471
- const previewHtml = isShort ? `<code>${this.escHtml(previewSlice)}</code>` : this.escHtml(previewSlice);
482
+ const langAttr = lang ? ` class="language-${lang}"` : "";
483
+ const previewHtml = kind === "prose" ? this.mdToHtml(previewSlice) : isShort ? `<code>${this.escHtml(previewSlice)}</code>` : `<pre><code${langAttr}>${this.escHtml(previewSlice)}</code></pre>`;
472
484
  let displayTool = toolName;
473
485
  if ((toolName.endsWith("__notify") || toolName === "notify") && inputPreview) {
474
486
  try {
@@ -568,6 +580,40 @@ ${label}`);
568
580
  escHtml(s) {
569
581
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
570
582
  }
583
+ static LANG_BY_EXT = {
584
+ js: "javascript",
585
+ mjs: "javascript",
586
+ cjs: "javascript",
587
+ jsx: "javascript",
588
+ ts: "typescript",
589
+ tsx: "typescript",
590
+ py: "python",
591
+ rb: "ruby",
592
+ go: "go",
593
+ java: "java",
594
+ kt: "kotlin",
595
+ rs: "rust",
596
+ php: "php",
597
+ cs: "csharp",
598
+ sh: "bash",
599
+ bash: "bash",
600
+ zsh: "bash",
601
+ ps1: "powershell",
602
+ json: "json",
603
+ yml: "yaml",
604
+ yaml: "yaml",
605
+ xml: "xml",
606
+ sql: "sql",
607
+ md: "markdown",
608
+ html: "html",
609
+ vue: "html",
610
+ css: "css",
611
+ scss: "scss"
612
+ };
613
+ langOf(path5) {
614
+ const m = /\.([a-z0-9]+)$/i.exec(path5 || "");
615
+ return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
616
+ }
571
617
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
572
618
  mdToHtml(s) {
573
619
  let text = s.replace(
@@ -653,6 +699,54 @@ function saveConfig(config) {
653
699
  fs2.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
654
700
  }
655
701
 
702
+ // src/shared/crash-guard.ts
703
+ import fs3 from "fs";
704
+ var BURST_WINDOW_MS = 6e4;
705
+ var BURST_LIMIT = 10;
706
+ function appendToLog(text) {
707
+ try {
708
+ fs3.mkdirSync(CONFIG_DIR, { recursive: true });
709
+ fs3.appendFileSync(LOG_FILE, text, "utf-8");
710
+ } catch {
711
+ }
712
+ }
713
+ function describe(err) {
714
+ if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
715
+ try {
716
+ return JSON.stringify(err);
717
+ } catch {
718
+ return String(err);
719
+ }
720
+ }
721
+ function installCrashGuard(context) {
722
+ let recent = [];
723
+ const handle = (kind, err) => {
724
+ const stack = describe(err);
725
+ appendToLog(`
726
+ [${(/* @__PURE__ */ new Date()).toISOString()}] ${kind} (${context})
727
+ ${stack}
728
+ `);
729
+ logger.error(`${kind} caught by crash guard: ${stack}`);
730
+ const now = Date.now();
731
+ recent = recent.filter((t) => now - t < BURST_WINDOW_MS);
732
+ recent.push(now);
733
+ if (recent.length > BURST_LIMIT) {
734
+ const msg = `${recent.length} unhandled errors within ${BURST_WINDOW_MS / 1e3}s \u2014 exiting`;
735
+ appendToLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
736
+ `);
737
+ logger.error(msg);
738
+ process.exit(1);
739
+ }
740
+ };
741
+ process.on("uncaughtException", (err) => handle("uncaughtException", err));
742
+ process.on("unhandledRejection", (reason) => handle("unhandledRejection", reason));
743
+ }
744
+ function logStartup(context) {
745
+ appendToLog(`
746
+ [${(/* @__PURE__ */ new Date()).toISOString()}] ${context} started (pid ${process.pid})
747
+ `);
748
+ }
749
+
656
750
  // src/hub/server.ts
657
751
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
658
752
  var HubServer = class {
@@ -696,9 +790,12 @@ var HubServer = class {
696
790
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
697
791
  this.wssChannel = new WebSocketServer({ noServer: true });
698
792
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
793
+ this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
699
794
  this.wssDashboard = new WebSocketServer({ noServer: true });
700
795
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
796
+ this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
701
797
  this.httpServer.on("upgrade", (req, socket, head) => {
798
+ socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
702
799
  const url = new URL(req.url, `http://${req.headers.host}`);
703
800
  const pathname = url.pathname;
704
801
  if (this.token && !this.isLocalRequest(req)) {
@@ -725,8 +822,11 @@ var HubServer = class {
725
822
  this.cleanupUploads();
726
823
  this.startHeartbeat();
727
824
  return new Promise((resolve, reject) => {
728
- this.httpServer.on("error", reject);
825
+ const onStartupError = (err) => reject(err);
826
+ this.httpServer.once("error", onStartupError);
729
827
  this.httpServer.listen(this.port, this.host, () => {
828
+ this.httpServer.removeListener("error", onStartupError);
829
+ this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
730
830
  const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
731
831
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
732
832
  resolve();
@@ -847,8 +947,8 @@ var HubServer = class {
847
947
  ];
848
948
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
849
949
  for (const candidate of candidates) {
850
- if (fs3.existsSync(candidate)) {
851
- const html = fs3.readFileSync(candidate, "utf-8");
950
+ if (fs4.existsSync(candidate)) {
951
+ const html = fs4.readFileSync(candidate, "utf-8");
852
952
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
853
953
  res.end(html);
854
954
  return;
@@ -895,6 +995,10 @@ var HubServer = class {
895
995
  handleChannelConnection(ws, req) {
896
996
  const isLocal = this.isLocalRequest(req);
897
997
  logger.info(`Channel server connected (local: ${isLocal})`);
998
+ ws.on("error", (err) => {
999
+ logger.warn(`Channel WebSocket error: ${err.message}`);
1000
+ ws.terminate();
1001
+ });
898
1002
  ws.on("pong", () => {
899
1003
  for (const [sessionId, sock] of this.channelSockets) {
900
1004
  if (sock === ws) {
@@ -1004,6 +1108,10 @@ var HubServer = class {
1004
1108
  handleDashboardConnection(ws) {
1005
1109
  this.dashboardSockets.add(ws);
1006
1110
  logger.info(`Dashboard connected (total: ${this.dashboardSockets.size})`);
1111
+ ws.on("error", (err) => {
1112
+ logger.warn(`Dashboard WebSocket error: ${err.message}`);
1113
+ ws.terminate();
1114
+ });
1007
1115
  const sessionsMsg = {
1008
1116
  type: "sessions_list",
1009
1117
  sessions: this.sessions.getAll()
@@ -1060,11 +1168,11 @@ var HubServer = class {
1060
1168
  logger.warn("Image upload rejected: exceeds 10MB");
1061
1169
  return;
1062
1170
  }
1063
- fs3.mkdirSync(UPLOADS_DIR, { recursive: true });
1171
+ fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
1064
1172
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1065
1173
  const filename = `${randomUUID3()}.${ext}`;
1066
1174
  const filePath = path4.join(UPLOADS_DIR, filename);
1067
- fs3.writeFileSync(filePath, buffer);
1175
+ fs4.writeFileSync(filePath, buffer);
1068
1176
  const forwardMsg = {
1069
1177
  type: "image_to_session",
1070
1178
  sessionId,
@@ -1077,7 +1185,7 @@ var HubServer = class {
1077
1185
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1078
1186
  setTimeout(() => {
1079
1187
  try {
1080
- fs3.unlinkSync(filePath);
1188
+ fs4.unlinkSync(filePath);
1081
1189
  } catch {
1082
1190
  }
1083
1191
  }, 5 * 60 * 1e3);
@@ -1247,11 +1355,11 @@ var HubServer = class {
1247
1355
  }
1248
1356
  cleanupUploads() {
1249
1357
  try {
1250
- if (!fs3.existsSync(UPLOADS_DIR)) return;
1251
- const files = fs3.readdirSync(UPLOADS_DIR);
1358
+ if (!fs4.existsSync(UPLOADS_DIR)) return;
1359
+ const files = fs4.readdirSync(UPLOADS_DIR);
1252
1360
  for (const file of files) {
1253
1361
  try {
1254
- fs3.unlinkSync(path4.join(UPLOADS_DIR, file));
1362
+ fs4.unlinkSync(path4.join(UPLOADS_DIR, file));
1255
1363
  } catch {
1256
1364
  }
1257
1365
  }
@@ -1295,6 +1403,8 @@ var HubServer = class {
1295
1403
  }
1296
1404
  };
1297
1405
  if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.argv[1].endsWith("hub/server.ts"))) {
1406
+ installCrashGuard("hub daemon");
1407
+ logStartup("Hub daemon");
1298
1408
  const config = loadConfig();
1299
1409
  const hub = new HubServer(config);
1300
1410
  hub.start().catch((err) => {