@delt/claude-alarm 1.2.0 → 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/cli.js CHANGED
@@ -439,10 +439,13 @@ var init_permission_key = __esm({
439
439
  });
440
440
 
441
441
  // src/hub/telegram.ts
442
- import fs4 from "fs";
443
- import path5 from "path";
442
+ import fs5 from "fs";
443
+ import path6 from "path";
444
444
  import { randomUUID as randomUUID2 } from "crypto";
445
- var TELEGRAM_API, MAX_CHOICE_MESSAGES, TelegramBot;
445
+ function visibleLength(html) {
446
+ return html.replace(/<[^>]*>/g, "").replace(/&(?:amp|lt|gt);/g, "&").length;
447
+ }
448
+ var TELEGRAM_API, MAX_CHOICE_MESSAGES, MAX_SELECTIONS, MAX_VISIBLE_CHARS, TRUNCATED, MAX_PHOTO_BYTES, TelegramBot;
446
449
  var init_telegram = __esm({
447
450
  "src/hub/telegram.ts"() {
448
451
  "use strict";
@@ -452,6 +455,10 @@ var init_telegram = __esm({
452
455
  init_permission_key();
453
456
  TELEGRAM_API = "https://api.telegram.org/bot";
454
457
  MAX_CHOICE_MESSAGES = 200;
458
+ MAX_SELECTIONS = 20;
459
+ MAX_VISIBLE_CHARS = 4e3;
460
+ TRUNCATED = "\u2026(truncated)";
461
+ MAX_PHOTO_BYTES = 10 * 1024 * 1024;
455
462
  TelegramBot = class _TelegramBot {
456
463
  config;
457
464
  offset = 0;
@@ -472,9 +479,9 @@ var init_telegram = __esm({
472
479
  choiceMessages = /* @__PURE__ */ new Map();
473
480
  // Callback: get current sessions list
474
481
  getSessions;
475
- // Pending messages for session selection
476
- pendingMessages = /* @__PURE__ */ new Map();
477
- // chatId -> pending
482
+ latestSelection;
483
+ // Keyed by prompt, not by chat: buttons from an older prompt must still send that prompt's message.
484
+ selections = /* @__PURE__ */ new Map();
478
485
  constructor(config2) {
479
486
  this.config = config2;
480
487
  }
@@ -483,8 +490,7 @@ var init_telegram = __esm({
483
490
  }
484
491
  /** Send a notification message to Telegram */
485
492
  async sendNotification(sessionId2, _sessionLabel, title, message) {
486
- const text = `<b>${this.escHtml(title)}</b>
487
- ${this.mdToHtml(message)}`;
493
+ const text = this.fitNotification(title, message);
488
494
  const result = await this.sendMessage(text);
489
495
  if (result?.message_id) {
490
496
  this.messageSessionMap.set(result.message_id, sessionId2);
@@ -496,6 +502,25 @@ ${this.mdToHtml(message)}`;
496
502
  }
497
503
  }
498
504
  }
505
+ fitNotification(title, message) {
506
+ const render = (body) => `<b>${this.escHtml(title)}</b>
507
+ ${this.mdToHtml(body)}`;
508
+ const full = render(message);
509
+ if (visibleLength(full) <= MAX_VISIBLE_CHARS) return full;
510
+ const cut = (n) => {
511
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(message[n - 1]) ? n - 1 : n;
512
+ return render(`${message.slice(0, end)}
513
+ ${TRUNCATED}`);
514
+ };
515
+ let lo = 0;
516
+ let hi = message.length;
517
+ while (lo < hi) {
518
+ const mid = Math.ceil((lo + hi) / 2);
519
+ if (visibleLength(cut(mid)) <= MAX_VISIBLE_CHARS) lo = mid;
520
+ else hi = mid - 1;
521
+ }
522
+ return cut(lo);
523
+ }
499
524
  /** Send a text message to the configured chat */
500
525
  async sendMessage(text, replyToMessageId, replyMarkup) {
501
526
  try {
@@ -577,6 +602,13 @@ ${this.mdToHtml(message)}`;
577
602
  const hasPhoto = msg.photo && msg.photo.length > 0;
578
603
  const text = (msg.text || msg.caption || "").trim();
579
604
  if (!text && !hasPhoto) return;
605
+ if (hasPhoto) {
606
+ const size = msg.photo[msg.photo.length - 1].file_size;
607
+ if (size !== void 0 && size > MAX_PHOTO_BYTES) {
608
+ this.photoNotDelivered("it is larger than 10 MB");
609
+ return;
610
+ }
611
+ }
580
612
  if (msg.reply_to_message) {
581
613
  const sessionId2 = this.messageSessionMap.get(msg.reply_to_message.message_id);
582
614
  if (sessionId2) {
@@ -590,18 +622,20 @@ ${this.mdToHtml(message)}`;
590
622
  }
591
623
  if (text) {
592
624
  const selectMatch = text.match(/^\/s_(\d+)$/);
593
- if (selectMatch) {
594
- const pending = this.pendingMessages.get(msg.chat.id);
625
+ const latest = this.latestSelection;
626
+ if (selectMatch && latest !== void 0) {
627
+ const pending = this.selections.get(latest);
595
628
  if (pending) {
596
- this.pendingMessages.delete(msg.chat.id);
629
+ this.selections.delete(latest);
597
630
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
598
631
  if (session) {
632
+ let delivered = true;
599
633
  if (pending.photoFileId) {
600
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
634
+ delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
601
635
  } else if (pending.text) {
602
636
  this.deliverToSession(session.id, pending.text);
603
637
  }
604
- this.sendMessage(`Sent to [${this.getLabel(session)}]`);
638
+ if (delivered) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
605
639
  } else {
606
640
  this.sendMessage("Invalid session number.");
607
641
  }
@@ -622,16 +656,19 @@ ${this.mdToHtml(message)}`;
622
656
  }
623
657
  return;
624
658
  }
659
+ const selectionId = randomUUID2().replace(/-/g, "").slice(0, 8);
660
+ this.latestSelection = selectionId;
625
661
  const sessionIds = sessions.map((s) => s.id);
626
662
  if (hasPhoto) {
627
663
  const largest = msg.photo[msg.photo.length - 1];
628
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
664
+ this.selections.set(selectionId, { photoFileId: largest.file_id, caption: text, sessionIds });
629
665
  } else {
630
- this.pendingMessages.set(msg.chat.id, { text, sessionIds });
666
+ this.selections.set(selectionId, { text, sessionIds });
631
667
  }
668
+ while (this.selections.size > MAX_SELECTIONS) this.selections.delete(this.selections.keys().next().value);
632
669
  const buttons = sessions.map((s, i) => ({
633
670
  text: this.getLabel(s),
634
- callback_data: `sess:${i}:${msg.chat.id}`
671
+ callback_data: `sel:${selectionId}:${i}`
635
672
  }));
636
673
  const rows = [];
637
674
  for (let i = 0; i < buttons.length; i += 2) {
@@ -646,44 +683,54 @@ ${this.mdToHtml(message)}`;
646
683
  }
647
684
  async deliverPhotoToSession(sessionId2, photos, caption) {
648
685
  const largest = photos[photos.length - 1];
649
- await this.deliverPhotoToSessionByFileId(sessionId2, largest.file_id, caption);
686
+ return this.deliverPhotoToSessionByFileId(sessionId2, largest.file_id, caption);
650
687
  }
651
688
  async deliverPhotoToSessionByFileId(sessionId2, fileId, caption) {
652
689
  try {
653
690
  const fileRes = await fetch(`${this.apiUrl}/getFile?file_id=${fileId}`);
654
- if (!fileRes.ok) {
655
- logger.warn("Failed to get Telegram file info");
656
- return;
691
+ const fileData = fileRes.ok ? await fileRes.json() : void 0;
692
+ if (!fileData?.ok || !fileData.result) {
693
+ this.photoNotDelivered("Telegram did not return the file");
694
+ return false;
657
695
  }
658
- const fileData = await fileRes.json();
659
- if (!fileData.ok) return;
660
696
  const downloadUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${fileData.result.file_path}`;
661
697
  const imgRes = await fetch(downloadUrl);
662
698
  if (!imgRes.ok) {
663
- logger.warn("Failed to download Telegram photo");
664
- return;
699
+ this.photoNotDelivered("the download failed");
700
+ return false;
665
701
  }
666
702
  const buffer = Buffer.from(await imgRes.arrayBuffer());
703
+ if (buffer.length > MAX_PHOTO_BYTES) {
704
+ this.photoNotDelivered("it is larger than 10 MB");
705
+ return false;
706
+ }
667
707
  const ext = fileData.result.file_path.split(".").pop() || "jpg";
668
708
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg";
669
- fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
709
+ fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
670
710
  const filename = `${randomUUID2()}.${ext}`;
671
- const filePath = path5.join(UPLOADS_DIR, filename);
672
- fs4.writeFileSync(filePath, buffer);
711
+ const filePath = path6.join(UPLOADS_DIR, filename);
712
+ fs5.writeFileSync(filePath, buffer);
673
713
  logger.info(`Telegram photo saved: ${filename} (${buffer.length} bytes)`);
674
714
  if (this.onImageToSession) {
675
715
  this.onImageToSession(sessionId2, filePath, mimeType, caption);
676
716
  }
677
717
  setTimeout(() => {
678
718
  try {
679
- fs4.unlinkSync(filePath);
719
+ fs5.unlinkSync(filePath);
680
720
  } catch {
681
721
  }
682
- }, 5 * 60 * 1e3);
722
+ }, 5 * 60 * 1e3).unref();
723
+ return this.onImageToSession !== void 0;
683
724
  } catch (err) {
684
725
  logger.warn(`Telegram photo download failed: ${err.message}`);
726
+ this.photoNotDelivered("the download failed");
727
+ return false;
685
728
  }
686
729
  }
730
+ photoNotDelivered(reason) {
731
+ logger.warn(`Telegram photo not delivered: ${reason}`);
732
+ void this.sendMessage(`Photo not delivered: ${reason}`);
733
+ }
687
734
  // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
688
735
  pendingSession(pending, idx) {
689
736
  const id = pending.sessionIds[idx];
@@ -797,7 +844,7 @@ ${this.escHtml(description)}` : "") + (slice ? `
797
844
  async handleChoiceCallback(query) {
798
845
  const choice = this.choiceTokens.get(query.data.slice("pc:".length));
799
846
  if (!choice) {
800
- await this.answerCallbackQuery(query.id, "Expired");
847
+ await this.expire(query);
801
848
  return;
802
849
  }
803
850
  this.dropChoiceTokens(choice.sessionId, choice.requestId);
@@ -826,10 +873,14 @@ ${this.escHtml(description)}` : "") + (slice ? `
826
873
  async handleCallbackQuery(query) {
827
874
  if (!query.data) return;
828
875
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
829
- if (query.data.startsWith("sess:")) {
876
+ if (query.data.startsWith("sel:")) {
830
877
  await this.handleSessionSelectCallback(query);
831
878
  return;
832
879
  }
880
+ if (query.data.startsWith("sess:")) {
881
+ await this.expire(query);
882
+ return;
883
+ }
833
884
  if (query.data.startsWith("pc:")) {
834
885
  await this.handleChoiceCallback(query);
835
886
  return;
@@ -852,19 +903,25 @@ ${label}`);
852
903
  }
853
904
  }
854
905
  async handleSessionSelectCallback(query) {
855
- const parts = query.data.split(":");
856
- if (parts.length < 3) return;
857
- const [, idxStr, chatIdStr] = parts;
858
- const chatId = parseInt(chatIdStr, 10);
859
- const pending = this.pendingMessages.get(chatId);
860
- const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
861
- if (!pending || !session) {
906
+ const [, selectionId, idxStr] = query.data.split(":");
907
+ const pending = this.selections.get(selectionId);
908
+ if (!pending) {
909
+ await this.expire(query);
910
+ return;
911
+ }
912
+ const session = this.pendingSession(pending, parseInt(idxStr, 10));
913
+ if (!session) {
862
914
  await this.answerCallbackQuery(query.id, "Session not found");
863
915
  return;
864
916
  }
865
- this.pendingMessages.delete(chatId);
917
+ this.selections.delete(selectionId);
866
918
  if (pending.photoFileId) {
867
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
919
+ const delivered = await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
920
+ if (!delivered) {
921
+ await this.answerCallbackQuery(query.id, "Photo not delivered");
922
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
923
+ return;
924
+ }
868
925
  } else if (pending.text) {
869
926
  this.deliverToSession(session.id, pending.text);
870
927
  }
@@ -884,6 +941,21 @@ ${label}`);
884
941
  logger.warn(`Telegram answerCallbackQuery error: ${err.message}`);
885
942
  }
886
943
  }
944
+ async expire(query) {
945
+ await this.answerCallbackQuery(query.id, "Expired");
946
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
947
+ }
948
+ async removeButtons(chatId, messageId) {
949
+ try {
950
+ await fetch(`${this.apiUrl}/editMessageReplyMarkup`, {
951
+ method: "POST",
952
+ headers: { "Content-Type": "application/json" },
953
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } })
954
+ });
955
+ } catch (err) {
956
+ logger.warn(`Telegram editMessageReplyMarkup error: ${err.message}`);
957
+ }
958
+ }
887
959
  async editMessageText(chatId, messageId, text) {
888
960
  try {
889
961
  await fetch(`${this.apiUrl}/editMessageText`, {
@@ -928,8 +1000,8 @@ ${label}`);
928
1000
  css: "css",
929
1001
  scss: "scss"
930
1002
  };
931
- langOf(path11) {
932
- const m = /\.([a-z0-9]+)$/i.exec(path11 || "");
1003
+ langOf(path12) {
1004
+ const m = /\.([a-z0-9]+)$/i.exec(path12 || "");
933
1005
  return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
934
1006
  }
935
1007
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
@@ -976,10 +1048,15 @@ ${bodyLines.join("\n")}`;
976
1048
 
977
1049
  // src/hub/codex-supervisor.ts
978
1050
  import { spawn as spawn2 } from "child_process";
979
- import fs5 from "fs";
980
- import path6 from "path";
1051
+ import fs6 from "fs";
1052
+ import path7 from "path";
981
1053
  function resolveAdapterScript(baseDir) {
982
- return [path6.join(baseDir, "..", "codex", "main.js"), path6.join(baseDir, "codex", "main.js")].find((p) => fs5.existsSync(p));
1054
+ return [path7.join(baseDir, "..", "codex", "main.js"), path7.join(baseDir, "codex", "main.js")].find((p) => fs6.existsSync(p));
1055
+ }
1056
+ function adapterEnv(base, hub) {
1057
+ const env = { ...base, CLAUDE_ALARM_HUB_HOST: hub.host, CLAUDE_ALARM_HUB_PORT: String(hub.port) };
1058
+ delete env.CLAUDE_ALARM_HUB_TOKEN;
1059
+ return env;
983
1060
  }
984
1061
  var HEALTHY_RUN_MS, CodexSupervisor;
985
1062
  var init_codex_supervisor = __esm({
@@ -988,18 +1065,24 @@ var init_codex_supervisor = __esm({
988
1065
  init_logger();
989
1066
  HEALTHY_RUN_MS = 6e4;
990
1067
  CodexSupervisor = class {
991
- constructor(script, spawnFn = spawn2, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
1068
+ constructor(script, opts = {}) {
992
1069
  this.script = script;
993
- this.spawnFn = spawnFn;
994
- this.minDelayMs = minDelayMs;
995
- this.maxDelayMs = maxDelayMs;
996
- this.stopGraceMs = stopGraceMs;
997
- this.delay = minDelayMs;
1070
+ this.spawnFn = opts.spawnFn ?? spawn2;
1071
+ this.minDelayMs = opts.minDelayMs ?? 2e3;
1072
+ this.maxDelayMs = opts.maxDelayMs ?? 6e4;
1073
+ this.stopGraceMs = opts.stopGraceMs ?? 3e3;
1074
+ this.env = opts.env;
1075
+ this.delay = this.minDelayMs;
998
1076
  }
999
1077
  child;
1000
1078
  timer;
1001
1079
  stopped = true;
1002
1080
  delay;
1081
+ spawnFn;
1082
+ minDelayMs;
1083
+ maxDelayMs;
1084
+ stopGraceMs;
1085
+ env;
1003
1086
  start() {
1004
1087
  this.stopped = false;
1005
1088
  this.launch();
@@ -1021,7 +1104,8 @@ var init_codex_supervisor = __esm({
1021
1104
  const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
1022
1105
  detached: true,
1023
1106
  stdio: ["pipe", "pipe", "pipe"],
1024
- windowsHide: true
1107
+ windowsHide: true,
1108
+ ...this.env ? { env: this.env } : {}
1025
1109
  });
1026
1110
  this.child = child;
1027
1111
  child.stdout?.on("data", (d) => process.stdout.write(d));
@@ -1058,15 +1142,15 @@ var init_entry = __esm({
1058
1142
  });
1059
1143
 
1060
1144
  // src/hub/auth.ts
1061
- import crypto from "crypto";
1145
+ import crypto2 from "crypto";
1062
1146
  function safeEqual(a, b) {
1063
1147
  if (typeof a !== "string") return false;
1064
1148
  const x = Buffer.from(a);
1065
1149
  const y = Buffer.from(b);
1066
- return x.length === y.length && crypto.timingSafeEqual(x, y);
1150
+ return x.length === y.length && crypto2.timingSafeEqual(x, y);
1067
1151
  }
1068
1152
  function dashboardCookieValue(token) {
1069
- return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
1153
+ return crypto2.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
1070
1154
  }
1071
1155
  function parseCookies(header) {
1072
1156
  const out = {};
@@ -1126,8 +1210,8 @@ __export(server_exports, {
1126
1210
  HubServer: () => HubServer
1127
1211
  });
1128
1212
  import http from "http";
1129
- import fs6 from "fs";
1130
- import path7 from "path";
1213
+ import fs7 from "fs";
1214
+ import path8 from "path";
1131
1215
  import { fileURLToPath } from "url";
1132
1216
  import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
1133
1217
  import { randomUUID as randomUUID3 } from "crypto";
@@ -1153,7 +1237,7 @@ var init_server = __esm({
1153
1237
  init_entry();
1154
1238
  init_hub_url();
1155
1239
  init_auth();
1156
- __dirname = path7.dirname(fileURLToPath(import.meta.url));
1240
+ __dirname = path8.dirname(fileURLToPath(import.meta.url));
1157
1241
  MAX_WS_PAYLOAD = 16 * 1024 * 1024;
1158
1242
  nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
1159
1243
  HubServer = class {
@@ -1374,23 +1458,23 @@ var init_server = __esm({
1374
1458
  return;
1375
1459
  }
1376
1460
  const candidates = [
1377
- path7.join(__dirname, "..", "dashboard", "index.html"),
1461
+ path8.join(__dirname, "..", "dashboard", "index.html"),
1378
1462
  // from dist/hub/
1379
- path7.join(__dirname, "dashboard", "index.html"),
1463
+ path8.join(__dirname, "dashboard", "index.html"),
1380
1464
  // from dist/ (bundled index.js)
1381
- path7.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1465
+ path8.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1382
1466
  // from dist/hub/ -> src/
1383
- path7.join(__dirname, "..", "src", "dashboard", "index.html"),
1467
+ path8.join(__dirname, "..", "src", "dashboard", "index.html"),
1384
1468
  // from dist/ -> src/
1385
- path7.join(process.cwd(), "dist", "dashboard", "index.html"),
1469
+ path8.join(process.cwd(), "dist", "dashboard", "index.html"),
1386
1470
  // from cwd
1387
- path7.join(process.cwd(), "src", "dashboard", "index.html")
1471
+ path8.join(process.cwd(), "src", "dashboard", "index.html")
1388
1472
  // from cwd/src
1389
1473
  ];
1390
1474
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
1391
1475
  for (const candidate of candidates) {
1392
- if (fs6.existsSync(candidate)) {
1393
- const html = fs6.readFileSync(candidate, "utf-8");
1476
+ if (fs7.existsSync(candidate)) {
1477
+ const html = fs7.readFileSync(candidate, "utf-8");
1394
1478
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1395
1479
  res.end(html);
1396
1480
  return;
@@ -1744,7 +1828,7 @@ var init_server = __esm({
1744
1828
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1745
1829
  }
1746
1830
  } else if (msg.type === "image_upload") {
1747
- this.handleImageUpload(msg);
1831
+ this.handleImageUpload(ws, msg);
1748
1832
  } else if (msg.type === "permission_response") {
1749
1833
  if (this.forwardPermissionResponse(msg)) {
1750
1834
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1795,27 +1879,38 @@ var init_server = __esm({
1795
1879
  }
1796
1880
  }
1797
1881
  }
1798
- handleImageUpload(msg) {
1882
+ handleImageUpload(ws, msg) {
1799
1883
  const { sessionId: sessionId2, imageData, mimeType, originalName, content } = msg;
1884
+ const reject = (reason) => {
1885
+ logger.warn(`Image upload rejected for ${sessionId2}: ${reason}`);
1886
+ const rejected = { type: "upload_rejected", sessionId: sessionId2, reason, withText: typeof content === "string" && content.trim().length > 0 };
1887
+ if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify(rejected));
1888
+ };
1889
+ const channelWs = this.channelSockets.get(sessionId2);
1890
+ if (!channelWs || channelWs.readyState !== WebSocket2.OPEN) {
1891
+ reject("the session is not connected");
1892
+ return;
1893
+ }
1800
1894
  if (!this.localChannels.has(sessionId2)) {
1801
- logger.warn(`Image upload rejected: session ${sessionId2} is not local`);
1895
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1802
1896
  return;
1803
1897
  }
1804
- const channelWs = this.channelSockets.get(sessionId2);
1805
- if (!channelWs || channelWs.readyState !== WebSocket2.OPEN) return;
1806
1898
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1807
- if (!allowedTypes.includes(mimeType)) return;
1899
+ if (!allowedTypes.includes(mimeType)) {
1900
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1901
+ return;
1902
+ }
1808
1903
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1809
1904
  const buffer = Buffer.from(base64Data, "base64");
1810
1905
  if (buffer.length > 10 * 1024 * 1024) {
1811
- logger.warn("Image upload rejected: exceeds 10MB");
1906
+ reject("the image is larger than 10 MB");
1812
1907
  return;
1813
1908
  }
1814
- fs6.mkdirSync(UPLOADS_DIR, { recursive: true });
1909
+ fs7.mkdirSync(UPLOADS_DIR, { recursive: true });
1815
1910
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1816
1911
  const filename = `${randomUUID3()}.${ext}`;
1817
- const filePath = path7.join(UPLOADS_DIR, filename);
1818
- fs6.writeFileSync(filePath, buffer);
1912
+ const filePath = path8.join(UPLOADS_DIR, filename);
1913
+ fs7.writeFileSync(filePath, buffer);
1819
1914
  const forwardMsg = {
1820
1915
  type: "image_to_session",
1821
1916
  sessionId: sessionId2,
@@ -1829,7 +1924,7 @@ var init_server = __esm({
1829
1924
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1830
1925
  setTimeout(() => {
1831
1926
  try {
1832
- fs6.unlinkSync(filePath);
1927
+ fs7.unlinkSync(filePath);
1833
1928
  } catch {
1834
1929
  }
1835
1930
  }, 5 * 60 * 1e3).unref();
@@ -1993,7 +2088,7 @@ var init_server = __esm({
1993
2088
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1994
2089
  return;
1995
2090
  }
1996
- this.codexSupervisor = new CodexSupervisor(script);
2091
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1997
2092
  this.codexSupervisor.start();
1998
2093
  logger.info("Codex adapter started");
1999
2094
  }
@@ -2020,11 +2115,11 @@ var init_server = __esm({
2020
2115
  }
2021
2116
  cleanupUploads() {
2022
2117
  try {
2023
- if (!fs6.existsSync(UPLOADS_DIR)) return;
2024
- const files = fs6.readdirSync(UPLOADS_DIR);
2118
+ if (!fs7.existsSync(UPLOADS_DIR)) return;
2119
+ const files = fs7.readdirSync(UPLOADS_DIR);
2025
2120
  for (const file of files) {
2026
2121
  try {
2027
- fs6.unlinkSync(path7.join(UPLOADS_DIR, file));
2122
+ fs7.unlinkSync(path8.join(UPLOADS_DIR, file));
2028
2123
  } catch {
2029
2124
  }
2030
2125
  }
@@ -2223,9 +2318,9 @@ var init_hub_client = __esm({
2223
2318
  });
2224
2319
 
2225
2320
  // src/channel/peer-name.ts
2226
- import fs7 from "fs";
2321
+ import fs8 from "fs";
2227
2322
  import os2 from "os";
2228
- import path8 from "path";
2323
+ import path9 from "path";
2229
2324
  function findPeerName(records, env) {
2230
2325
  const valid = records.filter((r) => typeof r === "object" && r !== null);
2231
2326
  const nameOf = (match) => {
@@ -2245,18 +2340,18 @@ function readPeerName(env = process.env) {
2245
2340
  sessionId: env.CLAUDE_CODE_SESSION_ID
2246
2341
  };
2247
2342
  if (!lookup.messagingSocket && !lookup.sessionId) return void 0;
2248
- const configDir = env.CLAUDE_CONFIG_DIR || path8.join(os2.homedir(), ".claude");
2249
- const dir = path8.join(configDir, "sessions");
2343
+ const configDir = env.CLAUDE_CONFIG_DIR || path9.join(os2.homedir(), ".claude");
2344
+ const dir = path9.join(configDir, "sessions");
2250
2345
  let files;
2251
2346
  try {
2252
- files = fs7.readdirSync(dir).filter((f) => f.endsWith(".json"));
2347
+ files = fs8.readdirSync(dir).filter((f) => f.endsWith(".json"));
2253
2348
  } catch {
2254
2349
  return void 0;
2255
2350
  }
2256
2351
  const records = [];
2257
2352
  for (const f of files) {
2258
2353
  try {
2259
- records.push(JSON.parse(fs7.readFileSync(path8.join(dir, f), "utf8")));
2354
+ records.push(JSON.parse(fs8.readFileSync(path9.join(dir, f), "utf8")));
2260
2355
  } catch {
2261
2356
  }
2262
2357
  }
@@ -2279,7 +2374,7 @@ import {
2279
2374
  } from "@modelcontextprotocol/sdk/types.js";
2280
2375
  import { z } from "zod";
2281
2376
  import { randomUUID as randomUUID4 } from "crypto";
2282
- import path9 from "path";
2377
+ import path10 from "path";
2283
2378
  async function main() {
2284
2379
  logger.info(`Starting MCP channel server (session: ${sessionId})`);
2285
2380
  hubClient.connect();
@@ -2348,7 +2443,7 @@ var init_server2 = __esm({
2348
2443
  init_hub_client();
2349
2444
  init_peer_name2();
2350
2445
  sessionId = randomUUID4();
2351
- sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path9.basename(process.cwd());
2446
+ sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path10.basename(process.cwd());
2352
2447
  peerName = readPeerName();
2353
2448
  server = new Server(
2354
2449
  {
@@ -2525,8 +2620,8 @@ NOTIFICATIONS:
2525
2620
  // src/cli.ts
2526
2621
  init_config();
2527
2622
  import { spawn as spawn3 } from "child_process";
2528
- import fs8 from "fs";
2529
- import path10 from "path";
2623
+ import fs9 from "fs";
2624
+ import path11 from "path";
2530
2625
  import readline from "readline";
2531
2626
  import { fileURLToPath as fileURLToPath2 } from "url";
2532
2627
 
@@ -2538,7 +2633,8 @@ import path3 from "path";
2538
2633
  import { Duplex } from "stream";
2539
2634
  import WebSocket from "ws";
2540
2635
  function resolveCommand(command, platform = process.platform, env = process.env) {
2541
- if (platform !== "win32" || /[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: false };
2636
+ if (platform !== "win32") return { file: command, shell: false };
2637
+ if (/[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: /\.(cmd|bat)$/i.test(command) };
2542
2638
  const file = findCodex(command, platform, env);
2543
2639
  return file ? { file, shell: file.endsWith(".cmd") } : { file: command, shell: false };
2544
2640
  }
@@ -2566,7 +2662,8 @@ var defaultSpawn = (command, args) => {
2566
2662
  if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
2567
2663
  return spawn(file, args, { stdio: "pipe", windowsHide: true });
2568
2664
  };
2569
- function connectProxy(command, spawnFn = defaultSpawn) {
2665
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
2666
+ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
2570
2667
  const child = spawnFn(command, ["app-server", "proxy"]);
2571
2668
  child.stdin?.on("error", () => {
2572
2669
  });
@@ -2600,9 +2697,11 @@ function connectProxy(command, spawnFn = defaultSpawn) {
2600
2697
  };
2601
2698
  return new Promise((resolve, reject) => {
2602
2699
  let settled = false;
2700
+ const timer = setTimeout(() => fail(new Error(`codex daemon did not answer within ${timeoutMs / 1e3}s`)), timeoutMs);
2603
2701
  const fail = (err) => {
2604
2702
  if (settled) return;
2605
2703
  settled = true;
2704
+ clearTimeout(timer);
2606
2705
  close();
2607
2706
  reject(err);
2608
2707
  };
@@ -2612,6 +2711,7 @@ function connectProxy(command, spawnFn = defaultSpawn) {
2612
2711
  ws.once("open", () => {
2613
2712
  if (settled) return;
2614
2713
  settled = true;
2714
+ clearTimeout(timer);
2615
2715
  child.on("error", (err) => logger.warn(`codex proxy error: ${err.message}`));
2616
2716
  ws.on("error", (err) => logger.warn(`codex daemon socket error: ${err.message}`));
2617
2717
  resolve({ ws, close });
@@ -3025,7 +3125,7 @@ function resolveAdapterHub(config2, env = process.env) {
3025
3125
  // src/codex/start-check.ts
3026
3126
  var ADAPTER_REPORT_TIMEOUT_MS = 1e4;
3027
3127
  var HUB_CHECK_TIMEOUT_MS = 3e3;
3028
- var RESTART_HINT = "After fixing this, restart the adapter: claude-alarm codex stop, then claude-alarm codex start";
3128
+ var RESTART_HINT = "After fixing this, restart the adapter: claude-alarm codex stop, then claude-alarm codex start (if the hub started the adapter, restart the hub instead)";
3029
3129
  var CONFIG_PATH = "~/.claude-alarm/config.json";
3030
3130
  var NOT_CHECKED = { text: "Codex daemon: not checked (the adapter was already running)", warning: false };
3031
3131
  function toOutcome(msg) {
@@ -3042,9 +3142,11 @@ function waitForAdapterReport(child, timeoutMs = ADAPTER_REPORT_TIMEOUT_MS) {
3042
3142
  resolve(result);
3043
3143
  };
3044
3144
  const onMessage = (msg) => {
3045
- if (msg && typeof msg === "object" && msg.type === "codex-first-connect") {
3046
- finish({ kind: "report", outcome: toOutcome(msg) });
3047
- }
3145
+ if (!msg || typeof msg !== "object") return;
3146
+ const m = msg;
3147
+ if (m.type === "codex-first-connect") finish({ kind: "report", outcome: toOutcome(m) });
3148
+ else if (m.type === "codex-already-running" && Number.isInteger(m.pid)) finish({ kind: "already", pid: m.pid });
3149
+ else if (m.type === "codex-lock-failed") finish({ kind: "lockFailed", error: String(m.error ?? "") });
3048
3150
  };
3049
3151
  const onExit = (code, signal) => finish({ kind: "exited", code, signal });
3050
3152
  const onError = (error) => finish({ kind: "exited", code: null, signal: null, error });
@@ -3124,9 +3226,9 @@ function printAlreadyRunning(d, pid, hub) {
3124
3226
  printChecks(d, NOT_CHECKED, hubLine(hub, d.hub));
3125
3227
  }
3126
3228
  async function startAdapter(d) {
3127
- const existing = d.readPid();
3128
- if (existing !== void 0 && d.isRunning(existing)) {
3129
- printAlreadyRunning(d, existing, await checkHub(d.hub, d.hubTimeoutMs));
3229
+ const existing = await d.queryOwner();
3230
+ if (existing.state === "running") {
3231
+ printAlreadyRunning(d, existing.pid, await checkHub(d.hub, d.hubTimeoutMs));
3130
3232
  return 0;
3131
3233
  }
3132
3234
  const child = d.spawnAdapter();
@@ -3143,6 +3245,14 @@ async function startAdapter(d) {
3143
3245
  ]);
3144
3246
  if (child.connected) child.disconnect();
3145
3247
  child.unref();
3248
+ if (report.kind === "already") {
3249
+ printAlreadyRunning(d, report.pid, hub);
3250
+ return 0;
3251
+ }
3252
+ if (report.kind === "lockFailed") {
3253
+ d.err(`Codex adapter cannot start: ${report.error}`);
3254
+ return 1;
3255
+ }
3146
3256
  if (report.kind !== "exited" && !seen.exit && child.pid !== void 0) {
3147
3257
  d.out(`Codex adapter started (PID: ${child.pid}). Logs: ${d.logFile}`);
3148
3258
  printChecks(d, daemonLine(report, d.command, d.logFile), hubLine(hub, d.hub));
@@ -3153,10 +3263,9 @@ async function startAdapter(d) {
3153
3263
  d.err(`Codex adapter failed to start: ${exit?.error?.message ?? "no process id"}`);
3154
3264
  return 1;
3155
3265
  }
3156
- const owner = d.readPid();
3157
- if (owner !== void 0 && owner === child.pid) d.removePidFile();
3158
- else if (owner !== void 0 && d.isRunning(owner)) {
3159
- printAlreadyRunning(d, owner, hub);
3266
+ const owner = await d.queryOwner();
3267
+ if (owner.state === "running" && owner.pid !== child.pid) {
3268
+ printAlreadyRunning(d, owner.pid, hub);
3160
3269
  return 0;
3161
3270
  }
3162
3271
  const how = exit.code !== null ? `code ${exit.code}` : `signal ${exit.signal}`;
@@ -3164,8 +3273,147 @@ async function startAdapter(d) {
3164
3273
  return 1;
3165
3274
  }
3166
3275
 
3276
+ // src/codex/instance-lock.ts
3277
+ import crypto from "crypto";
3278
+ import fs4 from "fs";
3279
+ import net2 from "net";
3280
+ import path5 from "path";
3281
+ var CONTROL_PROTOCOL = 1;
3282
+ var STOP_PROOF_LABEL = "claude-alarm codex stop";
3283
+ var MAX_LINE_BYTES = 4096;
3284
+ function stopProof(token) {
3285
+ return crypto.createHmac("sha256", token).update(STOP_PROOF_LABEL).digest("hex");
3286
+ }
3287
+ function controlEndpoint(configDir, platform = process.platform) {
3288
+ if (platform !== "win32") return path5.posix.join(configDir, "codex.sock");
3289
+ let dir;
3290
+ try {
3291
+ dir = fs4.realpathSync.native(configDir);
3292
+ } catch {
3293
+ dir = path5.win32.resolve(configDir);
3294
+ }
3295
+ const hash = crypto.createHash("sha256").update(dir.toLowerCase()).digest("hex").slice(0, 16);
3296
+ return `\\\\.\\pipe\\claude-alarm-codex-${hash}`;
3297
+ }
3298
+ function ask(endpoint, request, timeoutMs) {
3299
+ return new Promise((resolve) => {
3300
+ let buffer = "";
3301
+ let settled = false;
3302
+ const socket = net2.connect(endpoint);
3303
+ const finish = (answer) => {
3304
+ if (settled) return;
3305
+ settled = true;
3306
+ clearTimeout(timer);
3307
+ socket.destroy();
3308
+ resolve(answer);
3309
+ };
3310
+ const timer = setTimeout(() => finish({ state: "unknown", error: `no answer within ${timeoutMs}ms` }), timeoutMs);
3311
+ socket.setEncoding("utf8");
3312
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}
3313
+ `));
3314
+ socket.on("data", (chunk) => {
3315
+ buffer += chunk;
3316
+ const end = buffer.indexOf("\n");
3317
+ if (end < 0) {
3318
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) finish({ state: "unknown", error: "reply too long" });
3319
+ return;
3320
+ }
3321
+ try {
3322
+ finish({ reply: JSON.parse(buffer.slice(0, end)) });
3323
+ } catch {
3324
+ finish({ state: "unknown", error: "unreadable reply" });
3325
+ }
3326
+ });
3327
+ socket.on("end", () => finish({ state: "unknown", error: "closed without a reply" }));
3328
+ socket.on("error", (err) => {
3329
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") finish({ state: "absent" });
3330
+ else finish({ state: "unknown", error: err.code ?? err.message });
3331
+ });
3332
+ });
3333
+ }
3334
+ async function queryOwner(endpoint, timeoutMs = 1e3) {
3335
+ const answer = await ask(endpoint, { type: "status" }, timeoutMs);
3336
+ if (!("reply" in answer)) return answer;
3337
+ const reply = answer.reply;
3338
+ if (reply?.type === "status" && reply.protocol === CONTROL_PROTOCOL && Number.isInteger(reply.pid)) {
3339
+ return { state: "running", pid: reply.pid };
3340
+ }
3341
+ return { state: "unknown", error: "unexpected reply" };
3342
+ }
3343
+ async function requestStop(endpoint, token, timeoutMs = 1e3) {
3344
+ const answer = await ask(endpoint, { type: "stop", proof: stopProof(token) }, timeoutMs);
3345
+ if (!("reply" in answer)) return answer;
3346
+ const reply = answer.reply;
3347
+ if (reply?.type === "stopping" && Number.isInteger(reply.pid)) return { state: "stopping", pid: reply.pid };
3348
+ if (reply?.type === "error" && reply.error === "unauthorized") return { state: "unauthorized" };
3349
+ return { state: "unknown", error: "unexpected reply" };
3350
+ }
3351
+
3352
+ // src/codex/control-cli.ts
3353
+ var STOP_WAIT_MS = 5e3;
3354
+ var STOP_POLL_MS = 100;
3355
+ function legacyNote(d, ownerPid) {
3356
+ const pid = d.legacyPid();
3357
+ if (pid === void 0 || pid === ownerPid) return;
3358
+ if (!d.isRunning(pid)) {
3359
+ d.removeLegacyPidFile();
3360
+ return;
3361
+ }
3362
+ d.out(`Note: ${d.legacyPidFile} names a running process (PID: ${pid}). claude-alarm 1.2.0 and earlier wrote this file; if that process is an old Codex adapter, restart the hub or end it yourself.`);
3363
+ }
3364
+ function unavailable(d, error) {
3365
+ d.err(`Codex adapter control endpoint is unavailable: ${error}`);
3366
+ return 1;
3367
+ }
3368
+ async function stopAdapter(d, startWithHub) {
3369
+ const owner = await d.queryOwner();
3370
+ if (owner.state === "unknown") return unavailable(d, owner.error);
3371
+ if (owner.state === "absent") {
3372
+ d.out("Codex adapter is not running");
3373
+ legacyNote(d);
3374
+ return 0;
3375
+ }
3376
+ const reply = await d.requestStop();
3377
+ if (reply.state === "unknown") return unavailable(d, reply.error);
3378
+ if (reply.state === "unauthorized") {
3379
+ d.err(`Codex adapter refused to stop: the token in ${d.configFile} does not match the one it started with`);
3380
+ return 1;
3381
+ }
3382
+ if (reply.state === "absent") {
3383
+ d.out("Codex adapter is not running");
3384
+ return 0;
3385
+ }
3386
+ const sleep = d.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
3387
+ for (let waited = 0; waited < (d.stopWaitMs ?? STOP_WAIT_MS); waited += STOP_POLL_MS) {
3388
+ await sleep(STOP_POLL_MS);
3389
+ const now = await d.queryOwner();
3390
+ if (now.state === "absent" || now.state === "running" && now.pid !== reply.pid) {
3391
+ d.out(`Codex adapter stopped (PID: ${reply.pid})`);
3392
+ if (now.state === "running") d.out(`Another Codex adapter took over (PID: ${now.pid})`);
3393
+ else if (startWithHub) d.out("If the hub started it, the hub will not start it again until you restart the hub.");
3394
+ return 0;
3395
+ }
3396
+ }
3397
+ d.err(`Stop requested, but the Codex adapter (PID: ${reply.pid}) is still running. Check with: claude-alarm codex status`);
3398
+ return 1;
3399
+ }
3400
+ async function adapterStatus(d, startWithHub) {
3401
+ const owner = await d.queryOwner();
3402
+ if (owner.state === "running") {
3403
+ d.out(`Codex adapter: running (PID: ${owner.pid})`);
3404
+ legacyNote(d, owner.pid);
3405
+ } else if (owner.state === "unknown") {
3406
+ d.out(`Codex adapter: unknown (${owner.error})`);
3407
+ } else {
3408
+ d.out("Codex adapter: not running");
3409
+ legacyNote(d);
3410
+ }
3411
+ d.out(`Start with hub: ${startWithHub ? "enabled" : "disabled"}`);
3412
+ return 0;
3413
+ }
3414
+
3167
3415
  // src/cli.ts
3168
- var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
3416
+ var __dirname2 = path11.dirname(fileURLToPath2(import.meta.url));
3169
3417
  function authHeaders(token) {
3170
3418
  return token ? { Authorization: `Bearer ${token}` } : {};
3171
3419
  }
@@ -3201,8 +3449,8 @@ Quick start:
3201
3449
  }
3202
3450
  async function checkForUpdates() {
3203
3451
  try {
3204
- const pkgPath = path10.join(__dirname2, "..", "package.json");
3205
- const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
3452
+ const pkgPath = path11.join(__dirname2, "..", "package.json");
3453
+ const pkg = JSON.parse(fs9.readFileSync(pkgPath, "utf-8"));
3206
3454
  const currentVersion = pkg.version;
3207
3455
  const res = await fetch("https://registry.npmjs.org/@delt/claude-alarm/latest", {
3208
3456
  signal: AbortSignal.timeout(3e3)
@@ -3225,18 +3473,18 @@ async function hubStart(daemon) {
3225
3473
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
3226
3474
  const displayHost = hubUrlHost(host);
3227
3475
  checkForUpdates();
3228
- if (fs8.existsSync(PID_FILE)) {
3229
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3476
+ if (fs9.existsSync(PID_FILE)) {
3477
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3230
3478
  if (isProcessRunning(pid)) {
3231
3479
  console.log(`Hub is already running (PID: ${pid}). Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
3232
3480
  return;
3233
3481
  }
3234
- fs8.unlinkSync(PID_FILE);
3482
+ fs9.unlinkSync(PID_FILE);
3235
3483
  }
3236
3484
  if (daemon) {
3237
3485
  ensureConfigDir();
3238
- const logFd = fs8.openSync(LOG_FILE, "a");
3239
- const hubScript = path10.join(__dirname2, "hub", "server.js");
3486
+ const logFd = fs9.openSync(LOG_FILE, "a");
3487
+ const hubScript = path11.join(__dirname2, "hub", "server.js");
3240
3488
  const child = spawn3(process.execPath, [hubScript], {
3241
3489
  detached: true,
3242
3490
  stdio: ["ignore", logFd, logFd],
@@ -3247,7 +3495,7 @@ async function hubStart(daemon) {
3247
3495
  console.error("Failed to start hub daemon");
3248
3496
  process.exit(1);
3249
3497
  }
3250
- fs8.writeFileSync(PID_FILE, String(pid), "utf-8");
3498
+ fs9.writeFileSync(PID_FILE, String(pid), "utf-8");
3251
3499
  let exited = false;
3252
3500
  child.once("exit", () => {
3253
3501
  exited = true;
@@ -3261,7 +3509,7 @@ async function hubStart(daemon) {
3261
3509
  });
3262
3510
  if (startup !== "ready") {
3263
3511
  if (!exited) child.kill();
3264
- if (fs8.existsSync(PID_FILE)) fs8.unlinkSync(PID_FILE);
3512
+ if (fs9.existsSync(PID_FILE)) fs9.unlinkSync(PID_FILE);
3265
3513
  console.error(startup === "exited" ? `Hub exited during startup. See ${LOG_FILE}` : `Hub did not answer at http://${displayHost}:${port} within ${HUB_START_TIMEOUT_MS / 1e3}s and was stopped. See ${LOG_FILE}`);
3266
3514
  process.exit(1);
3267
3515
  }
@@ -3280,11 +3528,11 @@ async function hubStart(daemon) {
3280
3528
  const hub = new HubServer2(config2);
3281
3529
  await hub.start();
3282
3530
  ensureConfigDir();
3283
- fs8.writeFileSync(PID_FILE, String(process.pid), "utf-8");
3531
+ fs9.writeFileSync(PID_FILE, String(process.pid), "utf-8");
3284
3532
  const shutdown = async () => {
3285
3533
  console.log("\nShutting down...");
3286
3534
  await hub.stop();
3287
- if (fs8.existsSync(PID_FILE)) fs8.unlinkSync(PID_FILE);
3535
+ if (fs9.existsSync(PID_FILE)) fs9.unlinkSync(PID_FILE);
3288
3536
  process.exit(0);
3289
3537
  };
3290
3538
  process.on("SIGINT", shutdown);
@@ -3292,18 +3540,18 @@ async function hubStart(daemon) {
3292
3540
  }
3293
3541
  }
3294
3542
  function hubStop() {
3295
- if (!fs8.existsSync(PID_FILE)) {
3543
+ if (!fs9.existsSync(PID_FILE)) {
3296
3544
  console.log("Hub is not running (no PID file found)");
3297
3545
  return;
3298
3546
  }
3299
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3547
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3300
3548
  try {
3301
3549
  process.kill(pid, "SIGTERM");
3302
3550
  console.log(`Hub stopped (PID: ${pid})`);
3303
3551
  } catch {
3304
3552
  console.log("Hub process not found (may have already stopped)");
3305
3553
  }
3306
- fs8.unlinkSync(PID_FILE);
3554
+ fs9.unlinkSync(PID_FILE);
3307
3555
  }
3308
3556
  async function hubStatus() {
3309
3557
  const config2 = loadConfig();
@@ -3311,8 +3559,8 @@ async function hubStatus() {
3311
3559
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
3312
3560
  const displayHost = hubUrlHost(host);
3313
3561
  let pidInfo = "not running";
3314
- if (fs8.existsSync(PID_FILE)) {
3315
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3562
+ if (fs9.existsSync(PID_FILE)) {
3563
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3316
3564
  if (isProcessRunning(pid)) {
3317
3565
  pidInfo = `running (PID: ${pid})`;
3318
3566
  } else {
@@ -3373,7 +3621,7 @@ async function test() {
3373
3621
  console.error("Could not reach hub. Start it first: claude-alarm hub start");
3374
3622
  }
3375
3623
  }
3376
- function ask(question) {
3624
+ function ask2(question) {
3377
3625
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3378
3626
  return new Promise((resolve) => {
3379
3627
  rl.question(question, (answer) => {
@@ -3384,18 +3632,18 @@ function ask(question) {
3384
3632
  }
3385
3633
  async function init() {
3386
3634
  const dir = process.cwd();
3387
- const projectName = path10.basename(dir);
3635
+ const projectName = path11.basename(dir);
3388
3636
  console.log(`
3389
3637
  claude-alarm init for "${projectName}"
3390
3638
  `);
3391
- const remote = await ask("Connect to a remote hub? (y/N): ");
3639
+ const remote = await ask2("Connect to a remote hub? (y/N): ");
3392
3640
  let env = {
3393
3641
  CLAUDE_ALARM_SESSION_NAME: projectName
3394
3642
  };
3395
3643
  if (remote.toLowerCase() === "y") {
3396
- const host = await ask("Hub host (e.g. 192.168.1.100): ");
3397
- const port = await ask("Hub port (default: 7900): ");
3398
- const token = await ask("Hub token: ");
3644
+ const host = await ask2("Hub host (e.g. 192.168.1.100): ");
3645
+ const port = await ask2("Hub port (default: 7900): ");
3646
+ const token = await ask2("Hub token: ");
3399
3647
  if (!host) {
3400
3648
  console.error("Host is required.");
3401
3649
  process.exit(1);
@@ -3404,11 +3652,11 @@ claude-alarm init for "${projectName}"
3404
3652
  if (port) env.CLAUDE_ALARM_HUB_PORT = port;
3405
3653
  if (token) env.CLAUDE_ALARM_HUB_TOKEN = token;
3406
3654
  }
3407
- const mcpPath = path10.join(dir, ".mcp.json");
3655
+ const mcpPath = path11.join(dir, ".mcp.json");
3408
3656
  let mcpConfig = {};
3409
- if (fs8.existsSync(mcpPath)) {
3657
+ if (fs9.existsSync(mcpPath)) {
3410
3658
  try {
3411
- mcpConfig = JSON.parse(fs8.readFileSync(mcpPath, "utf-8"));
3659
+ mcpConfig = JSON.parse(fs9.readFileSync(mcpPath, "utf-8"));
3412
3660
  } catch {
3413
3661
  mcpConfig = {};
3414
3662
  }
@@ -3419,7 +3667,7 @@ claude-alarm init for "${projectName}"
3419
3667
  args: ["-y", "@delt/claude-alarm", "serve"],
3420
3668
  env
3421
3669
  };
3422
- fs8.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
3670
+ fs9.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
3423
3671
  console.log(`
3424
3672
  \u2713 Created ${mcpPath}`);
3425
3673
  if (remote.toLowerCase() !== "y") {
@@ -3441,7 +3689,7 @@ claude-alarm init for "${projectName}"
3441
3689
  }
3442
3690
  console.log(` Dashboard: http://${displayHost}:${port}`);
3443
3691
  if (shouldOfferCodex(config2, findCodex("codex") !== void 0)) {
3444
- const answer = await ask("\nCodex is installed. Show Codex conversations on the dashboard too? (y/N): ");
3692
+ const answer = await ask2("\nCodex is installed. Show Codex conversations on the dashboard too? (y/N): ");
3445
3693
  if (answer.toLowerCase() === "y") codexEnable(true);
3446
3694
  else setCodexEnabled(false);
3447
3695
  }
@@ -3470,8 +3718,8 @@ function isProcessRunning(pid) {
3470
3718
  }
3471
3719
  }
3472
3720
  function readCodexPid() {
3473
- if (!fs8.existsSync(CODEX_PID_FILE)) return void 0;
3474
- const pid = parseInt(fs8.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10);
3721
+ if (!fs9.existsSync(CODEX_PID_FILE)) return void 0;
3722
+ const pid = parseInt(fs9.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10);
3475
3723
  return Number.isNaN(pid) ? void 0 : pid;
3476
3724
  }
3477
3725
  function codexEnable(enabled) {
@@ -3481,21 +3729,15 @@ function codexEnable(enabled) {
3481
3729
  async function codexStart() {
3482
3730
  const config2 = loadConfig();
3483
3731
  ensureConfigDir();
3732
+ legacyNote(controlDeps());
3484
3733
  const code = await startAdapter({
3485
3734
  hub: resolveAdapterHub(config2),
3486
3735
  command: config2.codex?.command ?? "codex",
3487
3736
  logFile: CODEX_LOG_FILE,
3488
- readPid: readCodexPid,
3489
- isRunning: isProcessRunning,
3490
- removePidFile: () => {
3491
- try {
3492
- fs8.unlinkSync(CODEX_PID_FILE);
3493
- } catch {
3494
- }
3495
- },
3737
+ queryOwner: () => queryOwner(controlEndpoint(CONFIG_DIR)),
3496
3738
  spawnAdapter: () => {
3497
- const logFd = fs8.openSync(CODEX_LOG_FILE, "a");
3498
- return spawn3(process.execPath, [path10.join(__dirname2, "codex", "main.js")], {
3739
+ const logFd = fs9.openSync(CODEX_LOG_FILE, "a");
3740
+ return spawn3(process.execPath, [path11.join(__dirname2, "codex", "main.js")], {
3499
3741
  detached: true,
3500
3742
  stdio: ["ignore", logFd, logFd, "ipc"],
3501
3743
  windowsHide: true,
@@ -3507,28 +3749,25 @@ async function codexStart() {
3507
3749
  });
3508
3750
  if (code !== 0) process.exit(code);
3509
3751
  }
3510
- function codexStop() {
3511
- const pid = readCodexPid();
3512
- if (pid === void 0) {
3513
- console.log("Codex adapter is not running");
3514
- return;
3515
- }
3516
- try {
3517
- process.kill(pid, "SIGTERM");
3518
- console.log(`Codex adapter stopped (PID: ${pid})`);
3519
- } catch {
3520
- console.log("Codex adapter process not found (may have already stopped)");
3521
- }
3522
- try {
3523
- fs8.unlinkSync(CODEX_PID_FILE);
3524
- } catch {
3525
- }
3526
- }
3527
- function codexStatus() {
3528
- const pid = readCodexPid();
3529
- const state = pid === void 0 ? "not running" : isProcessRunning(pid) ? `running (PID: ${pid})` : "not running (stale PID file)";
3530
- console.log(`Codex adapter: ${state}`);
3531
- console.log(`Start with hub: ${loadConfig().codex?.enabled ? "enabled" : "disabled"}`);
3752
+ function controlDeps() {
3753
+ const endpoint = controlEndpoint(CONFIG_DIR);
3754
+ const token = loadConfig().hub.token ?? "";
3755
+ return {
3756
+ queryOwner: () => queryOwner(endpoint),
3757
+ requestStop: () => requestStop(endpoint, token),
3758
+ legacyPid: readCodexPid,
3759
+ isRunning: isProcessRunning,
3760
+ removeLegacyPidFile: () => {
3761
+ try {
3762
+ fs9.unlinkSync(CODEX_PID_FILE);
3763
+ } catch {
3764
+ }
3765
+ },
3766
+ legacyPidFile: CODEX_PID_FILE,
3767
+ configFile: CONFIG_FILE,
3768
+ out: (line) => console.log(line),
3769
+ err: (line) => console.error(line)
3770
+ };
3532
3771
  }
3533
3772
  async function codexRun(argv) {
3534
3773
  const abort = new AbortController();
@@ -3537,7 +3776,7 @@ async function codexRun(argv) {
3537
3776
  process.on("SIGTERM", stop);
3538
3777
  try {
3539
3778
  return await runFromCli(argv, {
3540
- readFile: (file) => fs8.promises.readFile(file, "utf-8"),
3779
+ readFile: (file) => fs9.promises.readFile(file, "utf-8"),
3541
3780
  readStdin: async () => {
3542
3781
  let text = "";
3543
3782
  for await (const chunk of process.stdin) text += chunk;
@@ -3588,8 +3827,8 @@ async function main2() {
3588
3827
  if (sub === "enable") codexEnable(true);
3589
3828
  else if (sub === "disable") codexEnable(false);
3590
3829
  else if (sub === "start") await codexStart();
3591
- else if (sub === "stop") codexStop();
3592
- else if (sub === "status") codexStatus();
3830
+ else if (sub === "stop") process.exitCode = await stopAdapter(controlDeps(), loadConfig().codex?.enabled === true);
3831
+ else if (sub === "status") process.exitCode = await adapterStatus(controlDeps(), loadConfig().codex?.enabled === true);
3593
3832
  else if (sub === "run") process.exitCode = await codexRun(args.slice(2));
3594
3833
  else {
3595
3834
  console.error(`Unknown codex command: ${sub}`);