@delt/claude-alarm 1.3.0 → 1.4.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.
@@ -1286,13 +1286,20 @@
1286
1286
  }
1287
1287
 
1288
1288
  function showUploadRejected(msg) {
1289
- state.waitingReply[msg.sessionId] = false;
1290
- const label = msg.withText ? 'Image and message not delivered' : 'Image not delivered';
1291
- if (state.selectedSession === msg.sessionId) {
1289
+ showNotDelivered(msg.sessionId, msg.withText ? 'Image and message not delivered' : 'Image not delivered', msg.reason);
1290
+ }
1291
+
1292
+ function showMessageRejected(msg) {
1293
+ showNotDelivered(msg.sessionId, 'Message not delivered', msg.reason);
1294
+ }
1295
+
1296
+ function showNotDelivered(sessionId, label, reason) {
1297
+ state.waitingReply[sessionId] = false;
1298
+ if (state.selectedSession === sessionId) {
1292
1299
  renderMessages();
1293
- showMentionError(`${label}: ${msg.reason}`);
1300
+ showMentionError(`${label}: ${reason}`);
1294
1301
  }
1295
- state.notifications.unshift({ sessionId: msg.sessionId, title: label, message: msg.reason, level: 'warning', time: Date.now() });
1302
+ state.notifications.unshift({ sessionId, title: label, message: reason, level: 'warning', time: Date.now() });
1296
1303
  renderNotifications();
1297
1304
  }
1298
1305
 
@@ -1462,32 +1469,15 @@
1462
1469
  case 'upload_rejected':
1463
1470
  showUploadRejected(msg);
1464
1471
  break;
1472
+ case 'message_rejected':
1473
+ showMessageRejected(msg);
1474
+ break;
1465
1475
  case 'notification':
1466
1476
  state.notifications.unshift({ sessionId: msg.sessionId, title: msg.title, message: msg.message, level: msg.level || 'info', time: msg.timestamp });
1467
1477
  renderNotifications();
1468
1478
  break;
1469
1479
  case 'permission_request':
1470
- if (!state.permissionRequests[msg.sessionId]) state.permissionRequests[msg.sessionId] = [];
1471
- state.permissionRequests[msg.sessionId].unshift({
1472
- requestId: msg.requestId,
1473
- toolName: msg.toolName,
1474
- description: msg.description,
1475
- inputPreview: msg.inputPreview,
1476
- timestamp: msg.timestamp,
1477
- resolved: false,
1478
- behavior: null,
1479
- choices: msg.choices || null,
1480
- sent: null,
1481
- outcome: null,
1482
- });
1483
- // Auto-select session if none selected
1484
- if (!state.selectedSession && state.sessions[msg.sessionId]) selectSession(msg.sessionId);
1485
- if (state.selectedSession === msg.sessionId) { renderMessages(); renderPermissionBar(); }
1486
- // Also show in notifications with permission info
1487
- state.notifications.unshift({ sessionId: msg.sessionId, title: 'Permission Request', message: `${msg.toolName}: ${msg.description}`, level: 'warning', time: msg.timestamp, permRequestId: msg.requestId });
1488
- renderNotifications();
1489
- // Play notification sound effect via title flash
1490
- flashTitle('Permission Request');
1480
+ showPermissionRequest(msg);
1491
1481
  break;
1492
1482
  case 'permission_response':
1493
1483
  // Another source (Telegram, other dashboard) resolved a permission request
@@ -1501,11 +1491,7 @@
1501
1491
  }
1502
1492
  break;
1503
1493
  case 'permission_pending':
1504
- for (const m of applyPendingChoices(state.permissionRequests, msg.requests || [])) {
1505
- handleMessage({ type: 'permission_request', ...m });
1506
- }
1507
- renderPermissionBar();
1508
- renderNotifications();
1494
+ restorePendingRequests(msg.requests || []);
1509
1495
  break;
1510
1496
  case 'codex_adapters': {
1511
1497
  state.codexAdapters = msg.adapters || [];
@@ -2128,6 +2114,46 @@
2128
2114
  return missing;
2129
2115
  }
2130
2116
 
2117
+ function addPermissionRequest(msg) {
2118
+ if (!state.permissionRequests[msg.sessionId]) state.permissionRequests[msg.sessionId] = [];
2119
+ state.permissionRequests[msg.sessionId].unshift({
2120
+ requestId: msg.requestId,
2121
+ toolName: msg.toolName,
2122
+ description: msg.description,
2123
+ inputPreview: msg.inputPreview,
2124
+ timestamp: msg.timestamp,
2125
+ resolved: false,
2126
+ behavior: null,
2127
+ choices: msg.choices || null,
2128
+ sent: null,
2129
+ outcome: null,
2130
+ });
2131
+ if (!state.selectedSession && state.sessions[msg.sessionId]) selectSession(msg.sessionId);
2132
+ if (state.selectedSession === msg.sessionId) { renderMessages(); renderPermissionBar(); }
2133
+ }
2134
+
2135
+ function showPermissionRequest(msg) {
2136
+ addPermissionRequest(msg);
2137
+ state.notifications.unshift({ sessionId: msg.sessionId, title: 'Permission Request', message: `${msg.toolName}: ${msg.description}`, level: 'warning', time: msg.timestamp, permRequestId: msg.requestId });
2138
+ renderNotifications();
2139
+ flashTitle('Permission Request');
2140
+ }
2141
+
2142
+ // A reconnect can bring back many requests at once; one row and one flash keep them from burying the notifications.
2143
+ function restorePendingRequests(requests) {
2144
+ const restored = applyPendingChoices(state.permissionRequests, requests);
2145
+ for (const m of restored) addPermissionRequest(m);
2146
+ if (restored.length) {
2147
+ const label = `${restored.length} approval request(s) waiting`;
2148
+ const listed = restored.slice(0, 3).map(m => `${m.toolName}: ${m.description}`).join(' · ');
2149
+ const message = restored.length > 3 ? `${listed} …and ${restored.length - 3} more` : listed;
2150
+ state.notifications.unshift({ sessionId: restored[0].sessionId, title: label, message, level: 'warning', time: Date.now() });
2151
+ flashTitle(label);
2152
+ }
2153
+ renderPermissionBar();
2154
+ renderNotifications();
2155
+ }
2156
+
2131
2157
  function sendPermissionChoice(sessionId, requestId, choiceId) {
2132
2158
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
2133
2159
  state.ws.send(JSON.stringify({ type: 'permission_response', sessionId, requestId, choiceId }));
@@ -2211,23 +2237,28 @@
2211
2237
  }, true);
2212
2238
 
2213
2239
  // Flash title for attention
2214
- let flashInterval = null;
2240
+ let flash = null;
2215
2241
  function flashTitle(msg) {
2216
- const original = document.title;
2217
- let on = true;
2218
- clearInterval(flashInterval);
2219
- flashInterval = setInterval(() => {
2220
- document.title = on ? `** ${msg} **` : original;
2221
- on = !on;
2222
- }, 500);
2223
- const stopFlash = () => {
2224
- clearInterval(flashInterval);
2225
- flashInterval = null;
2226
- document.title = original;
2227
- window.removeEventListener('focus', stopFlash);
2242
+ // While flashing, document.title may hold the flash text, so only a flash that starts from rest reads the real title.
2243
+ if (flash) {
2244
+ flash.msg = msg;
2245
+ return;
2246
+ }
2247
+ const current = { msg, original: document.title, on: true, interval: null, timeout: null };
2248
+ const stop = () => {
2249
+ clearInterval(current.interval);
2250
+ clearTimeout(current.timeout);
2251
+ window.removeEventListener('focus', stop);
2252
+ document.title = current.original;
2253
+ flash = null;
2228
2254
  };
2229
- window.addEventListener('focus', stopFlash);
2230
- setTimeout(stopFlash, 30000);
2255
+ current.interval = setInterval(() => {
2256
+ document.title = current.on ? `** ${current.msg} **` : current.original;
2257
+ current.on = !current.on;
2258
+ }, 500);
2259
+ current.timeout = setTimeout(stop, 30000);
2260
+ window.addEventListener('focus', stop);
2261
+ flash = current;
2231
2262
  }
2232
2263
 
2233
2264
  // Clear all notifications
@@ -249,6 +249,7 @@ var MAX_SELECTIONS = 20;
249
249
  var MAX_VISIBLE_CHARS = 4e3;
250
250
  var TRUNCATED = "\u2026(truncated)";
251
251
  var MAX_PHOTO_BYTES = 10 * 1024 * 1024;
252
+ var NO_LONGER_CONNECTED = "the session is no longer connected";
252
253
  function visibleLength(html) {
253
254
  return html.replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt);/g, "&").length;
254
255
  }
@@ -422,13 +423,7 @@ ${TRUNCATED}`);
422
423
  this.selections.delete(latest);
423
424
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
424
425
  if (session) {
425
- let delivered = true;
426
- if (pending.photoFileId) {
427
- delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
428
- } else if (pending.text) {
429
- this.deliverToSession(session.id, pending.text);
430
- }
431
- if (delivered) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
426
+ if (await this.deliverPending(session.id, pending)) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
432
427
  } else {
433
428
  this.sendMessage("Invalid session number.");
434
429
  }
@@ -470,9 +465,13 @@ ${TRUNCATED}`);
470
465
  this.sendMessage("Multiple sessions active. Select one:", void 0, { inline_keyboard: rows });
471
466
  }
472
467
  deliverToSession(sessionId, content) {
473
- if (this.onMessageToSession) {
474
- this.onMessageToSession(sessionId, content);
475
- }
468
+ const delivered = this.onMessageToSession?.(sessionId, content) === true;
469
+ if (!delivered) void this.sendMessage(`Not delivered: ${NO_LONGER_CONNECTED}`);
470
+ return delivered;
471
+ }
472
+ async deliverPending(sessionId, pending) {
473
+ if (pending.photoFileId) return this.deliverPhotoToSessionByFileId(sessionId, pending.photoFileId, pending.caption);
474
+ return pending.text ? this.deliverToSession(sessionId, pending.text) : false;
476
475
  }
477
476
  async deliverPhotoToSession(sessionId, photos, caption) {
478
477
  const largest = photos[photos.length - 1];
@@ -504,8 +503,19 @@ ${TRUNCATED}`);
504
503
  const filePath = path2.join(UPLOADS_DIR, filename);
505
504
  fs.writeFileSync(filePath, buffer);
506
505
  logger.info(`Telegram photo saved: ${filename} (${buffer.length} bytes)`);
507
- if (this.onImageToSession) {
508
- this.onImageToSession(sessionId, filePath, mimeType, caption);
506
+ let accepted = false;
507
+ try {
508
+ accepted = this.onImageToSession?.(sessionId, filePath, mimeType, caption) === true;
509
+ } catch (err) {
510
+ logger.warn(`Telegram photo hand-off failed: ${err.message}`);
511
+ }
512
+ if (!accepted) {
513
+ try {
514
+ fs.unlinkSync(filePath);
515
+ } catch {
516
+ }
517
+ this.photoNotDelivered(NO_LONGER_CONNECTED);
518
+ return false;
509
519
  }
510
520
  setTimeout(() => {
511
521
  try {
@@ -513,7 +523,7 @@ ${TRUNCATED}`);
513
523
  } catch {
514
524
  }
515
525
  }, 5 * 60 * 1e3).unref();
516
- return this.onImageToSession !== void 0;
526
+ return true;
517
527
  } catch (err) {
518
528
  logger.warn(`Telegram photo download failed: ${err.message}`);
519
529
  this.photoNotDelivered("the download failed");
@@ -708,15 +718,10 @@ ${label}`);
708
718
  return;
709
719
  }
710
720
  this.selections.delete(selectionId);
711
- if (pending.photoFileId) {
712
- const delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
713
- if (!delivered) {
714
- await this.answerCallbackQuery(query.id, "Photo not delivered");
715
- if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
716
- return;
717
- }
718
- } else if (pending.text) {
719
- this.deliverToSession(session.id, pending.text);
721
+ if (!await this.deliverPending(session.id, pending)) {
722
+ await this.answerCallbackQuery(query.id, pending.photoFileId ? "Photo not delivered" : "Not delivered");
723
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
724
+ return;
720
725
  }
721
726
  await this.answerCallbackQuery(query.id, `Sent to ${this.getLabel(session)}`);
722
727
  if (query.message) {
@@ -841,6 +846,11 @@ ${bodyLines.join("\n")}`;
841
846
  import { spawn } from "child_process";
842
847
  import fs2 from "fs";
843
848
  import path3 from "path";
849
+
850
+ // src/codex/shutdown.ts
851
+ var SUPERVISOR_STOP_GRACE_MS = 3e4;
852
+
853
+ // src/hub/codex-supervisor.ts
844
854
  var HEALTHY_RUN_MS = 6e4;
845
855
  function resolveAdapterScript(baseDir) {
846
856
  return [path3.join(baseDir, "..", "codex", "main.js"), path3.join(baseDir, "codex", "main.js")].find((p) => fs2.existsSync(p));
@@ -856,7 +866,7 @@ var CodexSupervisor = class {
856
866
  this.spawnFn = opts.spawnFn ?? spawn;
857
867
  this.minDelayMs = opts.minDelayMs ?? 2e3;
858
868
  this.maxDelayMs = opts.maxDelayMs ?? 6e4;
859
- this.stopGraceMs = opts.stopGraceMs ?? 3e3;
869
+ this.stopGraceMs = opts.stopGraceMs ?? SUPERVISOR_STOP_GRACE_MS;
860
870
  this.env = opts.env;
861
871
  this.delay = this.minDelayMs;
862
872
  }
@@ -879,11 +889,22 @@ var CodexSupervisor = class {
879
889
  this.timer = void 0;
880
890
  const child = this.child;
881
891
  this.child = void 0;
882
- if (!child) return;
883
- child.stdin?.end();
884
- const force = setTimeout(() => child.kill(), this.stopGraceMs);
885
- force.unref();
886
- child.once("exit", () => clearTimeout(force));
892
+ if (!child) return Promise.resolve();
893
+ return new Promise((resolve) => {
894
+ const done = () => {
895
+ clearTimeout(force);
896
+ child.removeListener("exit", done);
897
+ resolve();
898
+ };
899
+ const force = setTimeout(() => {
900
+ logger.warn(`Codex adapter did not exit within ${this.stopGraceMs}ms of stop; killing it`);
901
+ child.kill();
902
+ done();
903
+ }, this.stopGraceMs);
904
+ force.unref();
905
+ child.once("exit", done);
906
+ child.stdin?.end();
907
+ });
887
908
  }
888
909
  launch() {
889
910
  const startedAt = Date.now();
@@ -1193,9 +1214,9 @@ var HubServer = class {
1193
1214
  });
1194
1215
  }
1195
1216
  stop() {
1196
- return new Promise((resolve) => {
1197
- this.codexSupervisor?.stop();
1198
- this.codexSupervisor = void 0;
1217
+ const adapterStopped = this.codexSupervisor?.stop();
1218
+ this.codexSupervisor = void 0;
1219
+ const serverStopped = new Promise((resolve) => {
1199
1220
  if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
1200
1221
  if (this.telegramBot) this.telegramBot.stopPolling();
1201
1222
  for (const ws of this.channelSockets.values()) ws.terminate();
@@ -1215,6 +1236,8 @@ var HubServer = class {
1215
1236
  resolve();
1216
1237
  }, 3e3);
1217
1238
  });
1239
+ return Promise.all([serverStopped, adapterStopped]).then(() => {
1240
+ });
1218
1241
  }
1219
1242
  // --- HTTP Handler ---
1220
1243
  handleHttp(req, res) {
@@ -1471,7 +1494,16 @@ var HubServer = class {
1471
1494
  this.sessions.updateActivity(msg.sessionId);
1472
1495
  const notifySession = this.sessions.get(msg.sessionId);
1473
1496
  const notifyLabel = this.getSessionLabel(notifySession);
1474
- this.notifier.notifyWithSession(msg.sessionId, notifyLabel, `[${notifyLabel}] ${msg.title}`, msg.message, msg.level ?? "info");
1497
+ const notifyTitle = `[${notifyLabel}] ${msg.title}`;
1498
+ if (msg.to === "api") {
1499
+ logger.info(`${notifyTitle}: ${msg.message}`);
1500
+ break;
1501
+ }
1502
+ if (msg.to === "telegram") {
1503
+ void this.telegramBot?.sendNotification(msg.sessionId, notifyLabel, notifyTitle, msg.message);
1504
+ break;
1505
+ }
1506
+ if (msg.to !== "dashboard") this.notifier.notifyWithSession(msg.sessionId, notifyLabel, notifyTitle, msg.message, msg.level ?? "info");
1475
1507
  this.broadcastToDashboards({
1476
1508
  type: "notification",
1477
1509
  sessionId: msg.sessionId,
@@ -1673,6 +1705,11 @@ var HubServer = class {
1673
1705
  const channelWs = this.channelSockets.get(msg.sessionId);
1674
1706
  if (channelWs?.readyState === WebSocket.OPEN) {
1675
1707
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1708
+ } else {
1709
+ const reason = "the session is not connected";
1710
+ logger.warn(`Message rejected for ${msg.sessionId}: ${reason}`);
1711
+ const rejected = { type: "message_rejected", sessionId: msg.sessionId, reason };
1712
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1676
1713
  }
1677
1714
  } else if (msg.type === "image_upload") {
1678
1715
  this.handleImageUpload(ws, msg);
@@ -1798,19 +1835,25 @@ var HubServer = class {
1798
1835
  this.telegramBot.getSessions = () => this.sessions.getAll();
1799
1836
  this.telegramBot.onMessageToSession = (sessionId, content) => {
1800
1837
  const channelWs = this.channelSockets.get(sessionId);
1801
- if (channelWs?.readyState === WebSocket.OPEN) {
1802
- const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1803
- channelWs.send(JSON.stringify(msg));
1804
- logger.info(`Telegram message forwarded to session: ${sessionId}`);
1838
+ if (channelWs?.readyState !== WebSocket.OPEN) {
1839
+ logger.warn(`Telegram message not delivered to ${sessionId}: the session is not connected`);
1840
+ return false;
1805
1841
  }
1842
+ const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1843
+ channelWs.send(JSON.stringify(msg));
1844
+ logger.info(`Telegram message forwarded to session: ${sessionId}`);
1845
+ return true;
1806
1846
  };
1807
1847
  this.telegramBot.onImageToSession = (sessionId, imagePath, mimeType, caption) => {
1808
1848
  const channelWs = this.channelSockets.get(sessionId);
1809
- if (channelWs?.readyState === WebSocket.OPEN) {
1810
- const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1811
- channelWs.send(JSON.stringify(msg));
1812
- logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1849
+ if (channelWs?.readyState !== WebSocket.OPEN) {
1850
+ logger.warn(`Telegram photo not delivered to ${sessionId}: the session is not connected`);
1851
+ return false;
1813
1852
  }
1853
+ const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1854
+ channelWs.send(JSON.stringify(msg));
1855
+ logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1856
+ return true;
1814
1857
  };
1815
1858
  this.telegramBot.onPermissionVerdict = (sessionId, requestId, behavior) => {
1816
1859
  if (this.forwardPermissionResponse({ sessionId, requestId, behavior })) {