@delt/claude-alarm 1.1.1 → 1.3.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.
@@ -42,6 +42,7 @@ var CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
42
42
  var CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
43
43
  var WS_PATH_CHANNEL = "/ws/channel";
44
44
  var WS_PATH_DASHBOARD = "/ws/dashboard";
45
+ var WS_PATH_CODEX = "/ws/codex";
45
46
 
46
47
  // src/shared/peer-name.ts
47
48
  var SAFE_PEER_NAME = /^[^\u0000-\u001f\u007f"\\[\]]{1,128}$/;
@@ -175,6 +176,7 @@ var Notifier = class {
175
176
  execFile(
176
177
  "powershell",
177
178
  ["-Command", '(Get-ItemProperty -Path "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\PushNotifications" -Name ToastEnabled -ErrorAction SilentlyContinue).ToastEnabled'],
179
+ { windowsHide: true },
178
180
  (err, stdout) => {
179
181
  if (err) {
180
182
  resolve(true);
@@ -192,7 +194,7 @@ var Notifier = class {
192
194
  logger.warn("Windows notifications are disabled. Opening notification settings...");
193
195
  logger.warn("Please enable notifications for this app, then try again.");
194
196
  if (process.platform === "win32") {
195
- execFile("powershell", ["-Command", "Start-Process ms-settings:notifications"]);
197
+ execFile("powershell", ["-Command", "Start-Process ms-settings:notifications"], { windowsHide: true });
196
198
  }
197
199
  setTimeout(() => {
198
200
  this.notificationSettingsOpened = false;
@@ -243,6 +245,13 @@ ${requestId}`;
243
245
  // src/hub/telegram.ts
244
246
  var TELEGRAM_API = "https://api.telegram.org/bot";
245
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
+ function visibleLength(html) {
253
+ return html.replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt);/g, "&").length;
254
+ }
246
255
  var TelegramBot = class _TelegramBot {
247
256
  config;
248
257
  offset = 0;
@@ -263,9 +272,9 @@ var TelegramBot = class _TelegramBot {
263
272
  choiceMessages = /* @__PURE__ */ new Map();
264
273
  // Callback: get current sessions list
265
274
  getSessions;
266
- // Pending messages for session selection
267
- pendingMessages = /* @__PURE__ */ new Map();
268
- // chatId -> pending
275
+ latestSelection;
276
+ // Keyed by prompt, not by chat: buttons from an older prompt must still send that prompt's message.
277
+ selections = /* @__PURE__ */ new Map();
269
278
  constructor(config) {
270
279
  this.config = config;
271
280
  }
@@ -274,8 +283,7 @@ var TelegramBot = class _TelegramBot {
274
283
  }
275
284
  /** Send a notification message to Telegram */
276
285
  async sendNotification(sessionId, _sessionLabel, title, message) {
277
- const text = `<b>${this.escHtml(title)}</b>
278
- ${this.mdToHtml(message)}`;
286
+ const text = this.fitNotification(title, message);
279
287
  const result = await this.sendMessage(text);
280
288
  if (result?.message_id) {
281
289
  this.messageSessionMap.set(result.message_id, sessionId);
@@ -287,6 +295,25 @@ ${this.mdToHtml(message)}`;
287
295
  }
288
296
  }
289
297
  }
298
+ fitNotification(title, message) {
299
+ const render = (body) => `<b>${this.escHtml(title)}</b>
300
+ ${this.mdToHtml(body)}`;
301
+ const full = render(message);
302
+ if (visibleLength(full) <= MAX_VISIBLE_CHARS) return full;
303
+ const cut = (n) => {
304
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(message[n - 1]) ? n - 1 : n;
305
+ return render(`${message.slice(0, end)}
306
+ ${TRUNCATED}`);
307
+ };
308
+ let lo = 0;
309
+ let hi = message.length;
310
+ while (lo < hi) {
311
+ const mid = Math.ceil((lo + hi) / 2);
312
+ if (visibleLength(cut(mid)) <= MAX_VISIBLE_CHARS) lo = mid;
313
+ else hi = mid - 1;
314
+ }
315
+ return cut(lo);
316
+ }
290
317
  /** Send a text message to the configured chat */
291
318
  async sendMessage(text, replyToMessageId, replyMarkup) {
292
319
  try {
@@ -368,6 +395,13 @@ ${this.mdToHtml(message)}`;
368
395
  const hasPhoto = msg.photo && msg.photo.length > 0;
369
396
  const text = (msg.text || msg.caption || "").trim();
370
397
  if (!text && !hasPhoto) return;
398
+ if (hasPhoto) {
399
+ const size = msg.photo[msg.photo.length - 1].file_size;
400
+ if (size !== void 0 && size > MAX_PHOTO_BYTES) {
401
+ this.photoNotDelivered("it is larger than 10 MB");
402
+ return;
403
+ }
404
+ }
371
405
  if (msg.reply_to_message) {
372
406
  const sessionId = this.messageSessionMap.get(msg.reply_to_message.message_id);
373
407
  if (sessionId) {
@@ -381,18 +415,20 @@ ${this.mdToHtml(message)}`;
381
415
  }
382
416
  if (text) {
383
417
  const selectMatch = text.match(/^\/s_(\d+)$/);
384
- if (selectMatch) {
385
- const pending = this.pendingMessages.get(msg.chat.id);
418
+ const latest = this.latestSelection;
419
+ if (selectMatch && latest !== void 0) {
420
+ const pending = this.selections.get(latest);
386
421
  if (pending) {
387
- this.pendingMessages.delete(msg.chat.id);
422
+ this.selections.delete(latest);
388
423
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
389
424
  if (session) {
425
+ let delivered = true;
390
426
  if (pending.photoFileId) {
391
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
427
+ delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
392
428
  } else if (pending.text) {
393
429
  this.deliverToSession(session.id, pending.text);
394
430
  }
395
- this.sendMessage(`Sent to [${this.getLabel(session)}]`);
431
+ if (delivered) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
396
432
  } else {
397
433
  this.sendMessage("Invalid session number.");
398
434
  }
@@ -413,16 +449,19 @@ ${this.mdToHtml(message)}`;
413
449
  }
414
450
  return;
415
451
  }
452
+ const selectionId = randomUUID().replace(/-/g, "").slice(0, 8);
453
+ this.latestSelection = selectionId;
416
454
  const sessionIds = sessions.map((s) => s.id);
417
455
  if (hasPhoto) {
418
456
  const largest = msg.photo[msg.photo.length - 1];
419
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
457
+ this.selections.set(selectionId, { photoFileId: largest.file_id, caption: text, sessionIds });
420
458
  } else {
421
- this.pendingMessages.set(msg.chat.id, { text, sessionIds });
459
+ this.selections.set(selectionId, { text, sessionIds });
422
460
  }
461
+ while (this.selections.size > MAX_SELECTIONS) this.selections.delete(this.selections.keys().next().value);
423
462
  const buttons = sessions.map((s, i) => ({
424
463
  text: this.getLabel(s),
425
- callback_data: `sess:${i}:${msg.chat.id}`
464
+ callback_data: `sel:${selectionId}:${i}`
426
465
  }));
427
466
  const rows = [];
428
467
  for (let i = 0; i < buttons.length; i += 2) {
@@ -437,24 +476,27 @@ ${this.mdToHtml(message)}`;
437
476
  }
438
477
  async deliverPhotoToSession(sessionId, photos, caption) {
439
478
  const largest = photos[photos.length - 1];
440
- await this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
479
+ return this.deliverPhotoToSessionByFileId(sessionId, largest.file_id, caption);
441
480
  }
442
481
  async deliverPhotoToSessionByFileId(sessionId, fileId, caption) {
443
482
  try {
444
483
  const fileRes = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
445
- if (!fileRes.ok) {
446
- logger.warn("Failed to get Telegram file info");
447
- return;
484
+ const fileData = fileRes.ok ? await fileRes.json() : void 0;
485
+ if (!fileData?.ok || !fileData.result) {
486
+ this.photoNotDelivered("Telegram did not return the file");
487
+ return false;
448
488
  }
449
- const fileData = await fileRes.json();
450
- if (!fileData.ok) return;
451
489
  const downloadUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${fileData.result.file_path}`;
452
490
  const imgRes = await fetch(downloadUrl);
453
491
  if (!imgRes.ok) {
454
- logger.warn("Failed to download Telegram photo");
455
- return;
492
+ this.photoNotDelivered("the download failed");
493
+ return false;
456
494
  }
457
495
  const buffer = Buffer.from(await imgRes.arrayBuffer());
496
+ if (buffer.length > MAX_PHOTO_BYTES) {
497
+ this.photoNotDelivered("it is larger than 10 MB");
498
+ return false;
499
+ }
458
500
  const ext = fileData.result.file_path.split(".").pop() || "jpg";
459
501
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg";
460
502
  fs.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -470,11 +512,18 @@ ${this.mdToHtml(message)}`;
470
512
  fs.unlinkSync(filePath);
471
513
  } catch {
472
514
  }
473
- }, 5 * 60 * 1e3);
515
+ }, 5 * 60 * 1e3).unref();
516
+ return this.onImageToSession !== void 0;
474
517
  } catch (err) {
475
518
  logger.warn(`Telegram photo download failed: ${err.message}`);
519
+ this.photoNotDelivered("the download failed");
520
+ return false;
476
521
  }
477
522
  }
523
+ photoNotDelivered(reason) {
524
+ logger.warn(`Telegram photo not delivered: ${reason}`);
525
+ void this.sendMessage(`Photo not delivered: ${reason}`);
526
+ }
478
527
  // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
479
528
  pendingSession(pending, idx) {
480
529
  const id = pending.sessionIds[idx];
@@ -588,7 +637,7 @@ ${this.escHtml(description)}` : "") + (slice ? `
588
637
  async handleChoiceCallback(query) {
589
638
  const choice = this.choiceTokens.get(query.data.slice("pc:".length));
590
639
  if (!choice) {
591
- await this.answerCallbackQuery(query.id, "Expired");
640
+ await this.expire(query);
592
641
  return;
593
642
  }
594
643
  this.dropChoiceTokens(choice.sessionId, choice.requestId);
@@ -617,10 +666,14 @@ ${this.escHtml(description)}` : "") + (slice ? `
617
666
  async handleCallbackQuery(query) {
618
667
  if (!query.data) return;
619
668
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
620
- if (query.data.startsWith("sess:")) {
669
+ if (query.data.startsWith("sel:")) {
621
670
  await this.handleSessionSelectCallback(query);
622
671
  return;
623
672
  }
673
+ if (query.data.startsWith("sess:")) {
674
+ await this.expire(query);
675
+ return;
676
+ }
624
677
  if (query.data.startsWith("pc:")) {
625
678
  await this.handleChoiceCallback(query);
626
679
  return;
@@ -643,19 +696,25 @@ ${label}`);
643
696
  }
644
697
  }
645
698
  async handleSessionSelectCallback(query) {
646
- const parts = query.data.split(":");
647
- if (parts.length < 3) return;
648
- const [, idxStr, chatIdStr] = parts;
649
- const chatId = parseInt(chatIdStr, 10);
650
- const pending = this.pendingMessages.get(chatId);
651
- const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
652
- if (!pending || !session) {
699
+ const [, selectionId, idxStr] = query.data.split(":");
700
+ const pending = this.selections.get(selectionId);
701
+ if (!pending) {
702
+ await this.expire(query);
703
+ return;
704
+ }
705
+ const session = this.pendingSession(pending, parseInt(idxStr, 10));
706
+ if (!session) {
653
707
  await this.answerCallbackQuery(query.id, "Session not found");
654
708
  return;
655
709
  }
656
- this.pendingMessages.delete(chatId);
710
+ this.selections.delete(selectionId);
657
711
  if (pending.photoFileId) {
658
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
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
+ }
659
718
  } else if (pending.text) {
660
719
  this.deliverToSession(session.id, pending.text);
661
720
  }
@@ -675,6 +734,21 @@ ${label}`);
675
734
  logger.warn(`Telegram answerCallbackQuery error: ${err.message}`);
676
735
  }
677
736
  }
737
+ async expire(query) {
738
+ await this.answerCallbackQuery(query.id, "Expired");
739
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
740
+ }
741
+ async removeButtons(chatId, messageId) {
742
+ try {
743
+ await fetch(`${this.apiUrl}/editMessageReplyMarkup`, {
744
+ method: "POST",
745
+ headers: { "Content-Type": "application/json" },
746
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } })
747
+ });
748
+ } catch (err) {
749
+ logger.warn(`Telegram editMessageReplyMarkup error: ${err.message}`);
750
+ }
751
+ }
678
752
  async editMessageText(chatId, messageId, text) {
679
753
  try {
680
754
  await fetch(`${this.apiUrl}/editMessageText`, {
@@ -771,19 +845,30 @@ var HEALTHY_RUN_MS = 6e4;
771
845
  function resolveAdapterScript(baseDir) {
772
846
  return [path3.join(baseDir, "..", "codex", "main.js"), path3.join(baseDir, "codex", "main.js")].find((p) => fs2.existsSync(p));
773
847
  }
848
+ function adapterEnv(base, hub) {
849
+ const env = { ...base, CLAUDE_ALARM_HUB_HOST: hub.host, CLAUDE_ALARM_HUB_PORT: String(hub.port) };
850
+ delete env.CLAUDE_ALARM_HUB_TOKEN;
851
+ return env;
852
+ }
774
853
  var CodexSupervisor = class {
775
- constructor(script, spawnFn = spawn, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
854
+ constructor(script, opts = {}) {
776
855
  this.script = script;
777
- this.spawnFn = spawnFn;
778
- this.minDelayMs = minDelayMs;
779
- this.maxDelayMs = maxDelayMs;
780
- this.stopGraceMs = stopGraceMs;
781
- this.delay = minDelayMs;
856
+ this.spawnFn = opts.spawnFn ?? spawn;
857
+ this.minDelayMs = opts.minDelayMs ?? 2e3;
858
+ this.maxDelayMs = opts.maxDelayMs ?? 6e4;
859
+ this.stopGraceMs = opts.stopGraceMs ?? 3e3;
860
+ this.env = opts.env;
861
+ this.delay = this.minDelayMs;
782
862
  }
783
863
  child;
784
864
  timer;
785
865
  stopped = true;
786
866
  delay;
867
+ spawnFn;
868
+ minDelayMs;
869
+ maxDelayMs;
870
+ stopGraceMs;
871
+ env;
787
872
  start() {
788
873
  this.stopped = false;
789
874
  this.launch();
@@ -805,7 +890,8 @@ var CodexSupervisor = class {
805
890
  const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
806
891
  detached: true,
807
892
  stdio: ["pipe", "pipe", "pipe"],
808
- windowsHide: true
893
+ windowsHide: true,
894
+ ...this.env ? { env: this.env } : {}
809
895
  });
810
896
  this.child = child;
811
897
  child.stdout?.on("data", (d) => process.stdout.write(d));
@@ -920,6 +1006,21 @@ function logStartup(context) {
920
1006
  `);
921
1007
  }
922
1008
 
1009
+ // src/shared/entry.ts
1010
+ function isEntryScript(argv1, suffixes) {
1011
+ if (!argv1) return false;
1012
+ const normalized = argv1.replace(/\\/g, "/");
1013
+ return suffixes.some((suffix) => normalized.endsWith(suffix));
1014
+ }
1015
+
1016
+ // src/shared/hub-url.ts
1017
+ import net from "net";
1018
+ function hubUrlHost(host) {
1019
+ if (host === "" || host === "0.0.0.0") return "127.0.0.1";
1020
+ if (host === "::") return "[::1]";
1021
+ return net.isIPv6(host) ? `[${host}]` : host;
1022
+ }
1023
+
923
1024
  // src/hub/auth.ts
924
1025
  import crypto from "crypto";
925
1026
  var SESSION_COOKIE = "ca_session";
@@ -985,6 +1086,7 @@ function validChoices(choices) {
985
1086
  const valid = choices.filter((c) => typeof c?.id === "string" && typeof c?.label === "string");
986
1087
  return valid.length ? valid : void 0;
987
1088
  }
1089
+ var nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
988
1090
  var HubServer = class {
989
1091
  httpServer;
990
1092
  wssChannel;
@@ -1010,7 +1112,13 @@ var HubServer = class {
1010
1112
  token;
1011
1113
  codexEnabled;
1012
1114
  codexSupervisor;
1013
- constructor(config) {
1115
+ wssCodex;
1116
+ codexAdapters = /* @__PURE__ */ new Map();
1117
+ codexAlive = /* @__PURE__ */ new WeakMap();
1118
+ codexCallTimeoutMs;
1119
+ codexCalls = /* @__PURE__ */ new Map();
1120
+ constructor(config, options = {}) {
1121
+ this.codexCallTimeoutMs = options.codexCallTimeoutMs ?? 6e4;
1014
1122
  this.host = config?.hub?.host ?? DEFAULT_HUB_HOST;
1015
1123
  this.port = config?.hub?.port ?? DEFAULT_HUB_PORT;
1016
1124
  this.token = config?.hub?.token;
@@ -1023,7 +1131,7 @@ var HubServer = class {
1023
1131
  if (config?.webhooks) {
1024
1132
  this.notifier.configure({ webhooks: config.webhooks });
1025
1133
  }
1026
- const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
1134
+ const displayHost = hubUrlHost(this.host);
1027
1135
  this.notifier.configure({ dashboardUrl: `http://${displayHost}:${this.port}` });
1028
1136
  const fullConfig = loadConfig();
1029
1137
  if (fullConfig.telegram?.enabled && fullConfig.telegram.botToken && fullConfig.telegram.chatId) {
@@ -1036,6 +1144,9 @@ var HubServer = class {
1036
1144
  this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
1037
1145
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
1038
1146
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
1147
+ this.wssCodex = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
1148
+ this.wssCodex.on("connection", (ws, req) => this.handleCodexConnection(ws, req));
1149
+ this.wssCodex.on("error", (err) => logger.warn(`Codex WebSocket server error: ${err.message}`));
1039
1150
  this.httpServer.on("upgrade", (req, socket, head) => {
1040
1151
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
1041
1152
  const url = new URL(req.url ?? "/", "http://hub");
@@ -1056,6 +1167,10 @@ var HubServer = class {
1056
1167
  this.wssDashboard.handleUpgrade(req, socket, head, (ws) => {
1057
1168
  this.wssDashboard.emit("connection", ws, req);
1058
1169
  });
1170
+ } else if (pathname === WS_PATH_CODEX) {
1171
+ this.wssCodex.handleUpgrade(req, socket, head, (ws) => {
1172
+ this.wssCodex.emit("connection", ws, req);
1173
+ });
1059
1174
  } else {
1060
1175
  socket.destroy();
1061
1176
  }
@@ -1070,7 +1185,7 @@ var HubServer = class {
1070
1185
  this.httpServer.listen(this.port, this.host, () => {
1071
1186
  this.httpServer.removeListener("error", onStartupError);
1072
1187
  this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
1073
- const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
1188
+ const displayHost = hubUrlHost(this.host);
1074
1189
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
1075
1190
  this.startCodexAdapter();
1076
1191
  resolve();
@@ -1085,10 +1200,12 @@ var HubServer = class {
1085
1200
  if (this.telegramBot) this.telegramBot.stopPolling();
1086
1201
  for (const ws of this.channelSockets.values()) ws.terminate();
1087
1202
  for (const ws of this.dashboardSockets) ws.terminate();
1203
+ for (const { ws } of this.codexAdapters.values()) ws.terminate();
1088
1204
  this.channelSockets.clear();
1089
1205
  this.dashboardSockets.clear();
1090
1206
  this.wssChannel.close();
1091
1207
  this.wssDashboard.close();
1208
+ this.wssCodex.close();
1092
1209
  this.httpServer.close(() => {
1093
1210
  logger.info("Hub server stopped");
1094
1211
  resolve();
@@ -1153,6 +1270,12 @@ var HubServer = class {
1153
1270
  this.handleApiSend(req, res);
1154
1271
  } else if (url.pathname === "/api/notify" && req.method === "POST") {
1155
1272
  this.handleApiNotify(req, res);
1273
+ } else if (url.pathname === "/api/codex/folders" && req.method === "GET") {
1274
+ this.handleCodexFolders(url, res);
1275
+ } else if (url.pathname === "/api/codex/threads" && req.method === "POST") {
1276
+ this.handleCodexCreate(req, res);
1277
+ } else if (url.pathname === "/api/codex/threads/close" && req.method === "POST") {
1278
+ this.handleCodexClose(req, res);
1156
1279
  } else if (url.pathname === "/api/webhooks" && req.method === "GET") {
1157
1280
  const config = loadConfig();
1158
1281
  this.jsonResponse(res, 200, { webhooks: config.webhooks || [] });
@@ -1416,6 +1539,114 @@ var HubServer = class {
1416
1539
  }
1417
1540
  }
1418
1541
  }
1542
+ handleCodexConnection(ws, req) {
1543
+ const isLocal = this.isLocalRequest(req);
1544
+ let adapterId;
1545
+ ws.on("error", (err) => {
1546
+ logger.warn(`Codex adapter WebSocket error: ${err.message}`);
1547
+ ws.terminate();
1548
+ });
1549
+ ws.on("pong", () => this.codexAlive.set(ws, true));
1550
+ ws.on("message", (data) => {
1551
+ let msg;
1552
+ try {
1553
+ msg = JSON.parse(data.toString());
1554
+ if (msg === null || typeof msg !== "object") {
1555
+ logger.warn("Invalid message from Codex adapter");
1556
+ return;
1557
+ }
1558
+ } catch {
1559
+ logger.warn("Invalid message from Codex adapter");
1560
+ return;
1561
+ }
1562
+ if (msg.type === "adapter_hello") {
1563
+ const a = msg.adapter;
1564
+ if (typeof a?.id !== "string" || !a.id || typeof a.host !== "string") return;
1565
+ if (adapterId && adapterId !== a.id) return;
1566
+ const holder = this.codexAdapters.get(a.id);
1567
+ if (holder && holder.ws !== ws) holder.ws.terminate();
1568
+ if (!adapterId) logger.info(`Codex adapter connected: ${a.host} (${a.id}, local: ${isLocal})`);
1569
+ adapterId = a.id;
1570
+ this.codexAdapters.set(a.id, { ws, info: { id: a.id, host: a.host, ready: a.ready === true, isLocal } });
1571
+ this.broadcastCodexAdapters();
1572
+ } else if (msg.type === "adapter_result" && adapterId) {
1573
+ const call = this.codexCalls.get(msg.requestId);
1574
+ if (!call || call.adapterId !== adapterId) return;
1575
+ call.settle(msg.ok ? { status: 200, body: msg.data } : { status: 422, body: { error: String(msg.error) } });
1576
+ }
1577
+ });
1578
+ ws.on("close", () => {
1579
+ if (!adapterId || this.codexAdapters.get(adapterId)?.ws !== ws) return;
1580
+ this.codexAdapters.delete(adapterId);
1581
+ for (const call of [...this.codexCalls.values()]) {
1582
+ if (call.adapterId === adapterId) call.settle({ status: 502, body: { error: "Codex adapter disconnected" } });
1583
+ }
1584
+ logger.info(`Codex adapter disconnected: ${adapterId}`);
1585
+ this.broadcastCodexAdapters();
1586
+ });
1587
+ }
1588
+ callCodexAdapter(adapterId, call) {
1589
+ const adapter = this.codexAdapters.get(adapterId);
1590
+ if (!adapter || adapter.ws.readyState !== WebSocket.OPEN) {
1591
+ return Promise.resolve({ status: 404, body: { error: "Codex adapter is not connected" } });
1592
+ }
1593
+ const requestId = randomUUID3();
1594
+ return new Promise((resolve) => {
1595
+ const timer = setTimeout(
1596
+ () => settle({ status: 504, body: { error: "Codex did not respond in time. The conversation may still appear." } }),
1597
+ this.codexCallTimeoutMs
1598
+ );
1599
+ const settle = (outcome) => {
1600
+ clearTimeout(timer);
1601
+ this.codexCalls.delete(requestId);
1602
+ resolve(outcome);
1603
+ };
1604
+ this.codexCalls.set(requestId, { adapterId, settle });
1605
+ adapter.ws.send(JSON.stringify({ type: "adapter_call", requestId, call }));
1606
+ });
1607
+ }
1608
+ async handleCodexFolders(url, res) {
1609
+ const adapterId = url.searchParams.get("adapterId");
1610
+ if (!nonEmpty(adapterId)) {
1611
+ this.jsonResponse(res, 400, { error: "adapterId is required" });
1612
+ return;
1613
+ }
1614
+ const out = await this.callCodexAdapter(adapterId, { kind: "folders" });
1615
+ this.jsonResponse(res, out.status, out.body);
1616
+ }
1617
+ async handleCodexCreate(req, res) {
1618
+ const body = await this.readBody(req);
1619
+ const adapterId = body?.adapterId;
1620
+ const cwd = body?.cwd;
1621
+ if (!nonEmpty(adapterId) || !nonEmpty(cwd)) {
1622
+ this.jsonResponse(res, 400, { error: "adapterId and cwd are required" });
1623
+ return;
1624
+ }
1625
+ const out = await this.callCodexAdapter(adapterId, { kind: "create", cwd });
1626
+ this.jsonResponse(res, out.status, out.body);
1627
+ }
1628
+ async handleCodexClose(req, res) {
1629
+ const body = await this.readBody(req);
1630
+ const sessionId = body?.sessionId;
1631
+ if (!nonEmpty(sessionId)) {
1632
+ this.jsonResponse(res, 400, { error: "sessionId is required" });
1633
+ return;
1634
+ }
1635
+ const session = this.sessions.get(sessionId);
1636
+ const ws = this.channelSockets.get(sessionId);
1637
+ if (session?.agentKind !== "codex" || !session.closable || ws?.readyState !== WebSocket.OPEN) {
1638
+ this.jsonResponse(res, 404, { error: "No closable Codex conversation" });
1639
+ return;
1640
+ }
1641
+ ws.send(JSON.stringify({ type: "codex_close", sessionId }));
1642
+ this.jsonResponse(res, 200, { ok: true });
1643
+ }
1644
+ codexAdapterList() {
1645
+ return [...this.codexAdapters.values()].map((a) => a.info);
1646
+ }
1647
+ broadcastCodexAdapters() {
1648
+ this.broadcastToDashboards({ type: "codex_adapters", adapters: this.codexAdapterList() });
1649
+ }
1419
1650
  // --- Dashboard WebSocket ---
1420
1651
  handleDashboardConnection(ws) {
1421
1652
  this.dashboardSockets.add(ws);
@@ -1434,6 +1665,7 @@ var HubServer = class {
1434
1665
  requests: [...this.choiceRequests.values()].map((c) => c.request)
1435
1666
  };
1436
1667
  ws.send(JSON.stringify(pendingMsg));
1668
+ ws.send(JSON.stringify({ type: "codex_adapters", adapters: this.codexAdapterList() }));
1437
1669
  ws.on("message", (data) => {
1438
1670
  try {
1439
1671
  const msg = JSON.parse(data.toString());
@@ -1443,7 +1675,7 @@ var HubServer = class {
1443
1675
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1444
1676
  }
1445
1677
  } else if (msg.type === "image_upload") {
1446
- this.handleImageUpload(msg);
1678
+ this.handleImageUpload(ws, msg);
1447
1679
  } else if (msg.type === "permission_response") {
1448
1680
  if (this.forwardPermissionResponse(msg)) {
1449
1681
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1494,20 +1726,31 @@ var HubServer = class {
1494
1726
  }
1495
1727
  }
1496
1728
  }
1497
- handleImageUpload(msg) {
1729
+ handleImageUpload(ws, msg) {
1498
1730
  const { sessionId, imageData, mimeType, originalName, content } = msg;
1731
+ const reject = (reason) => {
1732
+ logger.warn(`Image upload rejected for ${sessionId}: ${reason}`);
1733
+ const rejected = { type: "upload_rejected", sessionId, reason, withText: typeof content === "string" && content.trim().length > 0 };
1734
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1735
+ };
1736
+ const channelWs = this.channelSockets.get(sessionId);
1737
+ if (!channelWs || channelWs.readyState !== WebSocket.OPEN) {
1738
+ reject("the session is not connected");
1739
+ return;
1740
+ }
1499
1741
  if (!this.localChannels.has(sessionId)) {
1500
- logger.warn(`Image upload rejected: session ${sessionId} is not local`);
1742
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1501
1743
  return;
1502
1744
  }
1503
- const channelWs = this.channelSockets.get(sessionId);
1504
- if (!channelWs || channelWs.readyState !== WebSocket.OPEN) return;
1505
1745
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1506
- if (!allowedTypes.includes(mimeType)) return;
1746
+ if (!allowedTypes.includes(mimeType)) {
1747
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1748
+ return;
1749
+ }
1507
1750
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1508
1751
  const buffer = Buffer.from(base64Data, "base64");
1509
1752
  if (buffer.length > 10 * 1024 * 1024) {
1510
- logger.warn("Image upload rejected: exceeds 10MB");
1753
+ reject("the image is larger than 10 MB");
1511
1754
  return;
1512
1755
  }
1513
1756
  fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -1521,7 +1764,8 @@ var HubServer = class {
1521
1764
  imagePath: filePath,
1522
1765
  mimeType,
1523
1766
  originalName,
1524
- content
1767
+ content,
1768
+ source: "dashboard"
1525
1769
  };
1526
1770
  channelWs.send(JSON.stringify(forwardMsg));
1527
1771
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
@@ -1530,7 +1774,7 @@ var HubServer = class {
1530
1774
  fs5.unlinkSync(filePath);
1531
1775
  } catch {
1532
1776
  }
1533
- }, 5 * 60 * 1e3);
1777
+ }, 5 * 60 * 1e3).unref();
1534
1778
  }
1535
1779
  async handleWebhookSave(req, res) {
1536
1780
  const body = await this.readBody(req);
@@ -1563,7 +1807,7 @@ var HubServer = class {
1563
1807
  this.telegramBot.onImageToSession = (sessionId, imagePath, mimeType, caption) => {
1564
1808
  const channelWs = this.channelSockets.get(sessionId);
1565
1809
  if (channelWs?.readyState === WebSocket.OPEN) {
1566
- const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption };
1810
+ const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1567
1811
  channelWs.send(JSON.stringify(msg));
1568
1812
  logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1569
1813
  }
@@ -1691,7 +1935,7 @@ var HubServer = class {
1691
1935
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1692
1936
  return;
1693
1937
  }
1694
- this.codexSupervisor = new CodexSupervisor(script);
1938
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1695
1939
  this.codexSupervisor.start();
1696
1940
  logger.info("Codex adapter started");
1697
1941
  }
@@ -1706,6 +1950,14 @@ var HubServer = class {
1706
1950
  this.channelAlive.set(sessionId, false);
1707
1951
  ws.ping();
1708
1952
  }
1953
+ for (const { ws } of this.codexAdapters.values()) {
1954
+ if (this.codexAlive.get(ws) === false) {
1955
+ ws.terminate();
1956
+ continue;
1957
+ }
1958
+ this.codexAlive.set(ws, false);
1959
+ ws.ping();
1960
+ }
1709
1961
  }, 3e4);
1710
1962
  }
1711
1963
  cleanupUploads() {
@@ -1768,7 +2020,7 @@ Content-Length: 0\r
1768
2020
  });
1769
2021
  }
1770
2022
  };
1771
- if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.argv[1].endsWith("hub/server.ts"))) {
2023
+ if (isEntryScript(process.argv[1], ["/hub/server.js", "/hub/server.ts"])) {
1772
2024
  installCrashGuard("hub daemon");
1773
2025
  logStartup("Hub daemon");
1774
2026
  const config = loadConfig();