@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.
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ var CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
40
40
  var CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
41
41
  var WS_PATH_CHANNEL = "/ws/channel";
42
42
  var WS_PATH_DASHBOARD = "/ws/dashboard";
43
+ var WS_PATH_CODEX = "/ws/codex";
43
44
  var CHANNEL_SERVER_NAME = "claude-alarm";
44
45
  var CHANNEL_SERVER_VERSION = "0.1.0";
45
46
 
@@ -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));
@@ -944,6 +1030,21 @@ function logStartup(context) {
944
1030
  `);
945
1031
  }
946
1032
 
1033
+ // src/shared/entry.ts
1034
+ function isEntryScript(argv1, suffixes) {
1035
+ if (!argv1) return false;
1036
+ const normalized = argv1.replace(/\\/g, "/");
1037
+ return suffixes.some((suffix) => normalized.endsWith(suffix));
1038
+ }
1039
+
1040
+ // src/shared/hub-url.ts
1041
+ import net from "net";
1042
+ function hubUrlHost(host) {
1043
+ if (host === "" || host === "0.0.0.0") return "127.0.0.1";
1044
+ if (host === "::") return "[::1]";
1045
+ return net.isIPv6(host) ? `[${host}]` : host;
1046
+ }
1047
+
947
1048
  // src/hub/auth.ts
948
1049
  import crypto from "crypto";
949
1050
  var SESSION_COOKIE = "ca_session";
@@ -1009,6 +1110,7 @@ function validChoices(choices) {
1009
1110
  const valid = choices.filter((c) => typeof c?.id === "string" && typeof c?.label === "string");
1010
1111
  return valid.length ? valid : void 0;
1011
1112
  }
1113
+ var nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
1012
1114
  var HubServer = class {
1013
1115
  httpServer;
1014
1116
  wssChannel;
@@ -1034,7 +1136,13 @@ var HubServer = class {
1034
1136
  token;
1035
1137
  codexEnabled;
1036
1138
  codexSupervisor;
1037
- constructor(config) {
1139
+ wssCodex;
1140
+ codexAdapters = /* @__PURE__ */ new Map();
1141
+ codexAlive = /* @__PURE__ */ new WeakMap();
1142
+ codexCallTimeoutMs;
1143
+ codexCalls = /* @__PURE__ */ new Map();
1144
+ constructor(config, options = {}) {
1145
+ this.codexCallTimeoutMs = options.codexCallTimeoutMs ?? 6e4;
1038
1146
  this.host = config?.hub?.host ?? DEFAULT_HUB_HOST;
1039
1147
  this.port = config?.hub?.port ?? DEFAULT_HUB_PORT;
1040
1148
  this.token = config?.hub?.token;
@@ -1047,7 +1155,7 @@ var HubServer = class {
1047
1155
  if (config?.webhooks) {
1048
1156
  this.notifier.configure({ webhooks: config.webhooks });
1049
1157
  }
1050
- const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
1158
+ const displayHost = hubUrlHost(this.host);
1051
1159
  this.notifier.configure({ dashboardUrl: `http://${displayHost}:${this.port}` });
1052
1160
  const fullConfig = loadConfig();
1053
1161
  if (fullConfig.telegram?.enabled && fullConfig.telegram.botToken && fullConfig.telegram.chatId) {
@@ -1060,6 +1168,9 @@ var HubServer = class {
1060
1168
  this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
1061
1169
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
1062
1170
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
1171
+ this.wssCodex = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
1172
+ this.wssCodex.on("connection", (ws, req) => this.handleCodexConnection(ws, req));
1173
+ this.wssCodex.on("error", (err) => logger.warn(`Codex WebSocket server error: ${err.message}`));
1063
1174
  this.httpServer.on("upgrade", (req, socket, head) => {
1064
1175
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
1065
1176
  const url = new URL(req.url ?? "/", "http://hub");
@@ -1080,6 +1191,10 @@ var HubServer = class {
1080
1191
  this.wssDashboard.handleUpgrade(req, socket, head, (ws) => {
1081
1192
  this.wssDashboard.emit("connection", ws, req);
1082
1193
  });
1194
+ } else if (pathname === WS_PATH_CODEX) {
1195
+ this.wssCodex.handleUpgrade(req, socket, head, (ws) => {
1196
+ this.wssCodex.emit("connection", ws, req);
1197
+ });
1083
1198
  } else {
1084
1199
  socket.destroy();
1085
1200
  }
@@ -1094,7 +1209,7 @@ var HubServer = class {
1094
1209
  this.httpServer.listen(this.port, this.host, () => {
1095
1210
  this.httpServer.removeListener("error", onStartupError);
1096
1211
  this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
1097
- const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
1212
+ const displayHost = hubUrlHost(this.host);
1098
1213
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
1099
1214
  this.startCodexAdapter();
1100
1215
  resolve();
@@ -1109,10 +1224,12 @@ var HubServer = class {
1109
1224
  if (this.telegramBot) this.telegramBot.stopPolling();
1110
1225
  for (const ws of this.channelSockets.values()) ws.terminate();
1111
1226
  for (const ws of this.dashboardSockets) ws.terminate();
1227
+ for (const { ws } of this.codexAdapters.values()) ws.terminate();
1112
1228
  this.channelSockets.clear();
1113
1229
  this.dashboardSockets.clear();
1114
1230
  this.wssChannel.close();
1115
1231
  this.wssDashboard.close();
1232
+ this.wssCodex.close();
1116
1233
  this.httpServer.close(() => {
1117
1234
  logger.info("Hub server stopped");
1118
1235
  resolve();
@@ -1177,6 +1294,12 @@ var HubServer = class {
1177
1294
  this.handleApiSend(req, res);
1178
1295
  } else if (url.pathname === "/api/notify" && req.method === "POST") {
1179
1296
  this.handleApiNotify(req, res);
1297
+ } else if (url.pathname === "/api/codex/folders" && req.method === "GET") {
1298
+ this.handleCodexFolders(url, res);
1299
+ } else if (url.pathname === "/api/codex/threads" && req.method === "POST") {
1300
+ this.handleCodexCreate(req, res);
1301
+ } else if (url.pathname === "/api/codex/threads/close" && req.method === "POST") {
1302
+ this.handleCodexClose(req, res);
1180
1303
  } else if (url.pathname === "/api/webhooks" && req.method === "GET") {
1181
1304
  const config = loadConfig();
1182
1305
  this.jsonResponse(res, 200, { webhooks: config.webhooks || [] });
@@ -1440,6 +1563,114 @@ var HubServer = class {
1440
1563
  }
1441
1564
  }
1442
1565
  }
1566
+ handleCodexConnection(ws, req) {
1567
+ const isLocal = this.isLocalRequest(req);
1568
+ let adapterId;
1569
+ ws.on("error", (err) => {
1570
+ logger.warn(`Codex adapter WebSocket error: ${err.message}`);
1571
+ ws.terminate();
1572
+ });
1573
+ ws.on("pong", () => this.codexAlive.set(ws, true));
1574
+ ws.on("message", (data) => {
1575
+ let msg;
1576
+ try {
1577
+ msg = JSON.parse(data.toString());
1578
+ if (msg === null || typeof msg !== "object") {
1579
+ logger.warn("Invalid message from Codex adapter");
1580
+ return;
1581
+ }
1582
+ } catch {
1583
+ logger.warn("Invalid message from Codex adapter");
1584
+ return;
1585
+ }
1586
+ if (msg.type === "adapter_hello") {
1587
+ const a = msg.adapter;
1588
+ if (typeof a?.id !== "string" || !a.id || typeof a.host !== "string") return;
1589
+ if (adapterId && adapterId !== a.id) return;
1590
+ const holder = this.codexAdapters.get(a.id);
1591
+ if (holder && holder.ws !== ws) holder.ws.terminate();
1592
+ if (!adapterId) logger.info(`Codex adapter connected: ${a.host} (${a.id}, local: ${isLocal})`);
1593
+ adapterId = a.id;
1594
+ this.codexAdapters.set(a.id, { ws, info: { id: a.id, host: a.host, ready: a.ready === true, isLocal } });
1595
+ this.broadcastCodexAdapters();
1596
+ } else if (msg.type === "adapter_result" && adapterId) {
1597
+ const call = this.codexCalls.get(msg.requestId);
1598
+ if (!call || call.adapterId !== adapterId) return;
1599
+ call.settle(msg.ok ? { status: 200, body: msg.data } : { status: 422, body: { error: String(msg.error) } });
1600
+ }
1601
+ });
1602
+ ws.on("close", () => {
1603
+ if (!adapterId || this.codexAdapters.get(adapterId)?.ws !== ws) return;
1604
+ this.codexAdapters.delete(adapterId);
1605
+ for (const call of [...this.codexCalls.values()]) {
1606
+ if (call.adapterId === adapterId) call.settle({ status: 502, body: { error: "Codex adapter disconnected" } });
1607
+ }
1608
+ logger.info(`Codex adapter disconnected: ${adapterId}`);
1609
+ this.broadcastCodexAdapters();
1610
+ });
1611
+ }
1612
+ callCodexAdapter(adapterId, call) {
1613
+ const adapter = this.codexAdapters.get(adapterId);
1614
+ if (!adapter || adapter.ws.readyState !== WebSocket.OPEN) {
1615
+ return Promise.resolve({ status: 404, body: { error: "Codex adapter is not connected" } });
1616
+ }
1617
+ const requestId = randomUUID3();
1618
+ return new Promise((resolve) => {
1619
+ const timer = setTimeout(
1620
+ () => settle({ status: 504, body: { error: "Codex did not respond in time. The conversation may still appear." } }),
1621
+ this.codexCallTimeoutMs
1622
+ );
1623
+ const settle = (outcome) => {
1624
+ clearTimeout(timer);
1625
+ this.codexCalls.delete(requestId);
1626
+ resolve(outcome);
1627
+ };
1628
+ this.codexCalls.set(requestId, { adapterId, settle });
1629
+ adapter.ws.send(JSON.stringify({ type: "adapter_call", requestId, call }));
1630
+ });
1631
+ }
1632
+ async handleCodexFolders(url, res) {
1633
+ const adapterId = url.searchParams.get("adapterId");
1634
+ if (!nonEmpty(adapterId)) {
1635
+ this.jsonResponse(res, 400, { error: "adapterId is required" });
1636
+ return;
1637
+ }
1638
+ const out = await this.callCodexAdapter(adapterId, { kind: "folders" });
1639
+ this.jsonResponse(res, out.status, out.body);
1640
+ }
1641
+ async handleCodexCreate(req, res) {
1642
+ const body = await this.readBody(req);
1643
+ const adapterId = body?.adapterId;
1644
+ const cwd = body?.cwd;
1645
+ if (!nonEmpty(adapterId) || !nonEmpty(cwd)) {
1646
+ this.jsonResponse(res, 400, { error: "adapterId and cwd are required" });
1647
+ return;
1648
+ }
1649
+ const out = await this.callCodexAdapter(adapterId, { kind: "create", cwd });
1650
+ this.jsonResponse(res, out.status, out.body);
1651
+ }
1652
+ async handleCodexClose(req, res) {
1653
+ const body = await this.readBody(req);
1654
+ const sessionId = body?.sessionId;
1655
+ if (!nonEmpty(sessionId)) {
1656
+ this.jsonResponse(res, 400, { error: "sessionId is required" });
1657
+ return;
1658
+ }
1659
+ const session = this.sessions.get(sessionId);
1660
+ const ws = this.channelSockets.get(sessionId);
1661
+ if (session?.agentKind !== "codex" || !session.closable || ws?.readyState !== WebSocket.OPEN) {
1662
+ this.jsonResponse(res, 404, { error: "No closable Codex conversation" });
1663
+ return;
1664
+ }
1665
+ ws.send(JSON.stringify({ type: "codex_close", sessionId }));
1666
+ this.jsonResponse(res, 200, { ok: true });
1667
+ }
1668
+ codexAdapterList() {
1669
+ return [...this.codexAdapters.values()].map((a) => a.info);
1670
+ }
1671
+ broadcastCodexAdapters() {
1672
+ this.broadcastToDashboards({ type: "codex_adapters", adapters: this.codexAdapterList() });
1673
+ }
1443
1674
  // --- Dashboard WebSocket ---
1444
1675
  handleDashboardConnection(ws) {
1445
1676
  this.dashboardSockets.add(ws);
@@ -1458,6 +1689,7 @@ var HubServer = class {
1458
1689
  requests: [...this.choiceRequests.values()].map((c) => c.request)
1459
1690
  };
1460
1691
  ws.send(JSON.stringify(pendingMsg));
1692
+ ws.send(JSON.stringify({ type: "codex_adapters", adapters: this.codexAdapterList() }));
1461
1693
  ws.on("message", (data) => {
1462
1694
  try {
1463
1695
  const msg = JSON.parse(data.toString());
@@ -1467,7 +1699,7 @@ var HubServer = class {
1467
1699
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1468
1700
  }
1469
1701
  } else if (msg.type === "image_upload") {
1470
- this.handleImageUpload(msg);
1702
+ this.handleImageUpload(ws, msg);
1471
1703
  } else if (msg.type === "permission_response") {
1472
1704
  if (this.forwardPermissionResponse(msg)) {
1473
1705
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1518,20 +1750,31 @@ var HubServer = class {
1518
1750
  }
1519
1751
  }
1520
1752
  }
1521
- handleImageUpload(msg) {
1753
+ handleImageUpload(ws, msg) {
1522
1754
  const { sessionId, imageData, mimeType, originalName, content } = msg;
1755
+ const reject = (reason) => {
1756
+ logger.warn(`Image upload rejected for ${sessionId}: ${reason}`);
1757
+ const rejected = { type: "upload_rejected", sessionId, reason, withText: typeof content === "string" && content.trim().length > 0 };
1758
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(rejected));
1759
+ };
1760
+ const channelWs = this.channelSockets.get(sessionId);
1761
+ if (!channelWs || channelWs.readyState !== WebSocket.OPEN) {
1762
+ reject("the session is not connected");
1763
+ return;
1764
+ }
1523
1765
  if (!this.localChannels.has(sessionId)) {
1524
- logger.warn(`Image upload rejected: session ${sessionId} is not local`);
1766
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1525
1767
  return;
1526
1768
  }
1527
- const channelWs = this.channelSockets.get(sessionId);
1528
- if (!channelWs || channelWs.readyState !== WebSocket.OPEN) return;
1529
1769
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1530
- if (!allowedTypes.includes(mimeType)) return;
1770
+ if (!allowedTypes.includes(mimeType)) {
1771
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1772
+ return;
1773
+ }
1531
1774
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1532
1775
  const buffer = Buffer.from(base64Data, "base64");
1533
1776
  if (buffer.length > 10 * 1024 * 1024) {
1534
- logger.warn("Image upload rejected: exceeds 10MB");
1777
+ reject("the image is larger than 10 MB");
1535
1778
  return;
1536
1779
  }
1537
1780
  fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
@@ -1545,7 +1788,8 @@ var HubServer = class {
1545
1788
  imagePath: filePath,
1546
1789
  mimeType,
1547
1790
  originalName,
1548
- content
1791
+ content,
1792
+ source: "dashboard"
1549
1793
  };
1550
1794
  channelWs.send(JSON.stringify(forwardMsg));
1551
1795
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
@@ -1554,7 +1798,7 @@ var HubServer = class {
1554
1798
  fs5.unlinkSync(filePath);
1555
1799
  } catch {
1556
1800
  }
1557
- }, 5 * 60 * 1e3);
1801
+ }, 5 * 60 * 1e3).unref();
1558
1802
  }
1559
1803
  async handleWebhookSave(req, res) {
1560
1804
  const body = await this.readBody(req);
@@ -1587,7 +1831,7 @@ var HubServer = class {
1587
1831
  this.telegramBot.onImageToSession = (sessionId, imagePath, mimeType, caption) => {
1588
1832
  const channelWs = this.channelSockets.get(sessionId);
1589
1833
  if (channelWs?.readyState === WebSocket.OPEN) {
1590
- const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption };
1834
+ const msg = { type: "image_to_session", sessionId, imagePath, mimeType, content: caption, source: "telegram" };
1591
1835
  channelWs.send(JSON.stringify(msg));
1592
1836
  logger.info(`Telegram photo forwarded to session: ${sessionId}`);
1593
1837
  }
@@ -1715,7 +1959,7 @@ var HubServer = class {
1715
1959
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1716
1960
  return;
1717
1961
  }
1718
- this.codexSupervisor = new CodexSupervisor(script);
1962
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1719
1963
  this.codexSupervisor.start();
1720
1964
  logger.info("Codex adapter started");
1721
1965
  }
@@ -1730,6 +1974,14 @@ var HubServer = class {
1730
1974
  this.channelAlive.set(sessionId, false);
1731
1975
  ws.ping();
1732
1976
  }
1977
+ for (const { ws } of this.codexAdapters.values()) {
1978
+ if (this.codexAlive.get(ws) === false) {
1979
+ ws.terminate();
1980
+ continue;
1981
+ }
1982
+ this.codexAlive.set(ws, false);
1983
+ ws.ping();
1984
+ }
1733
1985
  }, 3e4);
1734
1986
  }
1735
1987
  cleanupUploads() {
@@ -1792,7 +2044,7 @@ Content-Length: 0\r
1792
2044
  });
1793
2045
  }
1794
2046
  };
1795
- if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.argv[1].endsWith("hub/server.ts"))) {
2047
+ if (isEntryScript(process.argv[1], ["/hub/server.js", "/hub/server.ts"])) {
1796
2048
  installCrashGuard("hub daemon");
1797
2049
  logStartup("Hub daemon");
1798
2050
  const config = loadConfig();
@@ -1942,6 +2194,7 @@ export {
1942
2194
  SessionManager,
1943
2195
  UPLOADS_DIR,
1944
2196
  WS_PATH_CHANNEL,
2197
+ WS_PATH_CODEX,
1945
2198
  WS_PATH_DASHBOARD,
1946
2199
  loadConfig,
1947
2200
  logger,