@delt/claude-alarm 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -301,7 +301,7 @@ var init_session_manager = __esm({
301
301
 
302
302
  // src/hub/notifier.ts
303
303
  import notifier from "node-notifier";
304
- import { execFile } from "child_process";
304
+ import { execFile as execFile2 } from "child_process";
305
305
  var Notifier;
306
306
  var init_notifier = __esm({
307
307
  "src/hub/notifier.ts"() {
@@ -362,7 +362,7 @@ var init_notifier = __esm({
362
362
  }
363
363
  checkWindowsNotifications() {
364
364
  return new Promise((resolve) => {
365
- execFile(
365
+ execFile2(
366
366
  "powershell",
367
367
  ["-Command", '(Get-ItemProperty -Path "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\PushNotifications" -Name ToastEnabled -ErrorAction SilentlyContinue).ToastEnabled'],
368
368
  { windowsHide: true },
@@ -383,7 +383,7 @@ var init_notifier = __esm({
383
383
  logger.warn("Windows notifications are disabled. Opening notification settings...");
384
384
  logger.warn("Please enable notifications for this app, then try again.");
385
385
  if (process.platform === "win32") {
386
- execFile("powershell", ["-Command", "Start-Process ms-settings:notifications"], { windowsHide: true });
386
+ execFile2("powershell", ["-Command", "Start-Process ms-settings:notifications"], { windowsHide: true });
387
387
  }
388
388
  setTimeout(() => {
389
389
  this.notificationSettingsOpened = false;
@@ -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, NO_LONGER_CONNECTED, TelegramBot;
446
449
  var init_telegram = __esm({
447
450
  "src/hub/telegram.ts"() {
448
451
  "use strict";
@@ -452,6 +455,11 @@ 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;
462
+ NO_LONGER_CONNECTED = "the session is no longer connected";
455
463
  TelegramBot = class _TelegramBot {
456
464
  config;
457
465
  offset = 0;
@@ -472,9 +480,9 @@ var init_telegram = __esm({
472
480
  choiceMessages = /* @__PURE__ */ new Map();
473
481
  // Callback: get current sessions list
474
482
  getSessions;
475
- // Pending messages for session selection
476
- pendingMessages = /* @__PURE__ */ new Map();
477
- // chatId -> pending
483
+ latestSelection;
484
+ // Keyed by prompt, not by chat: buttons from an older prompt must still send that prompt's message.
485
+ selections = /* @__PURE__ */ new Map();
478
486
  constructor(config2) {
479
487
  this.config = config2;
480
488
  }
@@ -483,8 +491,7 @@ var init_telegram = __esm({
483
491
  }
484
492
  /** Send a notification message to Telegram */
485
493
  async sendNotification(sessionId2, _sessionLabel, title, message) {
486
- const text = `<b>${this.escHtml(title)}</b>
487
- ${this.mdToHtml(message)}`;
494
+ const text = this.fitNotification(title, message);
488
495
  const result = await this.sendMessage(text);
489
496
  if (result?.message_id) {
490
497
  this.messageSessionMap.set(result.message_id, sessionId2);
@@ -496,6 +503,25 @@ ${this.mdToHtml(message)}`;
496
503
  }
497
504
  }
498
505
  }
506
+ fitNotification(title, message) {
507
+ const render = (body) => `<b>${this.escHtml(title)}</b>
508
+ ${this.mdToHtml(body)}`;
509
+ const full = render(message);
510
+ if (visibleLength(full) <= MAX_VISIBLE_CHARS) return full;
511
+ const cut = (n) => {
512
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(message[n - 1]) ? n - 1 : n;
513
+ return render(`${message.slice(0, end)}
514
+ ${TRUNCATED}`);
515
+ };
516
+ let lo = 0;
517
+ let hi = message.length;
518
+ while (lo < hi) {
519
+ const mid = Math.ceil((lo + hi) / 2);
520
+ if (visibleLength(cut(mid)) <= MAX_VISIBLE_CHARS) lo = mid;
521
+ else hi = mid - 1;
522
+ }
523
+ return cut(lo);
524
+ }
499
525
  /** Send a text message to the configured chat */
500
526
  async sendMessage(text, replyToMessageId, replyMarkup) {
501
527
  try {
@@ -577,6 +603,13 @@ ${this.mdToHtml(message)}`;
577
603
  const hasPhoto = msg.photo && msg.photo.length > 0;
578
604
  const text = (msg.text || msg.caption || "").trim();
579
605
  if (!text && !hasPhoto) return;
606
+ if (hasPhoto) {
607
+ const size = msg.photo[msg.photo.length - 1].file_size;
608
+ if (size !== void 0 && size > MAX_PHOTO_BYTES) {
609
+ this.photoNotDelivered("it is larger than 10 MB");
610
+ return;
611
+ }
612
+ }
580
613
  if (msg.reply_to_message) {
581
614
  const sessionId2 = this.messageSessionMap.get(msg.reply_to_message.message_id);
582
615
  if (sessionId2) {
@@ -590,18 +623,14 @@ ${this.mdToHtml(message)}`;
590
623
  }
591
624
  if (text) {
592
625
  const selectMatch = text.match(/^\/s_(\d+)$/);
593
- if (selectMatch) {
594
- const pending = this.pendingMessages.get(msg.chat.id);
626
+ const latest = this.latestSelection;
627
+ if (selectMatch && latest !== void 0) {
628
+ const pending = this.selections.get(latest);
595
629
  if (pending) {
596
- this.pendingMessages.delete(msg.chat.id);
630
+ this.selections.delete(latest);
597
631
  const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
598
632
  if (session) {
599
- if (pending.photoFileId) {
600
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
601
- } else if (pending.text) {
602
- this.deliverToSession(session.id, pending.text);
603
- }
604
- this.sendMessage(`Sent to [${this.getLabel(session)}]`);
633
+ if (await this.deliverPending(session.id, pending)) this.sendMessage(`Sent to [${this.getLabel(session)}]`);
605
634
  } else {
606
635
  this.sendMessage("Invalid session number.");
607
636
  }
@@ -622,16 +651,19 @@ ${this.mdToHtml(message)}`;
622
651
  }
623
652
  return;
624
653
  }
654
+ const selectionId = randomUUID2().replace(/-/g, "").slice(0, 8);
655
+ this.latestSelection = selectionId;
625
656
  const sessionIds = sessions.map((s) => s.id);
626
657
  if (hasPhoto) {
627
658
  const largest = msg.photo[msg.photo.length - 1];
628
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
659
+ this.selections.set(selectionId, { photoFileId: largest.file_id, caption: text, sessionIds });
629
660
  } else {
630
- this.pendingMessages.set(msg.chat.id, { text, sessionIds });
661
+ this.selections.set(selectionId, { text, sessionIds });
631
662
  }
663
+ while (this.selections.size > MAX_SELECTIONS) this.selections.delete(this.selections.keys().next().value);
632
664
  const buttons = sessions.map((s, i) => ({
633
665
  text: this.getLabel(s),
634
- callback_data: `sess:${i}:${msg.chat.id}`
666
+ callback_data: `sel:${selectionId}:${i}`
635
667
  }));
636
668
  const rows = [];
637
669
  for (let i = 0; i < buttons.length; i += 2) {
@@ -640,50 +672,75 @@ ${this.mdToHtml(message)}`;
640
672
  this.sendMessage("Multiple sessions active. Select one:", void 0, { inline_keyboard: rows });
641
673
  }
642
674
  deliverToSession(sessionId2, content) {
643
- if (this.onMessageToSession) {
644
- this.onMessageToSession(sessionId2, content);
645
- }
675
+ const delivered = this.onMessageToSession?.(sessionId2, content) === true;
676
+ if (!delivered) void this.sendMessage(`Not delivered: ${NO_LONGER_CONNECTED}`);
677
+ return delivered;
678
+ }
679
+ async deliverPending(sessionId2, pending) {
680
+ if (pending.photoFileId) return this.deliverPhotoToSessionByFileId(sessionId2, pending.photoFileId, pending.caption);
681
+ return pending.text ? this.deliverToSession(sessionId2, pending.text) : false;
646
682
  }
647
683
  async deliverPhotoToSession(sessionId2, photos, caption) {
648
684
  const largest = photos[photos.length - 1];
649
- await this.deliverPhotoToSessionByFileId(sessionId2, largest.file_id, caption);
685
+ return this.deliverPhotoToSessionByFileId(sessionId2, largest.file_id, caption);
650
686
  }
651
687
  async deliverPhotoToSessionByFileId(sessionId2, fileId, caption) {
652
688
  try {
653
689
  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;
690
+ const fileData = fileRes.ok ? await fileRes.json() : void 0;
691
+ if (!fileData?.ok || !fileData.result) {
692
+ this.photoNotDelivered("Telegram did not return the file");
693
+ return false;
657
694
  }
658
- const fileData = await fileRes.json();
659
- if (!fileData.ok) return;
660
695
  const downloadUrl = `https://api.telegram.org/file/bot${this.config.botToken}/${fileData.result.file_path}`;
661
696
  const imgRes = await fetch(downloadUrl);
662
697
  if (!imgRes.ok) {
663
- logger.warn("Failed to download Telegram photo");
664
- return;
698
+ this.photoNotDelivered("the download failed");
699
+ return false;
665
700
  }
666
701
  const buffer = Buffer.from(await imgRes.arrayBuffer());
702
+ if (buffer.length > MAX_PHOTO_BYTES) {
703
+ this.photoNotDelivered("it is larger than 10 MB");
704
+ return false;
705
+ }
667
706
  const ext = fileData.result.file_path.split(".").pop() || "jpg";
668
707
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg";
669
- fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
708
+ fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
670
709
  const filename = `${randomUUID2()}.${ext}`;
671
- const filePath = path5.join(UPLOADS_DIR, filename);
672
- fs4.writeFileSync(filePath, buffer);
710
+ const filePath = path6.join(UPLOADS_DIR, filename);
711
+ fs5.writeFileSync(filePath, buffer);
673
712
  logger.info(`Telegram photo saved: ${filename} (${buffer.length} bytes)`);
674
- if (this.onImageToSession) {
675
- this.onImageToSession(sessionId2, filePath, mimeType, caption);
713
+ let accepted = false;
714
+ try {
715
+ accepted = this.onImageToSession?.(sessionId2, filePath, mimeType, caption) === true;
716
+ } catch (err) {
717
+ logger.warn(`Telegram photo hand-off failed: ${err.message}`);
718
+ }
719
+ if (!accepted) {
720
+ try {
721
+ fs5.unlinkSync(filePath);
722
+ } catch {
723
+ }
724
+ this.photoNotDelivered(NO_LONGER_CONNECTED);
725
+ return false;
676
726
  }
677
727
  setTimeout(() => {
678
728
  try {
679
- fs4.unlinkSync(filePath);
729
+ fs5.unlinkSync(filePath);
680
730
  } catch {
681
731
  }
682
- }, 5 * 60 * 1e3);
732
+ }, 5 * 60 * 1e3).unref();
733
+ return true;
683
734
  } catch (err) {
684
735
  logger.warn(`Telegram photo download failed: ${err.message}`);
736
+ this.photoNotDelivered("the download failed");
737
+ return false;
685
738
  }
686
739
  }
740
+ photoNotDelivered(reason) {
741
+ logger.warn(`Telegram photo not delivered: ${reason}`);
742
+ void this.sendMessage(`Photo not delivered: ${reason}`);
743
+ }
687
744
  // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
688
745
  pendingSession(pending, idx) {
689
746
  const id = pending.sessionIds[idx];
@@ -797,7 +854,7 @@ ${this.escHtml(description)}` : "") + (slice ? `
797
854
  async handleChoiceCallback(query) {
798
855
  const choice = this.choiceTokens.get(query.data.slice("pc:".length));
799
856
  if (!choice) {
800
- await this.answerCallbackQuery(query.id, "Expired");
857
+ await this.expire(query);
801
858
  return;
802
859
  }
803
860
  this.dropChoiceTokens(choice.sessionId, choice.requestId);
@@ -826,10 +883,14 @@ ${this.escHtml(description)}` : "") + (slice ? `
826
883
  async handleCallbackQuery(query) {
827
884
  if (!query.data) return;
828
885
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
829
- if (query.data.startsWith("sess:")) {
886
+ if (query.data.startsWith("sel:")) {
830
887
  await this.handleSessionSelectCallback(query);
831
888
  return;
832
889
  }
890
+ if (query.data.startsWith("sess:")) {
891
+ await this.expire(query);
892
+ return;
893
+ }
833
894
  if (query.data.startsWith("pc:")) {
834
895
  await this.handleChoiceCallback(query);
835
896
  return;
@@ -852,21 +913,22 @@ ${label}`);
852
913
  }
853
914
  }
854
915
  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) {
916
+ const [, selectionId, idxStr] = query.data.split(":");
917
+ const pending = this.selections.get(selectionId);
918
+ if (!pending) {
919
+ await this.expire(query);
920
+ return;
921
+ }
922
+ const session = this.pendingSession(pending, parseInt(idxStr, 10));
923
+ if (!session) {
862
924
  await this.answerCallbackQuery(query.id, "Session not found");
863
925
  return;
864
926
  }
865
- this.pendingMessages.delete(chatId);
866
- if (pending.photoFileId) {
867
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
868
- } else if (pending.text) {
869
- this.deliverToSession(session.id, pending.text);
927
+ this.selections.delete(selectionId);
928
+ if (!await this.deliverPending(session.id, pending)) {
929
+ await this.answerCallbackQuery(query.id, pending.photoFileId ? "Photo not delivered" : "Not delivered");
930
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
931
+ return;
870
932
  }
871
933
  await this.answerCallbackQuery(query.id, `Sent to ${this.getLabel(session)}`);
872
934
  if (query.message) {
@@ -884,6 +946,21 @@ ${label}`);
884
946
  logger.warn(`Telegram answerCallbackQuery error: ${err.message}`);
885
947
  }
886
948
  }
949
+ async expire(query) {
950
+ await this.answerCallbackQuery(query.id, "Expired");
951
+ if (query.message) await this.removeButtons(query.message.chat.id, query.message.message_id);
952
+ }
953
+ async removeButtons(chatId, messageId) {
954
+ try {
955
+ await fetch(`${this.apiUrl}/editMessageReplyMarkup`, {
956
+ method: "POST",
957
+ headers: { "Content-Type": "application/json" },
958
+ body: JSON.stringify({ chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] } })
959
+ });
960
+ } catch (err) {
961
+ logger.warn(`Telegram editMessageReplyMarkup error: ${err.message}`);
962
+ }
963
+ }
887
964
  async editMessageText(chatId, messageId, text) {
888
965
  try {
889
966
  await fetch(`${this.apiUrl}/editMessageText`, {
@@ -928,8 +1005,8 @@ ${label}`);
928
1005
  css: "css",
929
1006
  scss: "scss"
930
1007
  };
931
- langOf(path11) {
932
- const m = /\.([a-z0-9]+)$/i.exec(path11 || "");
1008
+ langOf(path12) {
1009
+ const m = /\.([a-z0-9]+)$/i.exec(path12 || "");
933
1010
  return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
934
1011
  }
935
1012
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
@@ -976,10 +1053,15 @@ ${bodyLines.join("\n")}`;
976
1053
 
977
1054
  // src/hub/codex-supervisor.ts
978
1055
  import { spawn as spawn2 } from "child_process";
979
- import fs5 from "fs";
980
- import path6 from "path";
1056
+ import fs6 from "fs";
1057
+ import path7 from "path";
981
1058
  function resolveAdapterScript(baseDir) {
982
- return [path6.join(baseDir, "..", "codex", "main.js"), path6.join(baseDir, "codex", "main.js")].find((p) => fs5.existsSync(p));
1059
+ return [path7.join(baseDir, "..", "codex", "main.js"), path7.join(baseDir, "codex", "main.js")].find((p) => fs6.existsSync(p));
1060
+ }
1061
+ function adapterEnv(base, hub) {
1062
+ const env = { ...base, CLAUDE_ALARM_HUB_HOST: hub.host, CLAUDE_ALARM_HUB_PORT: String(hub.port) };
1063
+ delete env.CLAUDE_ALARM_HUB_TOKEN;
1064
+ return env;
983
1065
  }
984
1066
  var HEALTHY_RUN_MS, CodexSupervisor;
985
1067
  var init_codex_supervisor = __esm({
@@ -988,18 +1070,24 @@ var init_codex_supervisor = __esm({
988
1070
  init_logger();
989
1071
  HEALTHY_RUN_MS = 6e4;
990
1072
  CodexSupervisor = class {
991
- constructor(script, spawnFn = spawn2, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
1073
+ constructor(script, opts = {}) {
992
1074
  this.script = script;
993
- this.spawnFn = spawnFn;
994
- this.minDelayMs = minDelayMs;
995
- this.maxDelayMs = maxDelayMs;
996
- this.stopGraceMs = stopGraceMs;
997
- this.delay = minDelayMs;
1075
+ this.spawnFn = opts.spawnFn ?? spawn2;
1076
+ this.minDelayMs = opts.minDelayMs ?? 2e3;
1077
+ this.maxDelayMs = opts.maxDelayMs ?? 6e4;
1078
+ this.stopGraceMs = opts.stopGraceMs ?? 3e3;
1079
+ this.env = opts.env;
1080
+ this.delay = this.minDelayMs;
998
1081
  }
999
1082
  child;
1000
1083
  timer;
1001
1084
  stopped = true;
1002
1085
  delay;
1086
+ spawnFn;
1087
+ minDelayMs;
1088
+ maxDelayMs;
1089
+ stopGraceMs;
1090
+ env;
1003
1091
  start() {
1004
1092
  this.stopped = false;
1005
1093
  this.launch();
@@ -1021,7 +1109,8 @@ var init_codex_supervisor = __esm({
1021
1109
  const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
1022
1110
  detached: true,
1023
1111
  stdio: ["pipe", "pipe", "pipe"],
1024
- windowsHide: true
1112
+ windowsHide: true,
1113
+ ...this.env ? { env: this.env } : {}
1025
1114
  });
1026
1115
  this.child = child;
1027
1116
  child.stdout?.on("data", (d) => process.stdout.write(d));
@@ -1058,15 +1147,15 @@ var init_entry = __esm({
1058
1147
  });
1059
1148
 
1060
1149
  // src/hub/auth.ts
1061
- import crypto from "crypto";
1150
+ import crypto2 from "crypto";
1062
1151
  function safeEqual(a, b) {
1063
1152
  if (typeof a !== "string") return false;
1064
1153
  const x = Buffer.from(a);
1065
1154
  const y = Buffer.from(b);
1066
- return x.length === y.length && crypto.timingSafeEqual(x, y);
1155
+ return x.length === y.length && crypto2.timingSafeEqual(x, y);
1067
1156
  }
1068
1157
  function dashboardCookieValue(token) {
1069
- return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
1158
+ return crypto2.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
1070
1159
  }
1071
1160
  function parseCookies(header) {
1072
1161
  const out = {};
@@ -1126,8 +1215,8 @@ __export(server_exports, {
1126
1215
  HubServer: () => HubServer
1127
1216
  });
1128
1217
  import http from "http";
1129
- import fs6 from "fs";
1130
- import path7 from "path";
1218
+ import fs7 from "fs";
1219
+ import path8 from "path";
1131
1220
  import { fileURLToPath } from "url";
1132
1221
  import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
1133
1222
  import { randomUUID as randomUUID3 } from "crypto";
@@ -1153,7 +1242,7 @@ var init_server = __esm({
1153
1242
  init_entry();
1154
1243
  init_hub_url();
1155
1244
  init_auth();
1156
- __dirname = path7.dirname(fileURLToPath(import.meta.url));
1245
+ __dirname = path8.dirname(fileURLToPath(import.meta.url));
1157
1246
  MAX_WS_PAYLOAD = 16 * 1024 * 1024;
1158
1247
  nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
1159
1248
  HubServer = class {
@@ -1374,23 +1463,23 @@ var init_server = __esm({
1374
1463
  return;
1375
1464
  }
1376
1465
  const candidates = [
1377
- path7.join(__dirname, "..", "dashboard", "index.html"),
1466
+ path8.join(__dirname, "..", "dashboard", "index.html"),
1378
1467
  // from dist/hub/
1379
- path7.join(__dirname, "dashboard", "index.html"),
1468
+ path8.join(__dirname, "dashboard", "index.html"),
1380
1469
  // from dist/ (bundled index.js)
1381
- path7.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1470
+ path8.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1382
1471
  // from dist/hub/ -> src/
1383
- path7.join(__dirname, "..", "src", "dashboard", "index.html"),
1472
+ path8.join(__dirname, "..", "src", "dashboard", "index.html"),
1384
1473
  // from dist/ -> src/
1385
- path7.join(process.cwd(), "dist", "dashboard", "index.html"),
1474
+ path8.join(process.cwd(), "dist", "dashboard", "index.html"),
1386
1475
  // from cwd
1387
- path7.join(process.cwd(), "src", "dashboard", "index.html")
1476
+ path8.join(process.cwd(), "src", "dashboard", "index.html")
1388
1477
  // from cwd/src
1389
1478
  ];
1390
1479
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
1391
1480
  for (const candidate of candidates) {
1392
- if (fs6.existsSync(candidate)) {
1393
- const html = fs6.readFileSync(candidate, "utf-8");
1481
+ if (fs7.existsSync(candidate)) {
1482
+ const html = fs7.readFileSync(candidate, "utf-8");
1394
1483
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1395
1484
  res.end(html);
1396
1485
  return;
@@ -1540,7 +1629,16 @@ var init_server = __esm({
1540
1629
  this.sessions.updateActivity(msg.sessionId);
1541
1630
  const notifySession = this.sessions.get(msg.sessionId);
1542
1631
  const notifyLabel = this.getSessionLabel(notifySession);
1543
- this.notifier.notifyWithSession(msg.sessionId, notifyLabel, `[${notifyLabel}] ${msg.title}`, msg.message, msg.level ?? "info");
1632
+ const notifyTitle = `[${notifyLabel}] ${msg.title}`;
1633
+ if (msg.to === "api") {
1634
+ logger.info(`${notifyTitle}: ${msg.message}`);
1635
+ break;
1636
+ }
1637
+ if (msg.to === "telegram") {
1638
+ void this.telegramBot?.sendNotification(msg.sessionId, notifyLabel, notifyTitle, msg.message);
1639
+ break;
1640
+ }
1641
+ if (msg.to !== "dashboard") this.notifier.notifyWithSession(msg.sessionId, notifyLabel, notifyTitle, msg.message, msg.level ?? "info");
1544
1642
  this.broadcastToDashboards({
1545
1643
  type: "notification",
1546
1644
  sessionId: msg.sessionId,
@@ -1742,9 +1840,14 @@ var init_server = __esm({
1742
1840
  const channelWs = this.channelSockets.get(msg.sessionId);
1743
1841
  if (channelWs?.readyState === WebSocket2.OPEN) {
1744
1842
  channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1843
+ } else {
1844
+ const reason = "the session is not connected";
1845
+ logger.warn(`Message rejected for ${msg.sessionId}: ${reason}`);
1846
+ const rejected = { type: "message_rejected", sessionId: msg.sessionId, reason };
1847
+ if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify(rejected));
1745
1848
  }
1746
1849
  } else if (msg.type === "image_upload") {
1747
- this.handleImageUpload(msg);
1850
+ this.handleImageUpload(ws, msg);
1748
1851
  } else if (msg.type === "permission_response") {
1749
1852
  if (this.forwardPermissionResponse(msg)) {
1750
1853
  const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
@@ -1795,27 +1898,38 @@ var init_server = __esm({
1795
1898
  }
1796
1899
  }
1797
1900
  }
1798
- handleImageUpload(msg) {
1901
+ handleImageUpload(ws, msg) {
1799
1902
  const { sessionId: sessionId2, imageData, mimeType, originalName, content } = msg;
1903
+ const reject = (reason) => {
1904
+ logger.warn(`Image upload rejected for ${sessionId2}: ${reason}`);
1905
+ const rejected = { type: "upload_rejected", sessionId: sessionId2, reason, withText: typeof content === "string" && content.trim().length > 0 };
1906
+ if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify(rejected));
1907
+ };
1908
+ const channelWs = this.channelSockets.get(sessionId2);
1909
+ if (!channelWs || channelWs.readyState !== WebSocket2.OPEN) {
1910
+ reject("the session is not connected");
1911
+ return;
1912
+ }
1800
1913
  if (!this.localChannels.has(sessionId2)) {
1801
- logger.warn(`Image upload rejected: session ${sessionId2} is not local`);
1914
+ reject("this session is on another PC; images can only go to sessions on the hub's PC");
1802
1915
  return;
1803
1916
  }
1804
- const channelWs = this.channelSockets.get(sessionId2);
1805
- if (!channelWs || channelWs.readyState !== WebSocket2.OPEN) return;
1806
1917
  const allowedTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
1807
- if (!allowedTypes.includes(mimeType)) return;
1918
+ if (!allowedTypes.includes(mimeType)) {
1919
+ reject("only PNG, JPEG, GIF and WebP images are supported");
1920
+ return;
1921
+ }
1808
1922
  const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
1809
1923
  const buffer = Buffer.from(base64Data, "base64");
1810
1924
  if (buffer.length > 10 * 1024 * 1024) {
1811
- logger.warn("Image upload rejected: exceeds 10MB");
1925
+ reject("the image is larger than 10 MB");
1812
1926
  return;
1813
1927
  }
1814
- fs6.mkdirSync(UPLOADS_DIR, { recursive: true });
1928
+ fs7.mkdirSync(UPLOADS_DIR, { recursive: true });
1815
1929
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1816
1930
  const filename = `${randomUUID3()}.${ext}`;
1817
- const filePath = path7.join(UPLOADS_DIR, filename);
1818
- fs6.writeFileSync(filePath, buffer);
1931
+ const filePath = path8.join(UPLOADS_DIR, filename);
1932
+ fs7.writeFileSync(filePath, buffer);
1819
1933
  const forwardMsg = {
1820
1934
  type: "image_to_session",
1821
1935
  sessionId: sessionId2,
@@ -1829,7 +1943,7 @@ var init_server = __esm({
1829
1943
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1830
1944
  setTimeout(() => {
1831
1945
  try {
1832
- fs6.unlinkSync(filePath);
1946
+ fs7.unlinkSync(filePath);
1833
1947
  } catch {
1834
1948
  }
1835
1949
  }, 5 * 60 * 1e3).unref();
@@ -1856,19 +1970,25 @@ var init_server = __esm({
1856
1970
  this.telegramBot.getSessions = () => this.sessions.getAll();
1857
1971
  this.telegramBot.onMessageToSession = (sessionId2, content) => {
1858
1972
  const channelWs = this.channelSockets.get(sessionId2);
1859
- if (channelWs?.readyState === WebSocket2.OPEN) {
1860
- const msg = { type: "message_to_session", sessionId: sessionId2, content, source: "telegram" };
1861
- channelWs.send(JSON.stringify(msg));
1862
- logger.info(`Telegram message forwarded to session: ${sessionId2}`);
1973
+ if (channelWs?.readyState !== WebSocket2.OPEN) {
1974
+ logger.warn(`Telegram message not delivered to ${sessionId2}: the session is not connected`);
1975
+ return false;
1863
1976
  }
1977
+ const msg = { type: "message_to_session", sessionId: sessionId2, content, source: "telegram" };
1978
+ channelWs.send(JSON.stringify(msg));
1979
+ logger.info(`Telegram message forwarded to session: ${sessionId2}`);
1980
+ return true;
1864
1981
  };
1865
1982
  this.telegramBot.onImageToSession = (sessionId2, imagePath, mimeType, caption) => {
1866
1983
  const channelWs = this.channelSockets.get(sessionId2);
1867
- if (channelWs?.readyState === WebSocket2.OPEN) {
1868
- const msg = { type: "image_to_session", sessionId: sessionId2, imagePath, mimeType, content: caption, source: "telegram" };
1869
- channelWs.send(JSON.stringify(msg));
1870
- logger.info(`Telegram photo forwarded to session: ${sessionId2}`);
1984
+ if (channelWs?.readyState !== WebSocket2.OPEN) {
1985
+ logger.warn(`Telegram photo not delivered to ${sessionId2}: the session is not connected`);
1986
+ return false;
1871
1987
  }
1988
+ const msg = { type: "image_to_session", sessionId: sessionId2, imagePath, mimeType, content: caption, source: "telegram" };
1989
+ channelWs.send(JSON.stringify(msg));
1990
+ logger.info(`Telegram photo forwarded to session: ${sessionId2}`);
1991
+ return true;
1872
1992
  };
1873
1993
  this.telegramBot.onPermissionVerdict = (sessionId2, requestId, behavior) => {
1874
1994
  if (this.forwardPermissionResponse({ sessionId: sessionId2, requestId, behavior })) {
@@ -1993,7 +2113,7 @@ var init_server = __esm({
1993
2113
  logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1994
2114
  return;
1995
2115
  }
1996
- this.codexSupervisor = new CodexSupervisor(script);
2116
+ this.codexSupervisor = new CodexSupervisor(script, { env: adapterEnv(process.env, { host: this.host, port: this.port }) });
1997
2117
  this.codexSupervisor.start();
1998
2118
  logger.info("Codex adapter started");
1999
2119
  }
@@ -2020,11 +2140,11 @@ var init_server = __esm({
2020
2140
  }
2021
2141
  cleanupUploads() {
2022
2142
  try {
2023
- if (!fs6.existsSync(UPLOADS_DIR)) return;
2024
- const files = fs6.readdirSync(UPLOADS_DIR);
2143
+ if (!fs7.existsSync(UPLOADS_DIR)) return;
2144
+ const files = fs7.readdirSync(UPLOADS_DIR);
2025
2145
  for (const file of files) {
2026
2146
  try {
2027
- fs6.unlinkSync(path7.join(UPLOADS_DIR, file));
2147
+ fs7.unlinkSync(path8.join(UPLOADS_DIR, file));
2028
2148
  } catch {
2029
2149
  }
2030
2150
  }
@@ -2223,9 +2343,9 @@ var init_hub_client = __esm({
2223
2343
  });
2224
2344
 
2225
2345
  // src/channel/peer-name.ts
2226
- import fs7 from "fs";
2346
+ import fs8 from "fs";
2227
2347
  import os2 from "os";
2228
- import path8 from "path";
2348
+ import path9 from "path";
2229
2349
  function findPeerName(records, env) {
2230
2350
  const valid = records.filter((r) => typeof r === "object" && r !== null);
2231
2351
  const nameOf = (match) => {
@@ -2245,18 +2365,18 @@ function readPeerName(env = process.env) {
2245
2365
  sessionId: env.CLAUDE_CODE_SESSION_ID
2246
2366
  };
2247
2367
  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");
2368
+ const configDir = env.CLAUDE_CONFIG_DIR || path9.join(os2.homedir(), ".claude");
2369
+ const dir = path9.join(configDir, "sessions");
2250
2370
  let files;
2251
2371
  try {
2252
- files = fs7.readdirSync(dir).filter((f) => f.endsWith(".json"));
2372
+ files = fs8.readdirSync(dir).filter((f) => f.endsWith(".json"));
2253
2373
  } catch {
2254
2374
  return void 0;
2255
2375
  }
2256
2376
  const records = [];
2257
2377
  for (const f of files) {
2258
2378
  try {
2259
- records.push(JSON.parse(fs7.readFileSync(path8.join(dir, f), "utf8")));
2379
+ records.push(JSON.parse(fs8.readFileSync(path9.join(dir, f), "utf8")));
2260
2380
  } catch {
2261
2381
  }
2262
2382
  }
@@ -2279,7 +2399,7 @@ import {
2279
2399
  } from "@modelcontextprotocol/sdk/types.js";
2280
2400
  import { z } from "zod";
2281
2401
  import { randomUUID as randomUUID4 } from "crypto";
2282
- import path9 from "path";
2402
+ import path10 from "path";
2283
2403
  async function main() {
2284
2404
  logger.info(`Starting MCP channel server (session: ${sessionId})`);
2285
2405
  hubClient.connect();
@@ -2348,7 +2468,7 @@ var init_server2 = __esm({
2348
2468
  init_hub_client();
2349
2469
  init_peer_name2();
2350
2470
  sessionId = randomUUID4();
2351
- sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path9.basename(process.cwd());
2471
+ sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path10.basename(process.cwd());
2352
2472
  peerName = readPeerName();
2353
2473
  server = new Server(
2354
2474
  {
@@ -2525,20 +2645,21 @@ NOTIFICATIONS:
2525
2645
  // src/cli.ts
2526
2646
  init_config();
2527
2647
  import { spawn as spawn3 } from "child_process";
2528
- import fs8 from "fs";
2529
- import path10 from "path";
2648
+ import fs9 from "fs";
2649
+ import path11 from "path";
2530
2650
  import readline from "readline";
2531
2651
  import { fileURLToPath as fileURLToPath2 } from "url";
2532
2652
 
2533
2653
  // src/codex/transport.ts
2534
2654
  init_logger();
2535
- import { spawn } from "child_process";
2655
+ import { execFile, spawn } from "child_process";
2536
2656
  import fs2 from "fs";
2537
2657
  import path3 from "path";
2538
2658
  import { Duplex } from "stream";
2539
2659
  import WebSocket from "ws";
2540
2660
  function resolveCommand(command, platform = process.platform, env = process.env) {
2541
- if (platform !== "win32" || /[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: false };
2661
+ if (platform !== "win32") return { file: command, shell: false };
2662
+ if (/[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: /\.(cmd|bat)$/i.test(command) };
2542
2663
  const file = findCodex(command, platform, env);
2543
2664
  return file ? { file, shell: file.endsWith(".cmd") } : { file: command, shell: false };
2544
2665
  }
@@ -2561,13 +2682,116 @@ function findCodex(command, platform = process.platform, env = process.env) {
2561
2682
  ];
2562
2683
  return candidates.find((file) => !!file && fs2.existsSync(file));
2563
2684
  }
2685
+ var shellSpawns = /* @__PURE__ */ new WeakSet();
2686
+ var spawnedThroughShell = (child) => shellSpawns.has(child);
2564
2687
  var defaultSpawn = (command, args) => {
2565
2688
  const { file, shell } = resolveCommand(command);
2566
- if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
2567
- return spawn(file, args, { stdio: "pipe", windowsHide: true });
2689
+ if (!shell) return spawn(file, args, { stdio: "pipe", windowsHide: true });
2690
+ const child = spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
2691
+ shellSpawns.add(child);
2692
+ return child;
2693
+ };
2694
+ var queryProcesses = () => new Promise((resolve, reject) => {
2695
+ const script = "@(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ pid = [int]$_.ProcessId; parentPid = [int]$_.ParentProcessId; creationTime = $_.CreationDate.ToUniversalTime().ToString('o') } }) | ConvertTo-Json -Compress";
2696
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 5e3, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
2697
+ if (err) {
2698
+ reject(err);
2699
+ return;
2700
+ }
2701
+ try {
2702
+ const rows = JSON.parse(stdout);
2703
+ if (!Array.isArray(rows) || rows.some((row) => !Number.isInteger(row.pid) || !Number.isInteger(row.parentPid) || typeof row.creationTime !== "string")) {
2704
+ throw new Error("invalid process snapshot");
2705
+ }
2706
+ resolve(rows);
2707
+ } catch (error) {
2708
+ reject(error);
2709
+ }
2710
+ });
2711
+ });
2712
+ var runFile = (file, args, options, callback) => {
2713
+ execFile(file, args, options, (err) => callback(err));
2568
2714
  };
2569
- function connectProxy(command, spawnFn = defaultSpawn) {
2715
+ function descendantsOf(rootPid, rows) {
2716
+ const root = rows.find((row) => row.pid === rootPid);
2717
+ if (!root) return void 0;
2718
+ const found = [];
2719
+ const seen = /* @__PURE__ */ new Set([root.pid]);
2720
+ let level = [root];
2721
+ for (let depth = 1; level.length; depth++) {
2722
+ const next = [];
2723
+ for (const parent of level) {
2724
+ for (const row of rows) {
2725
+ if (row.parentPid !== parent.pid || seen.has(row.pid) || row.creationTime < parent.creationTime) continue;
2726
+ seen.add(row.pid);
2727
+ next.push(row);
2728
+ found.push({ ...row, depth });
2729
+ }
2730
+ }
2731
+ level = next;
2732
+ }
2733
+ return found;
2734
+ }
2735
+ var exited = (child) => child.exitCode !== null || child.signalCode !== null;
2736
+ function treeKiller(platform = process.platform, run = runFile, query = queryProcesses, throughShell = spawnedThroughShell) {
2737
+ const recorded = /* @__PURE__ */ new WeakMap();
2738
+ const stop = (pid) => new Promise((resolve) => {
2739
+ run("taskkill", ["/PID", String(pid), "/F"], { windowsHide: true }, (err) => {
2740
+ if (err) logger.debug(`taskkill ${pid} failed: ${err.message}`);
2741
+ resolve();
2742
+ });
2743
+ });
2744
+ const stopDeepestFirst = async (descendants) => {
2745
+ for (const descendant of [...descendants].sort((a, b) => b.depth - a.depth)) await stop(descendant.pid);
2746
+ };
2747
+ const snapshot = async () => {
2748
+ try {
2749
+ return await query();
2750
+ } catch (err) {
2751
+ logger.debug(`codex process query failed: ${String(err)}`);
2752
+ return void 0;
2753
+ }
2754
+ };
2755
+ const isShellTree = (child) => platform === "win32" && child.pid !== void 0 && throughShell(child);
2756
+ const killer = async (child) => {
2757
+ if (!isShellTree(child)) {
2758
+ if (!exited(child)) child.kill();
2759
+ return;
2760
+ }
2761
+ if (!exited(child)) {
2762
+ const rows = await snapshot();
2763
+ if (!exited(child)) {
2764
+ const descendants2 = rows && descendantsOf(child.pid, rows);
2765
+ if (descendants2) await stopDeepestFirst(descendants2);
2766
+ child.kill();
2767
+ return;
2768
+ }
2769
+ }
2770
+ const descendants = await recorded.get(child) ?? [];
2771
+ if (!descendants.length) return;
2772
+ const current = await snapshot();
2773
+ if (!current) return;
2774
+ await stopDeepestFirst(descendants.filter((d) => current.some((row) => row.pid === d.pid && row.creationTime === d.creationTime)));
2775
+ };
2776
+ killer.track = async (child) => {
2777
+ if (!isShellTree(child) || exited(child)) return;
2778
+ const earlier = recorded.get(child);
2779
+ const latest = (async () => {
2780
+ const rows = await snapshot();
2781
+ const found = rows && !exited(child) ? descendantsOf(child.pid, rows) ?? [] : [];
2782
+ const before = await earlier ?? [];
2783
+ return [...before, ...found.filter((d) => !before.some((b) => b.pid === d.pid && b.creationTime === d.creationTime))];
2784
+ })();
2785
+ recorded.set(child, latest);
2786
+ await latest;
2787
+ };
2788
+ return killer;
2789
+ }
2790
+ var killTree = treeKiller();
2791
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
2792
+ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS, killTreeFn = killTree) {
2570
2793
  const child = spawnFn(command, ["app-server", "proxy"]);
2794
+ void killTreeFn.track?.(child);
2571
2795
  child.stdin?.on("error", () => {
2572
2796
  });
2573
2797
  child.stderr?.on("data", (d) => logger.debug(`codex proxy: ${String(d).trim()}`));
@@ -2594,17 +2818,20 @@ function connectProxy(command, spawnFn = defaultSpawn) {
2594
2818
  const ws = new WebSocket("ws://localhost/", {
2595
2819
  createConnection: (() => stream)
2596
2820
  });
2597
- const close = () => {
2821
+ let closing;
2822
+ const close = () => closing ??= (async () => {
2598
2823
  ws.terminate();
2599
- child.kill();
2600
- };
2824
+ child.stdin?.end();
2825
+ await killTreeFn(child);
2826
+ })();
2601
2827
  return new Promise((resolve, reject) => {
2602
2828
  let settled = false;
2829
+ const timer = setTimeout(() => fail(new Error(`codex daemon did not answer within ${timeoutMs / 1e3}s`)), timeoutMs);
2603
2830
  const fail = (err) => {
2604
2831
  if (settled) return;
2605
2832
  settled = true;
2606
- close();
2607
- reject(err);
2833
+ clearTimeout(timer);
2834
+ void close().then(() => reject(err), () => reject(err));
2608
2835
  };
2609
2836
  child.on("error", fail);
2610
2837
  child.once("exit", (code) => fail(new Error(`codex proxy exited (code ${code})`)));
@@ -2612,8 +2839,10 @@ function connectProxy(command, spawnFn = defaultSpawn) {
2612
2839
  ws.once("open", () => {
2613
2840
  if (settled) return;
2614
2841
  settled = true;
2842
+ clearTimeout(timer);
2615
2843
  child.on("error", (err) => logger.warn(`codex proxy error: ${err.message}`));
2616
2844
  ws.on("error", (err) => logger.warn(`codex daemon socket error: ${err.message}`));
2845
+ void killTreeFn.track?.(child);
2617
2846
  resolve({ ws, close });
2618
2847
  });
2619
2848
  });
@@ -2905,7 +3134,7 @@ async function runCodexTask(opts) {
2905
3134
  };
2906
3135
  } finally {
2907
3136
  for (const t of timers) clearTimeout(t);
2908
- conn.close();
3137
+ await conn.close();
2909
3138
  }
2910
3139
  }
2911
3140
  var VALUE_FLAGS = {
@@ -3025,7 +3254,7 @@ function resolveAdapterHub(config2, env = process.env) {
3025
3254
  // src/codex/start-check.ts
3026
3255
  var ADAPTER_REPORT_TIMEOUT_MS = 1e4;
3027
3256
  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";
3257
+ 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
3258
  var CONFIG_PATH = "~/.claude-alarm/config.json";
3030
3259
  var NOT_CHECKED = { text: "Codex daemon: not checked (the adapter was already running)", warning: false };
3031
3260
  function toOutcome(msg) {
@@ -3042,9 +3271,11 @@ function waitForAdapterReport(child, timeoutMs = ADAPTER_REPORT_TIMEOUT_MS) {
3042
3271
  resolve(result);
3043
3272
  };
3044
3273
  const onMessage = (msg) => {
3045
- if (msg && typeof msg === "object" && msg.type === "codex-first-connect") {
3046
- finish({ kind: "report", outcome: toOutcome(msg) });
3047
- }
3274
+ if (!msg || typeof msg !== "object") return;
3275
+ const m = msg;
3276
+ if (m.type === "codex-first-connect") finish({ kind: "report", outcome: toOutcome(m) });
3277
+ else if (m.type === "codex-already-running" && Number.isInteger(m.pid)) finish({ kind: "already", pid: m.pid });
3278
+ else if (m.type === "codex-lock-failed") finish({ kind: "lockFailed", error: String(m.error ?? "") });
3048
3279
  };
3049
3280
  const onExit = (code, signal) => finish({ kind: "exited", code, signal });
3050
3281
  const onError = (error) => finish({ kind: "exited", code: null, signal: null, error });
@@ -3124,9 +3355,9 @@ function printAlreadyRunning(d, pid, hub) {
3124
3355
  printChecks(d, NOT_CHECKED, hubLine(hub, d.hub));
3125
3356
  }
3126
3357
  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));
3358
+ const existing = await d.queryOwner();
3359
+ if (existing.state === "running") {
3360
+ printAlreadyRunning(d, existing.pid, await checkHub(d.hub, d.hubTimeoutMs));
3130
3361
  return 0;
3131
3362
  }
3132
3363
  const child = d.spawnAdapter();
@@ -3143,6 +3374,14 @@ async function startAdapter(d) {
3143
3374
  ]);
3144
3375
  if (child.connected) child.disconnect();
3145
3376
  child.unref();
3377
+ if (report.kind === "already") {
3378
+ printAlreadyRunning(d, report.pid, hub);
3379
+ return 0;
3380
+ }
3381
+ if (report.kind === "lockFailed") {
3382
+ d.err(`Codex adapter cannot start: ${report.error}`);
3383
+ return 1;
3384
+ }
3146
3385
  if (report.kind !== "exited" && !seen.exit && child.pid !== void 0) {
3147
3386
  d.out(`Codex adapter started (PID: ${child.pid}). Logs: ${d.logFile}`);
3148
3387
  printChecks(d, daemonLine(report, d.command, d.logFile), hubLine(hub, d.hub));
@@ -3153,10 +3392,9 @@ async function startAdapter(d) {
3153
3392
  d.err(`Codex adapter failed to start: ${exit?.error?.message ?? "no process id"}`);
3154
3393
  return 1;
3155
3394
  }
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);
3395
+ const owner = await d.queryOwner();
3396
+ if (owner.state === "running" && owner.pid !== child.pid) {
3397
+ printAlreadyRunning(d, owner.pid, hub);
3160
3398
  return 0;
3161
3399
  }
3162
3400
  const how = exit.code !== null ? `code ${exit.code}` : `signal ${exit.signal}`;
@@ -3164,8 +3402,147 @@ async function startAdapter(d) {
3164
3402
  return 1;
3165
3403
  }
3166
3404
 
3405
+ // src/codex/instance-lock.ts
3406
+ import crypto from "crypto";
3407
+ import fs4 from "fs";
3408
+ import net2 from "net";
3409
+ import path5 from "path";
3410
+ var CONTROL_PROTOCOL = 1;
3411
+ var STOP_PROOF_LABEL = "claude-alarm codex stop";
3412
+ var MAX_LINE_BYTES = 4096;
3413
+ function stopProof(token) {
3414
+ return crypto.createHmac("sha256", token).update(STOP_PROOF_LABEL).digest("hex");
3415
+ }
3416
+ function controlEndpoint(configDir, platform = process.platform) {
3417
+ if (platform !== "win32") return path5.posix.join(configDir, "codex.sock");
3418
+ let dir;
3419
+ try {
3420
+ dir = fs4.realpathSync.native(configDir);
3421
+ } catch {
3422
+ dir = path5.win32.resolve(configDir);
3423
+ }
3424
+ const hash = crypto.createHash("sha256").update(dir.toLowerCase()).digest("hex").slice(0, 16);
3425
+ return `\\\\.\\pipe\\claude-alarm-codex-${hash}`;
3426
+ }
3427
+ function ask(endpoint, request, timeoutMs) {
3428
+ return new Promise((resolve) => {
3429
+ let buffer = "";
3430
+ let settled = false;
3431
+ const socket = net2.connect(endpoint);
3432
+ const finish = (answer) => {
3433
+ if (settled) return;
3434
+ settled = true;
3435
+ clearTimeout(timer);
3436
+ socket.destroy();
3437
+ resolve(answer);
3438
+ };
3439
+ const timer = setTimeout(() => finish({ state: "unknown", error: `no answer within ${timeoutMs}ms` }), timeoutMs);
3440
+ socket.setEncoding("utf8");
3441
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}
3442
+ `));
3443
+ socket.on("data", (chunk) => {
3444
+ buffer += chunk;
3445
+ const end = buffer.indexOf("\n");
3446
+ if (end < 0) {
3447
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) finish({ state: "unknown", error: "reply too long" });
3448
+ return;
3449
+ }
3450
+ try {
3451
+ finish({ reply: JSON.parse(buffer.slice(0, end)) });
3452
+ } catch {
3453
+ finish({ state: "unknown", error: "unreadable reply" });
3454
+ }
3455
+ });
3456
+ socket.on("end", () => finish({ state: "unknown", error: "closed without a reply" }));
3457
+ socket.on("error", (err) => {
3458
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") finish({ state: "absent" });
3459
+ else finish({ state: "unknown", error: err.code ?? err.message });
3460
+ });
3461
+ });
3462
+ }
3463
+ async function queryOwner(endpoint, timeoutMs = 1e3) {
3464
+ const answer = await ask(endpoint, { type: "status" }, timeoutMs);
3465
+ if (!("reply" in answer)) return answer;
3466
+ const reply = answer.reply;
3467
+ if (reply?.type === "status" && reply.protocol === CONTROL_PROTOCOL && Number.isInteger(reply.pid)) {
3468
+ return { state: "running", pid: reply.pid };
3469
+ }
3470
+ return { state: "unknown", error: "unexpected reply" };
3471
+ }
3472
+ async function requestStop(endpoint, token, timeoutMs = 1e3) {
3473
+ const answer = await ask(endpoint, { type: "stop", proof: stopProof(token) }, timeoutMs);
3474
+ if (!("reply" in answer)) return answer;
3475
+ const reply = answer.reply;
3476
+ if (reply?.type === "stopping" && Number.isInteger(reply.pid)) return { state: "stopping", pid: reply.pid };
3477
+ if (reply?.type === "error" && reply.error === "unauthorized") return { state: "unauthorized" };
3478
+ return { state: "unknown", error: "unexpected reply" };
3479
+ }
3480
+
3481
+ // src/codex/control-cli.ts
3482
+ var STOP_WAIT_MS = 5e3;
3483
+ var STOP_POLL_MS = 100;
3484
+ function legacyNote(d, ownerPid) {
3485
+ const pid = d.legacyPid();
3486
+ if (pid === void 0 || pid === ownerPid) return;
3487
+ if (!d.isRunning(pid)) {
3488
+ d.removeLegacyPidFile();
3489
+ return;
3490
+ }
3491
+ 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.`);
3492
+ }
3493
+ function unavailable(d, error) {
3494
+ d.err(`Codex adapter control endpoint is unavailable: ${error}`);
3495
+ return 1;
3496
+ }
3497
+ async function stopAdapter(d, startWithHub) {
3498
+ const owner = await d.queryOwner();
3499
+ if (owner.state === "unknown") return unavailable(d, owner.error);
3500
+ if (owner.state === "absent") {
3501
+ d.out("Codex adapter is not running");
3502
+ legacyNote(d);
3503
+ return 0;
3504
+ }
3505
+ const reply = await d.requestStop();
3506
+ if (reply.state === "unknown") return unavailable(d, reply.error);
3507
+ if (reply.state === "unauthorized") {
3508
+ d.err(`Codex adapter refused to stop: the token in ${d.configFile} does not match the one it started with`);
3509
+ return 1;
3510
+ }
3511
+ if (reply.state === "absent") {
3512
+ d.out("Codex adapter is not running");
3513
+ return 0;
3514
+ }
3515
+ const sleep = d.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
3516
+ for (let waited = 0; waited < (d.stopWaitMs ?? STOP_WAIT_MS); waited += STOP_POLL_MS) {
3517
+ await sleep(STOP_POLL_MS);
3518
+ const now = await d.queryOwner();
3519
+ if (now.state === "absent" || now.state === "running" && now.pid !== reply.pid) {
3520
+ d.out(`Codex adapter stopped (PID: ${reply.pid})`);
3521
+ if (now.state === "running") d.out(`Another Codex adapter took over (PID: ${now.pid})`);
3522
+ else if (startWithHub) d.out("If the hub started it, the hub will not start it again until you restart the hub.");
3523
+ return 0;
3524
+ }
3525
+ }
3526
+ d.err(`Stop requested, but the Codex adapter (PID: ${reply.pid}) is still running. Check with: claude-alarm codex status`);
3527
+ return 1;
3528
+ }
3529
+ async function adapterStatus(d, startWithHub) {
3530
+ const owner = await d.queryOwner();
3531
+ if (owner.state === "running") {
3532
+ d.out(`Codex adapter: running (PID: ${owner.pid})`);
3533
+ legacyNote(d, owner.pid);
3534
+ } else if (owner.state === "unknown") {
3535
+ d.out(`Codex adapter: unknown (${owner.error})`);
3536
+ } else {
3537
+ d.out("Codex adapter: not running");
3538
+ legacyNote(d);
3539
+ }
3540
+ d.out(`Start with hub: ${startWithHub ? "enabled" : "disabled"}`);
3541
+ return 0;
3542
+ }
3543
+
3167
3544
  // src/cli.ts
3168
- var __dirname2 = path10.dirname(fileURLToPath2(import.meta.url));
3545
+ var __dirname2 = path11.dirname(fileURLToPath2(import.meta.url));
3169
3546
  function authHeaders(token) {
3170
3547
  return token ? { Authorization: `Bearer ${token}` } : {};
3171
3548
  }
@@ -3201,8 +3578,8 @@ Quick start:
3201
3578
  }
3202
3579
  async function checkForUpdates() {
3203
3580
  try {
3204
- const pkgPath = path10.join(__dirname2, "..", "package.json");
3205
- const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
3581
+ const pkgPath = path11.join(__dirname2, "..", "package.json");
3582
+ const pkg = JSON.parse(fs9.readFileSync(pkgPath, "utf-8"));
3206
3583
  const currentVersion = pkg.version;
3207
3584
  const res = await fetch("https://registry.npmjs.org/@delt/claude-alarm/latest", {
3208
3585
  signal: AbortSignal.timeout(3e3)
@@ -3225,18 +3602,18 @@ async function hubStart(daemon) {
3225
3602
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
3226
3603
  const displayHost = hubUrlHost(host);
3227
3604
  checkForUpdates();
3228
- if (fs8.existsSync(PID_FILE)) {
3229
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3605
+ if (fs9.existsSync(PID_FILE)) {
3606
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3230
3607
  if (isProcessRunning(pid)) {
3231
3608
  console.log(`Hub is already running (PID: ${pid}). Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
3232
3609
  return;
3233
3610
  }
3234
- fs8.unlinkSync(PID_FILE);
3611
+ fs9.unlinkSync(PID_FILE);
3235
3612
  }
3236
3613
  if (daemon) {
3237
3614
  ensureConfigDir();
3238
- const logFd = fs8.openSync(LOG_FILE, "a");
3239
- const hubScript = path10.join(__dirname2, "hub", "server.js");
3615
+ const logFd = fs9.openSync(LOG_FILE, "a");
3616
+ const hubScript = path11.join(__dirname2, "hub", "server.js");
3240
3617
  const child = spawn3(process.execPath, [hubScript], {
3241
3618
  detached: true,
3242
3619
  stdio: ["ignore", logFd, logFd],
@@ -3247,21 +3624,21 @@ async function hubStart(daemon) {
3247
3624
  console.error("Failed to start hub daemon");
3248
3625
  process.exit(1);
3249
3626
  }
3250
- fs8.writeFileSync(PID_FILE, String(pid), "utf-8");
3251
- let exited = false;
3627
+ fs9.writeFileSync(PID_FILE, String(pid), "utf-8");
3628
+ let exited2 = false;
3252
3629
  child.once("exit", () => {
3253
- exited = true;
3630
+ exited2 = true;
3254
3631
  });
3255
3632
  child.unref();
3256
3633
  const startup = await waitForHub({
3257
3634
  url: `http://${displayHost}:${port}/api/status`,
3258
3635
  token: config2.hub.token,
3259
3636
  pid,
3260
- isAlive: () => !exited
3637
+ isAlive: () => !exited2
3261
3638
  });
3262
3639
  if (startup !== "ready") {
3263
- if (!exited) child.kill();
3264
- if (fs8.existsSync(PID_FILE)) fs8.unlinkSync(PID_FILE);
3640
+ if (!exited2) child.kill();
3641
+ if (fs9.existsSync(PID_FILE)) fs9.unlinkSync(PID_FILE);
3265
3642
  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
3643
  process.exit(1);
3267
3644
  }
@@ -3280,11 +3657,11 @@ async function hubStart(daemon) {
3280
3657
  const hub = new HubServer2(config2);
3281
3658
  await hub.start();
3282
3659
  ensureConfigDir();
3283
- fs8.writeFileSync(PID_FILE, String(process.pid), "utf-8");
3660
+ fs9.writeFileSync(PID_FILE, String(process.pid), "utf-8");
3284
3661
  const shutdown = async () => {
3285
3662
  console.log("\nShutting down...");
3286
3663
  await hub.stop();
3287
- if (fs8.existsSync(PID_FILE)) fs8.unlinkSync(PID_FILE);
3664
+ if (fs9.existsSync(PID_FILE)) fs9.unlinkSync(PID_FILE);
3288
3665
  process.exit(0);
3289
3666
  };
3290
3667
  process.on("SIGINT", shutdown);
@@ -3292,18 +3669,18 @@ async function hubStart(daemon) {
3292
3669
  }
3293
3670
  }
3294
3671
  function hubStop() {
3295
- if (!fs8.existsSync(PID_FILE)) {
3672
+ if (!fs9.existsSync(PID_FILE)) {
3296
3673
  console.log("Hub is not running (no PID file found)");
3297
3674
  return;
3298
3675
  }
3299
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3676
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3300
3677
  try {
3301
3678
  process.kill(pid, "SIGTERM");
3302
3679
  console.log(`Hub stopped (PID: ${pid})`);
3303
3680
  } catch {
3304
3681
  console.log("Hub process not found (may have already stopped)");
3305
3682
  }
3306
- fs8.unlinkSync(PID_FILE);
3683
+ fs9.unlinkSync(PID_FILE);
3307
3684
  }
3308
3685
  async function hubStatus() {
3309
3686
  const config2 = loadConfig();
@@ -3311,8 +3688,8 @@ async function hubStatus() {
3311
3688
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
3312
3689
  const displayHost = hubUrlHost(host);
3313
3690
  let pidInfo = "not running";
3314
- if (fs8.existsSync(PID_FILE)) {
3315
- const pid = parseInt(fs8.readFileSync(PID_FILE, "utf-8").trim(), 10);
3691
+ if (fs9.existsSync(PID_FILE)) {
3692
+ const pid = parseInt(fs9.readFileSync(PID_FILE, "utf-8").trim(), 10);
3316
3693
  if (isProcessRunning(pid)) {
3317
3694
  pidInfo = `running (PID: ${pid})`;
3318
3695
  } else {
@@ -3373,7 +3750,7 @@ async function test() {
3373
3750
  console.error("Could not reach hub. Start it first: claude-alarm hub start");
3374
3751
  }
3375
3752
  }
3376
- function ask(question) {
3753
+ function ask2(question) {
3377
3754
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3378
3755
  return new Promise((resolve) => {
3379
3756
  rl.question(question, (answer) => {
@@ -3384,18 +3761,18 @@ function ask(question) {
3384
3761
  }
3385
3762
  async function init() {
3386
3763
  const dir = process.cwd();
3387
- const projectName = path10.basename(dir);
3764
+ const projectName = path11.basename(dir);
3388
3765
  console.log(`
3389
3766
  claude-alarm init for "${projectName}"
3390
3767
  `);
3391
- const remote = await ask("Connect to a remote hub? (y/N): ");
3768
+ const remote = await ask2("Connect to a remote hub? (y/N): ");
3392
3769
  let env = {
3393
3770
  CLAUDE_ALARM_SESSION_NAME: projectName
3394
3771
  };
3395
3772
  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: ");
3773
+ const host = await ask2("Hub host (e.g. 192.168.1.100): ");
3774
+ const port = await ask2("Hub port (default: 7900): ");
3775
+ const token = await ask2("Hub token: ");
3399
3776
  if (!host) {
3400
3777
  console.error("Host is required.");
3401
3778
  process.exit(1);
@@ -3404,11 +3781,11 @@ claude-alarm init for "${projectName}"
3404
3781
  if (port) env.CLAUDE_ALARM_HUB_PORT = port;
3405
3782
  if (token) env.CLAUDE_ALARM_HUB_TOKEN = token;
3406
3783
  }
3407
- const mcpPath = path10.join(dir, ".mcp.json");
3784
+ const mcpPath = path11.join(dir, ".mcp.json");
3408
3785
  let mcpConfig = {};
3409
- if (fs8.existsSync(mcpPath)) {
3786
+ if (fs9.existsSync(mcpPath)) {
3410
3787
  try {
3411
- mcpConfig = JSON.parse(fs8.readFileSync(mcpPath, "utf-8"));
3788
+ mcpConfig = JSON.parse(fs9.readFileSync(mcpPath, "utf-8"));
3412
3789
  } catch {
3413
3790
  mcpConfig = {};
3414
3791
  }
@@ -3419,7 +3796,7 @@ claude-alarm init for "${projectName}"
3419
3796
  args: ["-y", "@delt/claude-alarm", "serve"],
3420
3797
  env
3421
3798
  };
3422
- fs8.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
3799
+ fs9.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
3423
3800
  console.log(`
3424
3801
  \u2713 Created ${mcpPath}`);
3425
3802
  if (remote.toLowerCase() !== "y") {
@@ -3441,7 +3818,7 @@ claude-alarm init for "${projectName}"
3441
3818
  }
3442
3819
  console.log(` Dashboard: http://${displayHost}:${port}`);
3443
3820
  if (shouldOfferCodex(config2, findCodex("codex") !== void 0)) {
3444
- const answer = await ask("\nCodex is installed. Show Codex conversations on the dashboard too? (y/N): ");
3821
+ const answer = await ask2("\nCodex is installed. Show Codex conversations on the dashboard too? (y/N): ");
3445
3822
  if (answer.toLowerCase() === "y") codexEnable(true);
3446
3823
  else setCodexEnabled(false);
3447
3824
  }
@@ -3470,8 +3847,8 @@ function isProcessRunning(pid) {
3470
3847
  }
3471
3848
  }
3472
3849
  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);
3850
+ if (!fs9.existsSync(CODEX_PID_FILE)) return void 0;
3851
+ const pid = parseInt(fs9.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10);
3475
3852
  return Number.isNaN(pid) ? void 0 : pid;
3476
3853
  }
3477
3854
  function codexEnable(enabled) {
@@ -3481,21 +3858,15 @@ function codexEnable(enabled) {
3481
3858
  async function codexStart() {
3482
3859
  const config2 = loadConfig();
3483
3860
  ensureConfigDir();
3861
+ legacyNote(controlDeps());
3484
3862
  const code = await startAdapter({
3485
3863
  hub: resolveAdapterHub(config2),
3486
3864
  command: config2.codex?.command ?? "codex",
3487
3865
  logFile: CODEX_LOG_FILE,
3488
- readPid: readCodexPid,
3489
- isRunning: isProcessRunning,
3490
- removePidFile: () => {
3491
- try {
3492
- fs8.unlinkSync(CODEX_PID_FILE);
3493
- } catch {
3494
- }
3495
- },
3866
+ queryOwner: () => queryOwner(controlEndpoint(CONFIG_DIR)),
3496
3867
  spawnAdapter: () => {
3497
- const logFd = fs8.openSync(CODEX_LOG_FILE, "a");
3498
- return spawn3(process.execPath, [path10.join(__dirname2, "codex", "main.js")], {
3868
+ const logFd = fs9.openSync(CODEX_LOG_FILE, "a");
3869
+ return spawn3(process.execPath, [path11.join(__dirname2, "codex", "main.js")], {
3499
3870
  detached: true,
3500
3871
  stdio: ["ignore", logFd, logFd, "ipc"],
3501
3872
  windowsHide: true,
@@ -3507,28 +3878,25 @@ async function codexStart() {
3507
3878
  });
3508
3879
  if (code !== 0) process.exit(code);
3509
3880
  }
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"}`);
3881
+ function controlDeps() {
3882
+ const endpoint = controlEndpoint(CONFIG_DIR);
3883
+ const token = loadConfig().hub.token ?? "";
3884
+ return {
3885
+ queryOwner: () => queryOwner(endpoint),
3886
+ requestStop: () => requestStop(endpoint, token),
3887
+ legacyPid: readCodexPid,
3888
+ isRunning: isProcessRunning,
3889
+ removeLegacyPidFile: () => {
3890
+ try {
3891
+ fs9.unlinkSync(CODEX_PID_FILE);
3892
+ } catch {
3893
+ }
3894
+ },
3895
+ legacyPidFile: CODEX_PID_FILE,
3896
+ configFile: CONFIG_FILE,
3897
+ out: (line) => console.log(line),
3898
+ err: (line) => console.error(line)
3899
+ };
3532
3900
  }
3533
3901
  async function codexRun(argv) {
3534
3902
  const abort = new AbortController();
@@ -3537,7 +3905,7 @@ async function codexRun(argv) {
3537
3905
  process.on("SIGTERM", stop);
3538
3906
  try {
3539
3907
  return await runFromCli(argv, {
3540
- readFile: (file) => fs8.promises.readFile(file, "utf-8"),
3908
+ readFile: (file) => fs9.promises.readFile(file, "utf-8"),
3541
3909
  readStdin: async () => {
3542
3910
  let text = "";
3543
3911
  for await (const chunk of process.stdin) text += chunk;
@@ -3588,8 +3956,8 @@ async function main2() {
3588
3956
  if (sub === "enable") codexEnable(true);
3589
3957
  else if (sub === "disable") codexEnable(false);
3590
3958
  else if (sub === "start") await codexStart();
3591
- else if (sub === "stop") codexStop();
3592
- else if (sub === "status") codexStatus();
3959
+ else if (sub === "stop") process.exitCode = await stopAdapter(controlDeps(), loadConfig().codex?.enabled === true);
3960
+ else if (sub === "status") process.exitCode = await adapterStatus(controlDeps(), loadConfig().codex?.enabled === true);
3593
3961
  else if (sub === "run") process.exitCode = await codexRun(args.slice(2));
3594
3962
  else {
3595
3963
  console.error(`Unknown codex command: ${sub}`);