@delt/claude-alarm 1.2.0 → 1.4.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.
@@ -1285,6 +1285,24 @@
1285
1285
  el.classList.toggle('show', !!msg);
1286
1286
  }
1287
1287
 
1288
+ function showUploadRejected(msg) {
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) {
1299
+ renderMessages();
1300
+ showMentionError(`${label}: ${reason}`);
1301
+ }
1302
+ state.notifications.unshift({ sessionId, title: label, message: reason, level: 'warning', time: Date.now() });
1303
+ renderNotifications();
1304
+ }
1305
+
1288
1306
  let mentionState = { open: false, items: [], index: 0, start: -1 };
1289
1307
 
1290
1308
  function currentMentionQuery(input) {
@@ -1448,32 +1466,18 @@
1448
1466
  state.notifications.unshift({ sessionId: msg.sessionId, title: 'Reply', message: msg.content.slice(0, 100), level: 'info', time: msg.timestamp });
1449
1467
  renderNotifications();
1450
1468
  break;
1469
+ case 'upload_rejected':
1470
+ showUploadRejected(msg);
1471
+ break;
1472
+ case 'message_rejected':
1473
+ showMessageRejected(msg);
1474
+ break;
1451
1475
  case 'notification':
1452
1476
  state.notifications.unshift({ sessionId: msg.sessionId, title: msg.title, message: msg.message, level: msg.level || 'info', time: msg.timestamp });
1453
1477
  renderNotifications();
1454
1478
  break;
1455
1479
  case 'permission_request':
1456
- if (!state.permissionRequests[msg.sessionId]) state.permissionRequests[msg.sessionId] = [];
1457
- state.permissionRequests[msg.sessionId].unshift({
1458
- requestId: msg.requestId,
1459
- toolName: msg.toolName,
1460
- description: msg.description,
1461
- inputPreview: msg.inputPreview,
1462
- timestamp: msg.timestamp,
1463
- resolved: false,
1464
- behavior: null,
1465
- choices: msg.choices || null,
1466
- sent: null,
1467
- outcome: null,
1468
- });
1469
- // Auto-select session if none selected
1470
- if (!state.selectedSession && state.sessions[msg.sessionId]) selectSession(msg.sessionId);
1471
- if (state.selectedSession === msg.sessionId) { renderMessages(); renderPermissionBar(); }
1472
- // Also show in notifications with permission info
1473
- state.notifications.unshift({ sessionId: msg.sessionId, title: 'Permission Request', message: `${msg.toolName}: ${msg.description}`, level: 'warning', time: msg.timestamp, permRequestId: msg.requestId });
1474
- renderNotifications();
1475
- // Play notification sound effect via title flash
1476
- flashTitle('Permission Request');
1480
+ showPermissionRequest(msg);
1477
1481
  break;
1478
1482
  case 'permission_response':
1479
1483
  // Another source (Telegram, other dashboard) resolved a permission request
@@ -1487,11 +1491,7 @@
1487
1491
  }
1488
1492
  break;
1489
1493
  case 'permission_pending':
1490
- for (const m of applyPendingChoices(state.permissionRequests, msg.requests || [])) {
1491
- handleMessage({ type: 'permission_request', ...m });
1492
- }
1493
- renderPermissionBar();
1494
- renderNotifications();
1494
+ restorePendingRequests(msg.requests || []);
1495
1495
  break;
1496
1496
  case 'codex_adapters': {
1497
1497
  state.codexAdapters = msg.adapters || [];
@@ -2114,6 +2114,46 @@
2114
2114
  return missing;
2115
2115
  }
2116
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
+
2117
2157
  function sendPermissionChoice(sessionId, requestId, choiceId) {
2118
2158
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
2119
2159
  state.ws.send(JSON.stringify({ type: 'permission_response', sessionId, requestId, choiceId }));
@@ -2197,23 +2237,28 @@
2197
2237
  }, true);
2198
2238
 
2199
2239
  // Flash title for attention
2200
- let flashInterval = null;
2240
+ let flash = null;
2201
2241
  function flashTitle(msg) {
2202
- const original = document.title;
2203
- let on = true;
2204
- clearInterval(flashInterval);
2205
- flashInterval = setInterval(() => {
2206
- document.title = on ? `** ${msg} **` : original;
2207
- on = !on;
2208
- }, 500);
2209
- const stopFlash = () => {
2210
- clearInterval(flashInterval);
2211
- flashInterval = null;
2212
- document.title = original;
2213
- 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;
2214
2254
  };
2215
- window.addEventListener('focus', stopFlash);
2216
- 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;
2217
2262
  }
2218
2263
 
2219
2264
  // Clear all notifications
@@ -245,6 +245,14 @@ ${requestId}`;
245
245
  // src/hub/telegram.ts
246
246
  var TELEGRAM_API = "https://api.telegram.org/bot";
247
247
  var MAX_CHOICE_MESSAGES = 200;
248
+ var MAX_SELECTIONS = 20;
249
+ var MAX_VISIBLE_CHARS = 4e3;
250
+ var TRUNCATED = "\u2026(truncated)";
251
+ var MAX_PHOTO_BYTES = 10 * 1024 * 1024;
252
+ var NO_LONGER_CONNECTED = "the session is no longer connected";
253
+ function visibleLength(html) {
254
+ return html.replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt);/g, "&").length;
255
+ }
248
256
  var TelegramBot = class _TelegramBot {
249
257
  config;
250
258
  offset = 0;
@@ -265,9 +273,9 @@ var TelegramBot = class _TelegramBot {
265
273
  choiceMessages = /* @__PURE__ */ new Map();
266
274
  // Callback: get current sessions list
267
275
  getSessions;
268
- // Pending messages for session selection
269
- pendingMessages = /* @__PURE__ */ new Map();
270
- // chatId -> pending
276
+ latestSelection;
277
+ // Keyed by prompt, not by chat: buttons from an older prompt must still send that prompt's message.
278
+ selections = /* @__PURE__ */ new Map();
271
279
  constructor(config) {
272
280
  this.config = config;
273
281
  }
@@ -276,8 +284,7 @@ var TelegramBot = class _TelegramBot {
276
284
  }
277
285
  /** Send a notification message to Telegram */
278
286
  async sendNotification(sessionId, _sessionLabel, title, message) {
279
- const text = `<b>${this.escHtml(title)}</b>
280
- ${this.mdToHtml(message)}`;
287
+ const text = this.fitNotification(title, message);
281
288
  const result = await this.sendMessage(text);
282
289
  if (result?.message_id) {
283
290
  this.messageSessionMap.set(result.message_id, sessionId);
@@ -289,6 +296,25 @@ ${this.mdToHtml(message)}`;
289
296
  }
290
297
  }
291
298
  }
299
+ fitNotification(title, message) {
300
+ const render = (body) => `<b>${this.escHtml(title)}</b>
301
+ ${this.mdToHtml(body)}`;
302
+ const full = render(message);
303
+ if (visibleLength(full) <= MAX_VISIBLE_CHARS) return full;
304
+ const cut = (n) => {
305
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(message[n - 1]) ? n - 1 : n;
306
+ return render(`${message.slice(0, end)}
307
+ ${TRUNCATED}`);
308
+ };
309
+ let lo = 0;
310
+ let hi = message.length;
311
+ while (lo < hi) {
312
+ const mid = Math.ceil((lo + hi) / 2);
313
+ if (visibleLength(cut(mid)) <= MAX_VISIBLE_CHARS) lo = mid;
314
+ else hi = mid - 1;
315
+ }
316
+ return cut(lo);
317
+ }
292
318
  /** Send a text message to the configured chat */
293
319
  async sendMessage(text, replyToMessageId, replyMarkup) {
294
320
  try {
@@ -370,6 +396,13 @@ ${this.mdToHtml(message)}`;
370
396
  const hasPhoto = msg.photo && msg.photo.length > 0;
371
397
  const text = (msg.text || msg.caption || "").trim();
372
398
  if (!text && !hasPhoto) return;
399
+ if (hasPhoto) {
400
+ const size = msg.photo[msg.photo.length - 1].file_size;
401
+ if (size !== void 0 && size > MAX_PHOTO_BYTES) {
402
+ this.photoNotDelivered("it is larger than 10 MB");
403
+ return;
404
+ }
405
+ }
373
406
  if (msg.reply_to_message) {
374
407
  const sessionId = this.messageSessionMap.get(msg.reply_to_message.message_id);
375
408
  if (sessionId) {
@@ -383,18 +416,14 @@ ${this.mdToHtml(message)}`;
383
416
  }
384
417
  if (text) {
385
418
  const selectMatch = text.match(/^\/s_(\d+)$/);
386
- if (selectMatch) {
387
- const pending = this.pendingMessages.get(msg.chat.id);
419
+ const latest = this.latestSelection;
420
+ if (selectMatch && latest !== void 0) {
421
+ const pending = this.selections.get(latest);
388
422
  if (pending) {
389
- this.pendingMessages.delete(msg.chat.id);
423
+ this.selections.delete(latest);
390
424
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
391
425
  if (session) {
392
- if (pending.photoFileId) {
393
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
394
- } else if (pending.text) {
395
- this.deliverToSession(session.id, pending.text);
396
- }
397
- this.sendMessage(`Sent to [${this.getLabel(session)}]`);
426
+ if (await this.deliverPending(session.id, pending)) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
398
427
  } else {
399
428
  this.sendMessage("Invalid session number.");
400
429
  }
@@ -415,16 +444,19 @@ ${this.mdToHtml(message)}`;
415
444
  }
416
445
  return;
417
446
  }
447
+ const selectionId = randomUUID().replace(/-/g, "").slice(0, 8);
448
+ this.latestSelection = selectionId;
418
449
  const sessionIds = sessions.map((s) => s.id);
419
450
  if (hasPhoto) {
420
451
  const largest = msg.photo[msg.photo.length - 1];
421
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
452
+ this.selections.set(selectionId, { photoFileId: largest.file_id, caption: text, sessionIds });
422
453
  } else {
423
- this.pendingMessages.set(msg.chat.id, { text, sessionIds });
454
+ this.selections.set(selectionId, { text, sessionIds });
424
455
  }
456
+ while (this.selections.size > MAX_SELECTIONS) this.selections.delete(this.selections.keys().next().value);
425
457
  const buttons = sessions.map((s, i) => ({
426
458
  text: this.getLabel(s),
427
- callback_data: `sess:${i}:${msg.chat.id}`
459
+ callback_data: `sel:${selectionId}:${i}`
428
460
  }));
429
461
  const rows = [];
430
462
  for (let i = 0; i < buttons.length; i += 2) {
@@ -433,30 +465,37 @@ ${this.mdToHtml(message)}`;
433
465
  this.sendMessage("Multiple sessions active. Select one:", void 0, { inline_keyboard: rows });
434
466
  }
435
467
  deliverToSession(sessionId, content) {
436
- if (this.onMessageToSession) {
437
- this.onMessageToSession(sessionId, content);
438
- }
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;
439
475
  }
440
476
  async deliverPhotoToSession(sessionId, photos, caption) {
441
477
  const largest = photos[photos.length - 1];
442
- await this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
478
+ return this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
443
479
  }
444
480
  async deliverPhotoToSessionByFileId(sessionId, fileId, caption) {
445
481
  try {
446
482
  const fileRes = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
447
- if (!fileRes.ok) {
448
- logger.warn("Failed to get Telegram file info");
449
- return;
483
+ const fileData = fileRes.ok ? await fileRes.json() : void 0;
484
+ if (!fileData?.ok || !fileData.result) {
485
+ this.photoNotDelivered("Telegram did not return the file");
486
+ return false;
450
487
  }
451
- const fileData = await fileRes.json();
452
- if (!fileData.ok) return;
453
488
  const downloadUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${fileData.result.file_path}`;
454
489
  const imgRes = await fetch(downloadUrl);
455
490
  if (!imgRes.ok) {
456
- logger.warn("Failed to download Telegram photo");
457
- return;
491
+ this.photoNotDelivered("the download failed");
492
+ return false;
458
493
  }
459
494
  const buffer = Buffer.from(await imgRes.arrayBuffer());
495
+ if (buffer.length > MAX_PHOTO_BYTES) {
496
+ this.photoNotDelivered("it is larger than 10 MB");
497
+ return false;
498
+ }
460
499
  const ext = fileData.result.file_path.split(".").pop() || "jpg";
461
500
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg";
462
501
  fs.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -464,19 +503,37 @@ ${this.mdToHtml(message)}`;
464
503
  const filePath = path2.join(UPLOADS_DIR, filename);
465
504
  fs.writeFileSync(filePath, buffer);
466
505
  logger.info(`Telegram photo saved: ${filename} (${buffer.length} bytes)`);
467
- if (this.onImageToSession) {
468
- 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;
469
519
  }
470
520
  setTimeout(() => {
471
521
  try {
472
522
  fs.unlinkSync(filePath);
473
523
  } catch {
474
524
  }
475
- }, 5 * 60 * 1e3);
525
+ }, 5 * 60 * 1e3).unref();
526
+ return true;
476
527
  } catch (err) {
477
528
  logger.warn(`Telegram photo download failed: ${err.message}`);
529
+ this.photoNotDelivered("the download failed");
530
+ return false;
478
531
  }
479
532
  }
533
+ photoNotDelivered(reason) {
534
+ logger.warn(`Telegram photo not delivered: ${reason}`);
535
+ void this.sendMessage(`Photo not delivered: ${reason}`);
536
+ }
480
537
  // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
481
538
  pendingSession(pending, idx) {
482
539
  const id = pending.sessionIds[idx];
@@ -590,7 +647,7 @@ ${this.escHtml(description)}` : "") + (slice ? `
590
647
  async handleChoiceCallback(query) {
591
648
  const choice = this.choiceTokens.get(query.data.slice("pc:".length));
592
649
  if (!choice) {
593
- await this.answerCallbackQuery(query.id, "Expired");
650
+ await this.expire(query);
594
651
  return;
595
652
  }
596
653
  this.dropChoiceTokens(choice.sessionId, choice.requestId);
@@ -619,10 +676,14 @@ ${this.escHtml(description)}` : "") + (slice ? `
619
676
  async handleCallbackQuery(query) {
620
677
  if (!query.data) return;
621
678
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
622
- if (query.data.startsWith("sess:")) {
679
+ if (query.data.startsWith("sel:")) {
623
680
  await this.handleSessionSelectCallback(query);
624
681
  return;
625
682
  }
683
+ if (query.data.startsWith("sess:")) {
684
+ await this.expire(query);
685
+ return;
686
+ }
626
687
  if (query.data.startsWith("pc:")) {
627
688
  await this.handleChoiceCallback(query);
628
689
  return;
@@ -645,21 +706,22 @@ ${label}`);
645
706
  }
646
707
  }
647
708
  async handleSessionSelectCallback(query) {
648
- const parts = query.data.split(":");
649
- if (parts.length < 3) return;
650
- const [, idxStr, chatIdStr] = parts;
651
- const chatId = parseInt(chatIdStr, 10);
652
- const pending = this.pendingMessages.get(chatId);
653
- const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
654
- if (!pending || !session) {
709
+ const [, selectionId, idxStr] = query.data.split(":");
710
+ const pending = this.selections.get(selectionId);
711
+ if (!pending) {
712
+ await this.expire(query);
713
+ return;
714
+ }
715
+ const session = this.pendingSession(pending, parseInt(idxStr, 10));
716
+ if (!session) {
655
717
  await this.answerCallbackQuery(query.id, "Session not found");
656
718
  return;
657
719
  }
658
- this.pendingMessages.delete(chatId);
659
- if (pending.photoFileId) {
660
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
661
- } else if (pending.text) {
662
- this.deliverToSession(session.id, pending.text);
720
+ this.selections.delete(selectionId);
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;
663
725
  }
664
726
  await this.answerCallbackQuery(query.id, `Sent to ${this.getLabel(session)}`);
665
727
  if (query.message) {
@@ -677,6 +739,21 @@ ${label}`);
677
739
  logger.warn(`Telegram answerCallbackQuery error: ${err.message}`);
678
740
  }
679
741
  }
742
+ async expire(query) {
743
+ await this.answerCallbackQuery(query.id, "Expired");
744
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
745
+ }
746
+ async removeButtons(chatId, messageId) {
747
+ try {
748
+ await fetch(`${this.apiUrl}/editMessageReplyMarkup`, {
749
+ method: "POST",
750
+ headers: { "Content-Type": "application/json" },
751
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } })
752
+ });
753
+ } catch (err) {
754
+ logger.warn(`Telegram editMessageReplyMarkup error: ${err.message}`);
755
+ }
756
+ }
680
757
  async editMessageText(chatId, messageId, text) {
681
758
  try {
682
759
  await fetch(`${this.apiUrl}/editMessageText`, {
@@ -773,19 +850,30 @@ var HEALTHY_RUN_MS = 6e4;
773
850
  function resolveAdapterScript(baseDir) {
774
851
  return [path3.join(baseDir, "..", "codex", "main.js"), path3.join(baseDir, "codex", "main.js")].find((p) => fs2.existsSync(p));
775
852
  }
853
+ function adapterEnv(base, hub) {
854
+ const env = { ...base, CLAUDE_ALARM_HUB_HOST: hub.host, CLAUDE_ALARM_HUB_PORT: String(hub.port) };
855
+ delete env.CLAUDE_ALARM_HUB_TOKEN;
856
+ return env;
857
+ }
776
858
  var CodexSupervisor = class {
777
- constructor(script, spawnFn = spawn, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
859
+ constructor(script, opts = {}) {
778
860
  this.script = script;
779
- this.spawnFn = spawnFn;
780
- this.minDelayMs = minDelayMs;
781
- this.maxDelayMs = maxDelayMs;
782
- this.stopGraceMs = stopGraceMs;
783
- this.delay = minDelayMs;
861
+ this.spawnFn = opts.spawnFn ?? spawn;
862
+ this.minDelayMs = opts.minDelayMs ?? 2e3;
863
+ this.maxDelayMs = opts.maxDelayMs ?? 6e4;
864
+ this.stopGraceMs = opts.stopGraceMs ?? 3e3;
865
+ this.env = opts.env;
866
+ this.delay = this.minDelayMs;
784
867
  }
785
868
  child;
786
869
  timer;
787
870
  stopped = true;
788
871
  delay;
872
+ spawnFn;
873
+ minDelayMs;
874
+ maxDelayMs;
875
+ stopGraceMs;
876
+ env;
789
877
  start() {
790
878
  this.stopped = false;
791
879
  this.launch();
@@ -807,7 +895,8 @@ var CodexSupervisor = class {
807
895
  const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
808
896
  detached: true,
809
897
  stdio: ["pipe", "pipe", "pipe"],
810
- windowsHide: true
898
+ windowsHide: true,
899
+ ...this.env ? { env: this.env } : {}
811
900
  });
812
901
  this.child = child;
813
902
  child.stdout?.on("data", (d) => process.stdout.write(d));
@@ -1387,7 +1476,16 @@ var HubServer = class {
1387
1476
  this.sessions.updateActivity(msg.sessionId);
1388
1477
  const notifySession = this.sessions.get(msg.sessionId);
1389
1478
  const notifyLabel = this.getSessionLabel(notifySession);
1390
- this.notifier.notifyWithSession(msg.sessionId, notifyLabel, `[${notifyLabel}] ${msg.title}`, msg.message, msg.level ?? "info");
1479
+ const notifyTitle = `[${notifyLabel}] ${msg.title}`;
1480
+ if (msg.to === "api") {
1481
+ logger.info(`${notifyTitle}: ${msg.message}`);
1482
+ break;
1483
+ }
1484
+ if (msg.to === "telegram") {
1485
+ void this.telegramBot?.sendNotification(msg.sessionId, notifyLabel, notifyTitle, msg.message);
1486
+ break;
1487
+ }
1488
+ if (msg.to !== "dashboard") this.notifier.notifyWithSession(msg.sessionId, notifyLabel, notifyTitle, msg.message, msg.level ?? "info");
1391
1489
  this.broadcastToDashboards({
1392
1490
  type: "notification",
1393
1491
  sessionId: msg.sessionId,
@@ -1589,9 +1687,14 @@ var HubServer = class {
1589
1687
  const channelWs = this.channelSockets.get(msg.sessionId);
1590
1688
  if (channelWs?.readyState === WebSocket.OPEN) {
1591
1689
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1690
+ } else {
1691
+ const reason = "the session is not connected";
1692
+ logger.warn(`Message rejected for ${msg.sessionId}: ${reason}`);
1693
+ const rejected = { type: "message_rejected", sessionId: msg.sessionId, reason };
1694
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1592
1695
  }
1593
1696
  } else if (msg.type === "image_upload") {
1594
- this.handleImageUpload(msg);
1697
+ this.handleImageUpload(ws, msg);
1595
1698
  } else if (msg.type === "permission_response") {
1596
1699
  if (this.forwardPermissionResponse(msg)) {
1597
1700
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1642,20 +1745,31 @@ var HubServer = class {
1642
1745
  }
1643
1746
  }
1644
1747
  }
1645
- handleImageUpload(msg) {
1748
+ handleImageUpload(ws, msg) {
1646
1749
  const { sessionId, imageData, mimeType, originalName, content } = msg;
1750
+ const reject = (reason) => {
1751
+ logger.warn(`Image upload rejected for ${sessionId}: ${reason}`);
1752
+ const rejected = { type: "upload_rejected", sessionId, reason, withText: typeof content === "string" && content.trim().length > 0 };
1753
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1754
+ };
1755
+ const channelWs = this.channelSockets.get(sessionId);
1756
+ if (!channelWs || channelWs.readyState !== WebSocket.OPEN) {
1757
+ reject("the session is not connected");
1758
+ return;
1759
+ }
1647
1760
  if (!this.localChannels.has(sessionId)) {
1648
- logger.warn(`Image upload rejected: session ${sessionId} is not local`);
1761
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1649
1762
  return;
1650
1763
  }
1651
- const channelWs = this.channelSockets.get(sessionId);
1652
- if (!channelWs || channelWs.readyState !== WebSocket.OPEN) return;
1653
1764
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1654
- if (!allowedTypes.includes(mimeType)) return;
1765
+ if (!allowedTypes.includes(mimeType)) {
1766
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1767
+ return;
1768
+ }
1655
1769
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1656
1770
  const buffer = Buffer.from(base64Data, "base64");
1657
1771
  if (buffer.length > 10 * 1024 * 1024) {
1658
- logger.warn("Image upload rejected: exceeds 10MB");
1772
+ reject("the image is larger than 10 MB");
1659
1773
  return;
1660
1774
  }
1661
1775
  fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -1703,19 +1817,25 @@ var HubServer = class {
1703
1817
  this.telegramBot.getSessions = () => this.sessions.getAll();
1704
1818
  this.telegramBot.onMessageToSession = (sessionId, content) => {
1705
1819
  const channelWs = this.channelSockets.get(sessionId);
1706
- if (channelWs?.readyState === WebSocket.OPEN) {
1707
- const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1708
- channelWs.send(JSON.stringify(msg));
1709
- logger.info(`Telegram message forwarded to session: ${sessionId}`);
1820
+ if (channelWs?.readyState !== WebSocket.OPEN) {
1821
+ logger.warn(`Telegram message not delivered to ${sessionId}: the session is not connected`);
1822
+ return false;
1710
1823
  }
1824
+ const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1825
+ channelWs.send(JSON.stringify(msg));
1826
+ logger.info(`Telegram message forwarded to session: ${sessionId}`);
1827
+ return true;
1711
1828
  };
1712
1829
  this.telegramBot.onImageToSession = (sessionId, imagePath, mimeType, caption) => {
1713
1830
  const channelWs = this.channelSockets.get(sessionId);
1714
- if (channelWs?.readyState === WebSocket.OPEN) {
1715
- const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1716
- channelWs.send(JSON.stringify(msg));
1717
- logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1831
+ if (channelWs?.readyState !== WebSocket.OPEN) {
1832
+ logger.warn(`Telegram photo not delivered to ${sessionId}: the session is not connected`);
1833
+ return false;
1718
1834
  }
1835
+ const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1836
+ channelWs.send(JSON.stringify(msg));
1837
+ logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1838
+ return true;
1719
1839
  };
1720
1840
  this.telegramBot.onPermissionVerdict = (sessionId, requestId, behavior) => {
1721
1841
  if (this.forwardPermissionResponse({ sessionId, requestId, behavior })) {
@@ -1840,7 +1960,7 @@ var HubServer = class {
1840
1960
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1841
1961
  return;
1842
1962
  }
1843
- this.codexSupervisor = new CodexSupervisor(script);
1963
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1844
1964
  this.codexSupervisor.start();
1845
1965
  logger.info("Codex adapter started");
1846
1966
  }