@delt/claude-alarm 0.9.0 → 1.0.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
@@ -12,7 +12,7 @@ var __export = (target, all) => {
12
12
  // src/shared/constants.ts
13
13
  import path from "path";
14
14
  import os from "os";
15
- var DEFAULT_HUB_HOST, DEFAULT_HUB_PORT, CONFIG_DIR, CONFIG_FILE, PID_FILE, LOG_FILE, UPLOADS_DIR, WS_PATH_CHANNEL, WS_PATH_DASHBOARD, CHANNEL_SERVER_NAME, CHANNEL_SERVER_VERSION;
15
+ var DEFAULT_HUB_HOST, DEFAULT_HUB_PORT, CONFIG_DIR, CONFIG_FILE, PID_FILE, LOG_FILE, UPLOADS_DIR, CODEX_PID_FILE, CODEX_LOG_FILE, WS_PATH_CHANNEL, WS_PATH_DASHBOARD, CHANNEL_SERVER_NAME, CHANNEL_SERVER_VERSION;
16
16
  var init_constants = __esm({
17
17
  "src/shared/constants.ts"() {
18
18
  "use strict";
@@ -23,6 +23,8 @@ var init_constants = __esm({
23
23
  PID_FILE = path.join(CONFIG_DIR, "hub.pid");
24
24
  LOG_FILE = path.join(CONFIG_DIR, "hub.log");
25
25
  UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
26
+ CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
27
+ CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
26
28
  WS_PATH_CHANNEL = "/ws/channel";
27
29
  WS_PATH_DASHBOARD = "/ws/dashboard";
28
30
  CHANNEL_SERVER_NAME = "claude-alarm";
@@ -67,6 +69,12 @@ function saveConfig(config2) {
67
69
  ensureConfigDir();
68
70
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config2, null, 2), { encoding: "utf-8", mode: 384 });
69
71
  }
72
+ function setCodexEnabled(enabled) {
73
+ const config2 = loadConfig();
74
+ config2.codex = { ...config2.codex, enabled };
75
+ saveConfig(config2);
76
+ return config2;
77
+ }
70
78
  function setupMcpConfig(targetDir) {
71
79
  const dir = targetDir ?? process.cwd();
72
80
  const mcpPath = path2.join(dir, ".mcp.json");
@@ -211,9 +219,9 @@ var init_session_manager = __esm({
211
219
  SessionManager = class {
212
220
  sessions = /* @__PURE__ */ new Map();
213
221
  register(session) {
214
- const baseName = session.cwd?.replace(/^.*[/\\]/, "") || session.name;
222
+ const baseName = this.baseName(session);
215
223
  const existing = Array.from(this.sessions.values()).filter(
216
- (s) => s.id !== session.id && (s.cwd?.replace(/^.*[/\\]/, "") || s.name) === baseName
224
+ (s) => s.id !== session.id && this.baseName(s) === baseName
217
225
  );
218
226
  if (existing.length > 0) {
219
227
  const usedNums = existing.map((s) => {
@@ -267,6 +275,9 @@ var init_session_manager = __esm({
267
275
  count() {
268
276
  return this.sessions.size;
269
277
  }
278
+ baseName(session) {
279
+ return session.title || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
280
+ }
270
281
  };
271
282
  }
272
283
  });
@@ -294,7 +305,7 @@ var init_notifier = __esm({
294
305
  async notify(title, message, level = "info") {
295
306
  await this.notifyWithSession(void 0, void 0, title, message, level);
296
307
  }
297
- async notifyWithSession(sessionId2, sessionLabel, title, message, level = "info") {
308
+ async notifyWithSession(sessionId2, sessionLabel2, title, message, level = "info") {
298
309
  const promises = [];
299
310
  if (this.desktopEnabled) {
300
311
  promises.push(this.sendDesktop(title, message, level));
@@ -302,8 +313,8 @@ var init_notifier = __esm({
302
313
  for (const webhook of this.webhooks) {
303
314
  promises.push(this.sendWebhook(webhook, title, message, level));
304
315
  }
305
- if (this.telegramBot && sessionId2 && sessionLabel) {
306
- promises.push(this.telegramBot.sendNotification(sessionId2, sessionLabel, title, message));
316
+ if (this.telegramBot && sessionId2 && sessionLabel2) {
317
+ promises.push(this.telegramBot.sendNotification(sessionId2, sessionLabel2, title, message));
307
318
  }
308
319
  await Promise.allSettled(promises);
309
320
  }
@@ -387,6 +398,17 @@ var init_notifier = __esm({
387
398
  }
388
399
  });
389
400
 
401
+ // src/shared/session-label.ts
402
+ function sessionLabel(session) {
403
+ const base = session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
404
+ return session.agentKind === "codex" ? `Codex \xB7 ${base}` : base;
405
+ }
406
+ var init_session_label = __esm({
407
+ "src/shared/session-label.ts"() {
408
+ "use strict";
409
+ }
410
+ });
411
+
390
412
  // src/hub/telegram.ts
391
413
  import fs3 from "fs";
392
414
  import path3 from "path";
@@ -397,6 +419,7 @@ var init_telegram = __esm({
397
419
  "use strict";
398
420
  init_logger();
399
421
  init_constants();
422
+ init_session_label();
400
423
  TELEGRAM_API = "https://api.telegram.org/bot";
401
424
  TelegramBot = class _TelegramBot {
402
425
  config;
@@ -535,15 +558,14 @@ ${this.mdToHtml(message)}`;
535
558
  const pending = this.pendingMessages.get(msg.chat.id);
536
559
  if (pending) {
537
560
  this.pendingMessages.delete(msg.chat.id);
538
- const sessions2 = this.getSessions?.() ?? [];
539
- const idx = parseInt(selectMatch[1], 10) - 1;
540
- if (idx >= 0 && idx < sessions2.length) {
561
+ const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
562
+ if (session) {
541
563
  if (pending.photoFileId) {
542
- await this.deliverPhotoToSessionByFileId(sessions2[idx].id, pending.photoFileId, pending.caption);
564
+ await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
543
565
  } else if (pending.text) {
544
- this.deliverToSession(sessions2[idx].id, pending.text);
566
+ this.deliverToSession(session.id, pending.text);
545
567
  }
546
- this.sendMessage(`Sent to [${this.getLabel(sessions2[idx])}]`);
568
+ this.sendMessage(`Sent to [${this.getLabel(session)}]`);
547
569
  } else {
548
570
  this.sendMessage("Invalid session number.");
549
571
  }
@@ -564,11 +586,12 @@ ${this.mdToHtml(message)}`;
564
586
  }
565
587
  return;
566
588
  }
589
+ const sessionIds = sessions.map((s) => s.id);
567
590
  if (hasPhoto) {
568
591
  const largest = msg.photo[msg.photo.length - 1];
569
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text });
592
+ this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
570
593
  } else {
571
- this.pendingMessages.set(msg.chat.id, { text });
594
+ this.pendingMessages.set(msg.chat.id, { text, sessionIds });
572
595
  }
573
596
  const buttons = sessions.map((s, i) => ({
574
597
  text: this.getLabel(s),
@@ -625,11 +648,16 @@ ${this.mdToHtml(message)}`;
625
648
  logger.warn(`Telegram photo download failed: ${err.message}`);
626
649
  }
627
650
  }
651
+ // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
652
+ pendingSession(pending, idx) {
653
+ const id = pending.sessionIds[idx];
654
+ return id ? this.getSessions?.().find((s) => s.id === id) : void 0;
655
+ }
628
656
  getLabel(session) {
629
- return session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
657
+ return sessionLabel(session);
630
658
  }
631
659
  /** Send a permission request with inline buttons */
632
- async sendPermissionRequest(sessionId2, sessionLabel, requestId, toolName, description, inputPreview) {
660
+ async sendPermissionRequest(sessionId2, sessionLabel2, requestId, toolName, description, inputPreview) {
633
661
  const isNotify = toolName.endsWith("__notify") || toolName === "notify";
634
662
  let preview = inputPreview;
635
663
  let kind = "code";
@@ -682,7 +710,7 @@ ${this.mdToHtml(message)}`;
682
710
  const mcpMatch = toolName.match(/__([^_]+)$/);
683
711
  if (mcpMatch) displayTool = mcpMatch[1].charAt(0).toUpperCase() + mcpMatch[1].slice(1);
684
712
  }
685
- const text = `\u26A0\uFE0F <b>Permission Request</b> \u2014 ${this.escHtml(sessionLabel)}
713
+ const text = `\u26A0\uFE0F <b>Permission Request</b> \u2014 ${this.escHtml(sessionLabel2)}
686
714
 
687
715
  \u{1F527} <b>${this.escHtml(displayTool)}</b>
688
716
  ${previewHtml}${truncNote}`;
@@ -696,6 +724,7 @@ ${previewHtml}${truncNote}`;
696
724
  }
697
725
  async handleCallbackQuery(query) {
698
726
  if (!query.data) return;
727
+ if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
699
728
  if (query.data.startsWith("sess:")) {
700
729
  await this.handleSessionSelectCallback(query);
701
730
  return;
@@ -721,22 +750,18 @@ ${label}`);
721
750
  const parts = query.data.split(":");
722
751
  if (parts.length < 3) return;
723
752
  const [, idxStr, chatIdStr] = parts;
724
- const idx = parseInt(idxStr, 10);
725
753
  const chatId = parseInt(chatIdStr, 10);
726
- const sessions = this.getSessions?.() ?? [];
727
- if (idx < 0 || idx >= sessions.length) {
754
+ const pending = this.pendingMessages.get(chatId);
755
+ const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
756
+ if (!pending || !session) {
728
757
  await this.answerCallbackQuery(query.id, "Session not found");
729
758
  return;
730
759
  }
731
- const session = sessions[idx];
732
- const pending = this.pendingMessages.get(chatId);
733
760
  this.pendingMessages.delete(chatId);
734
- if (pending) {
735
- if (pending.photoFileId) {
736
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
737
- } else if (pending.text) {
738
- this.deliverToSession(session.id, pending.text);
739
- }
761
+ if (pending.photoFileId) {
762
+ await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
763
+ } else if (pending.text) {
764
+ this.deliverToSession(session.id, pending.text);
740
765
  }
741
766
  await this.answerCallbackQuery(query.id, `Sent to ${this.getLabel(session)}`);
742
767
  if (query.message) {
@@ -798,8 +823,8 @@ ${label}`);
798
823
  css: "css",
799
824
  scss: "scss"
800
825
  };
801
- langOf(path8) {
802
- const m = /\.([a-z0-9]+)$/i.exec(path8 || "");
826
+ langOf(path9) {
827
+ const m = /\.([a-z0-9]+)$/i.exec(path9 || "");
803
828
  return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
804
829
  }
805
830
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
@@ -844,18 +869,152 @@ ${bodyLines.join("\n")}`;
844
869
  }
845
870
  });
846
871
 
872
+ // src/hub/codex-supervisor.ts
873
+ import { spawn } from "child_process";
874
+ import fs4 from "fs";
875
+ import path4 from "path";
876
+ function resolveAdapterScript(baseDir) {
877
+ return [path4.join(baseDir, "..", "codex", "main.js"), path4.join(baseDir, "codex", "main.js")].find((p) => fs4.existsSync(p));
878
+ }
879
+ var HEALTHY_RUN_MS, CodexSupervisor;
880
+ var init_codex_supervisor = __esm({
881
+ "src/hub/codex-supervisor.ts"() {
882
+ "use strict";
883
+ init_logger();
884
+ HEALTHY_RUN_MS = 6e4;
885
+ CodexSupervisor = class {
886
+ constructor(script, spawnFn = spawn, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
887
+ this.script = script;
888
+ this.spawnFn = spawnFn;
889
+ this.minDelayMs = minDelayMs;
890
+ this.maxDelayMs = maxDelayMs;
891
+ this.stopGraceMs = stopGraceMs;
892
+ this.delay = minDelayMs;
893
+ }
894
+ child;
895
+ timer;
896
+ stopped = true;
897
+ delay;
898
+ start() {
899
+ this.stopped = false;
900
+ this.launch();
901
+ }
902
+ stop() {
903
+ this.stopped = true;
904
+ if (this.timer) clearTimeout(this.timer);
905
+ this.timer = void 0;
906
+ const child = this.child;
907
+ this.child = void 0;
908
+ if (!child) return;
909
+ child.stdin?.end();
910
+ const force = setTimeout(() => child.kill(), this.stopGraceMs);
911
+ force.unref();
912
+ child.once("exit", () => clearTimeout(force));
913
+ }
914
+ launch() {
915
+ const startedAt = Date.now();
916
+ const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
917
+ detached: true,
918
+ stdio: ["pipe", "pipe", "pipe"],
919
+ windowsHide: true
920
+ });
921
+ this.child = child;
922
+ child.stdout?.on("data", (d) => process.stdout.write(d));
923
+ child.stderr?.on("data", (d) => process.stderr.write(d));
924
+ child.stdin?.on("error", () => {
925
+ });
926
+ child.on("error", (err) => logger.warn(`Codex adapter failed to start: ${err.message}`));
927
+ child.on("exit", (code, signal) => {
928
+ if (this.child === child) this.child = void 0;
929
+ if (this.stopped || code === 0) return;
930
+ if (Date.now() - startedAt > HEALTHY_RUN_MS) this.delay = this.minDelayMs;
931
+ logger.warn(`Codex adapter exited (${signal ?? code}); restarting in ${this.delay}ms`);
932
+ this.timer = setTimeout(() => {
933
+ this.timer = void 0;
934
+ if (!this.stopped) this.launch();
935
+ }, this.delay);
936
+ this.delay = Math.min(this.delay * 2, this.maxDelayMs);
937
+ });
938
+ }
939
+ };
940
+ }
941
+ });
942
+
943
+ // src/hub/auth.ts
944
+ import crypto from "crypto";
945
+ function safeEqual(a, b) {
946
+ if (typeof a !== "string") return false;
947
+ const x = Buffer.from(a);
948
+ const y = Buffer.from(b);
949
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
950
+ }
951
+ function dashboardCookieValue(token) {
952
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
953
+ }
954
+ function parseCookies(header) {
955
+ const out = {};
956
+ for (const part of (header ?? "").split(";")) {
957
+ const i = part.indexOf("=");
958
+ if (i <= 0) continue;
959
+ const raw = part.slice(i + 1).trim();
960
+ let value = raw;
961
+ try {
962
+ value = decodeURIComponent(raw);
963
+ } catch {
964
+ }
965
+ out[part.slice(0, i).trim()] = value;
966
+ }
967
+ return out;
968
+ }
969
+ function sessionCookieHeader(token, secure) {
970
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
971
+ }
972
+ function isAuthorized(req, token, allowQueryToken) {
973
+ const auth = req.headers.authorization;
974
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
975
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
976
+ if (allowQueryToken) {
977
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
978
+ if (safeEqual(q, token)) return true;
979
+ }
980
+ return false;
981
+ }
982
+ function isCrossOrigin(req) {
983
+ const origin = req.headers.origin;
984
+ if (!origin) return false;
985
+ try {
986
+ return new URL(origin).host !== req.headers.host;
987
+ } catch {
988
+ return true;
989
+ }
990
+ }
991
+ function isJsonRequest(req) {
992
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
993
+ }
994
+ function isSecureRequest(req) {
995
+ return req.headers["x-forwarded-proto"] === "https";
996
+ }
997
+ var SESSION_COOKIE, COOKIE_MAX_AGE;
998
+ var init_auth = __esm({
999
+ "src/hub/auth.ts"() {
1000
+ "use strict";
1001
+ SESSION_COOKIE = "ca_session";
1002
+ COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
1003
+ }
1004
+ });
1005
+
847
1006
  // src/hub/server.ts
848
1007
  var server_exports = {};
849
1008
  __export(server_exports, {
850
1009
  HubServer: () => HubServer
851
1010
  });
852
1011
  import http from "http";
853
- import fs4 from "fs";
854
- import path4 from "path";
1012
+ import fs5 from "fs";
1013
+ import path5 from "path";
855
1014
  import { fileURLToPath } from "url";
856
1015
  import { WebSocketServer, WebSocket } from "ws";
857
1016
  import { randomUUID as randomUUID3 } from "crypto";
858
- var __dirname, HubServer;
1017
+ var __dirname, MAX_WS_PAYLOAD, HubServer;
859
1018
  var init_server = __esm({
860
1019
  "src/hub/server.ts"() {
861
1020
  "use strict";
@@ -864,9 +1023,13 @@ var init_server = __esm({
864
1023
  init_session_manager();
865
1024
  init_notifier();
866
1025
  init_telegram();
1026
+ init_codex_supervisor();
867
1027
  init_config();
1028
+ init_session_label();
868
1029
  init_crash_guard();
869
- __dirname = path4.dirname(fileURLToPath(import.meta.url));
1030
+ init_auth();
1031
+ __dirname = path5.dirname(fileURLToPath(import.meta.url));
1032
+ MAX_WS_PAYLOAD = 16 * 1024 * 1024;
870
1033
  HubServer = class {
871
1034
  httpServer;
872
1035
  wssChannel;
@@ -884,13 +1047,17 @@ var init_server = __esm({
884
1047
  heartbeatInterval;
885
1048
  channelAlive = /* @__PURE__ */ new Map();
886
1049
  // sessionId -> alive flag
1050
+ socketOwners = /* @__PURE__ */ new WeakMap();
887
1051
  host;
888
1052
  port;
889
1053
  token;
1054
+ codexEnabled;
1055
+ codexSupervisor;
890
1056
  constructor(config2) {
891
1057
  this.host = config2?.hub?.host ?? DEFAULT_HUB_HOST;
892
1058
  this.port = config2?.hub?.port ?? DEFAULT_HUB_PORT;
893
1059
  this.token = config2?.hub?.token;
1060
+ this.codexEnabled = config2?.codex?.enabled === true;
894
1061
  if (config2?.notifications) {
895
1062
  this.notifier.configure({
896
1063
  desktop: config2.notifications.desktop
@@ -906,22 +1073,23 @@ var init_server = __esm({
906
1073
  this.initTelegram(fullConfig.telegram);
907
1074
  }
908
1075
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
909
- this.wssChannel = new WebSocketServer({ noServer: true });
1076
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
910
1077
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
911
1078
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
912
- this.wssDashboard = new WebSocketServer({ noServer: true });
1079
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
913
1080
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
914
1081
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
915
1082
  this.httpServer.on("upgrade", (req, socket, head) => {
916
1083
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
917
- const url = new URL(req.url, `http://${req.headers.host}`);
1084
+ const url = new URL(req.url ?? "/", "http://hub");
918
1085
  const pathname = url.pathname;
919
- if (this.token && !this.isLocalRequest(req)) {
920
- const wsToken = url.searchParams.get("token");
921
- if (wsToken !== this.token) {
922
- socket.destroy();
923
- return;
924
- }
1086
+ if (isCrossOrigin(req)) {
1087
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
1088
+ return;
1089
+ }
1090
+ if (!this.authorized(req, true)) {
1091
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
1092
+ return;
925
1093
  }
926
1094
  if (pathname === WS_PATH_CHANNEL) {
927
1095
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -947,12 +1115,15 @@ var init_server = __esm({
947
1115
  this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
948
1116
  const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
949
1117
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
1118
+ this.startCodexAdapter();
950
1119
  resolve();
951
1120
  });
952
1121
  });
953
1122
  }
954
1123
  stop() {
955
1124
  return new Promise((resolve) => {
1125
+ this.codexSupervisor?.stop();
1126
+ this.codexSupervisor = void 0;
956
1127
  if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
957
1128
  if (this.telegramBot) this.telegramBot.stopPolling();
958
1129
  for (const ws of this.channelSockets.values()) ws.terminate();
@@ -973,31 +1144,28 @@ var init_server = __esm({
973
1144
  }
974
1145
  // --- HTTP Handler ---
975
1146
  handleHttp(req, res) {
976
- const url = new URL(req.url, `http://${req.headers.host}`);
977
- const origin = req.headers.origin;
978
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
979
- res.setHeader("Access-Control-Allow-Origin", origin);
980
- }
981
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
982
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
983
- if (req.method === "OPTIONS") {
984
- res.writeHead(204);
985
- res.end();
1147
+ const url = new URL(req.url ?? "/", "http://hub");
1148
+ if (isCrossOrigin(req)) {
1149
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
986
1150
  return;
987
1151
  }
988
- if (url.pathname !== "/" && this.token) {
989
- if (!this.isLocalRequest(req)) {
990
- const authHeader = req.headers["authorization"];
991
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
992
- if (bearerToken !== this.token) {
993
- this.jsonResponse(res, 401, { error: "Unauthorized" });
994
- return;
995
- }
996
- }
1152
+ if (req.method === "POST" && !isJsonRequest(req)) {
1153
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
1154
+ return;
997
1155
  }
998
1156
  if (url.pathname === "/" && req.method === "GET") {
999
- this.serveDashboard(res);
1000
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
1157
+ this.serveDashboard(req, res, url);
1158
+ return;
1159
+ }
1160
+ if (url.pathname === "/api/login" && req.method === "POST") {
1161
+ this.handleLogin(req, res);
1162
+ return;
1163
+ }
1164
+ if (!this.authorized(req, false)) {
1165
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1166
+ return;
1167
+ }
1168
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
1001
1169
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
1002
1170
  } else if (url.pathname === "/api/status" && req.method === "GET") {
1003
1171
  this.jsonResponse(res, 200, {
@@ -1048,25 +1216,31 @@ var init_server = __esm({
1048
1216
  this.jsonResponse(res, 404, { error: "Not found" });
1049
1217
  }
1050
1218
  }
1051
- serveDashboard(res) {
1219
+ serveDashboard(req, res, url) {
1220
+ const linkToken = url.searchParams.get("token");
1221
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1222
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1223
+ res.end();
1224
+ return;
1225
+ }
1052
1226
  const candidates = [
1053
- path4.join(__dirname, "..", "dashboard", "index.html"),
1227
+ path5.join(__dirname, "..", "dashboard", "index.html"),
1054
1228
  // from dist/hub/
1055
- path4.join(__dirname, "dashboard", "index.html"),
1229
+ path5.join(__dirname, "dashboard", "index.html"),
1056
1230
  // from dist/ (bundled index.js)
1057
- path4.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1231
+ path5.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1058
1232
  // from dist/hub/ -> src/
1059
- path4.join(__dirname, "..", "src", "dashboard", "index.html"),
1233
+ path5.join(__dirname, "..", "src", "dashboard", "index.html"),
1060
1234
  // from dist/ -> src/
1061
- path4.join(process.cwd(), "dist", "dashboard", "index.html"),
1235
+ path5.join(process.cwd(), "dist", "dashboard", "index.html"),
1062
1236
  // from cwd
1063
- path4.join(process.cwd(), "src", "dashboard", "index.html")
1237
+ path5.join(process.cwd(), "src", "dashboard", "index.html")
1064
1238
  // from cwd/src
1065
1239
  ];
1066
1240
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
1067
1241
  for (const candidate of candidates) {
1068
- if (fs4.existsSync(candidate)) {
1069
- const html = fs4.readFileSync(candidate, "utf-8");
1242
+ if (fs5.existsSync(candidate)) {
1243
+ const html = fs5.readFileSync(candidate, "utf-8");
1070
1244
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1071
1245
  res.end(html);
1072
1246
  return;
@@ -1075,6 +1249,20 @@ var init_server = __esm({
1075
1249
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1076
1250
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
1077
1251
  }
1252
+ async handleLogin(req, res) {
1253
+ const body = await this.readBody(req);
1254
+ if (!this.token) {
1255
+ res.writeHead(204);
1256
+ res.end();
1257
+ return;
1258
+ }
1259
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1260
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1261
+ return;
1262
+ }
1263
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1264
+ res.end();
1265
+ }
1078
1266
  async handleApiSend(req, res) {
1079
1267
  const body = await this.readBody(req);
1080
1268
  if (!body) {
@@ -1091,7 +1279,7 @@ var init_server = __esm({
1091
1279
  this.jsonResponse(res, 404, { error: "Session not connected" });
1092
1280
  return;
1093
1281
  }
1094
- const msg = { type: "message_to_session", sessionId: sessionId2, content };
1282
+ const msg = { type: "message_to_session", sessionId: sessionId2, content, source: "api" };
1095
1283
  ws.send(JSON.stringify(msg));
1096
1284
  this.jsonResponse(res, 200, { ok: true });
1097
1285
  }
@@ -1151,6 +1339,22 @@ var init_server = __esm({
1151
1339
  });
1152
1340
  }
1153
1341
  handleChannelMessage(ws, isLocal, msg) {
1342
+ if (msg.type === "register") {
1343
+ const id = msg.session.id;
1344
+ const owned = this.socketOwners.get(ws);
1345
+ if (owned && owned !== id) {
1346
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1347
+ return;
1348
+ }
1349
+ const holder = this.channelSockets.get(id);
1350
+ if (holder && holder !== ws) {
1351
+ this.socketOwners.delete(holder);
1352
+ holder.terminate();
1353
+ }
1354
+ this.socketOwners.set(ws, id);
1355
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1356
+ return;
1357
+ }
1154
1358
  switch (msg.type) {
1155
1359
  case "register": {
1156
1360
  const session = msg.session;
@@ -1174,7 +1378,6 @@ var init_server = __esm({
1174
1378
  break;
1175
1379
  }
1176
1380
  case "peer_name": {
1177
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1178
1381
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1179
1382
  if (updated) {
1180
1383
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1250,7 +1453,7 @@ var init_server = __esm({
1250
1453
  if (msg.type === "message_to_session") {
1251
1454
  const channelWs = this.channelSockets.get(msg.sessionId);
1252
1455
  if (channelWs?.readyState === WebSocket.OPEN) {
1253
- channelWs.send(JSON.stringify(msg));
1456
+ channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1254
1457
  }
1255
1458
  } else if (msg.type === "image_upload") {
1256
1459
  this.handleImageUpload(msg);
@@ -1295,11 +1498,11 @@ var init_server = __esm({
1295
1498
  logger.warn("Image upload rejected: exceeds 10MB");
1296
1499
  return;
1297
1500
  }
1298
- fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
1501
+ fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
1299
1502
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1300
1503
  const filename = `${randomUUID3()}.${ext}`;
1301
- const filePath = path4.join(UPLOADS_DIR, filename);
1302
- fs4.writeFileSync(filePath, buffer);
1504
+ const filePath = path5.join(UPLOADS_DIR, filename);
1505
+ fs5.writeFileSync(filePath, buffer);
1303
1506
  const forwardMsg = {
1304
1507
  type: "image_to_session",
1305
1508
  sessionId: sessionId2,
@@ -1312,7 +1515,7 @@ var init_server = __esm({
1312
1515
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1313
1516
  setTimeout(() => {
1314
1517
  try {
1315
- fs4.unlinkSync(filePath);
1518
+ fs5.unlinkSync(filePath);
1316
1519
  } catch {
1317
1520
  }
1318
1521
  }, 5 * 60 * 1e3);
@@ -1340,7 +1543,7 @@ var init_server = __esm({
1340
1543
  this.telegramBot.onMessageToSession = (sessionId2, content) => {
1341
1544
  const channelWs = this.channelSockets.get(sessionId2);
1342
1545
  if (channelWs?.readyState === WebSocket.OPEN) {
1343
- const msg = { type: "message_to_session", sessionId: sessionId2, content };
1546
+ const msg = { type: "message_to_session", sessionId: sessionId2, content, source: "telegram" };
1344
1547
  channelWs.send(JSON.stringify(msg));
1345
1548
  logger.info(`Telegram message forwarded to session: ${sessionId2}`);
1346
1549
  }
@@ -1467,6 +1670,17 @@ var init_server = __esm({
1467
1670
  this.jsonResponse(res, 500, { error: err.message });
1468
1671
  }
1469
1672
  }
1673
+ startCodexAdapter() {
1674
+ if (!this.codexEnabled) return;
1675
+ const script = resolveAdapterScript(__dirname);
1676
+ if (!script) {
1677
+ logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1678
+ return;
1679
+ }
1680
+ this.codexSupervisor = new CodexSupervisor(script);
1681
+ this.codexSupervisor.start();
1682
+ logger.info("Codex adapter started");
1683
+ }
1470
1684
  startHeartbeat() {
1471
1685
  this.heartbeatInterval = setInterval(() => {
1472
1686
  for (const [sessionId2, ws] of this.channelSockets) {
@@ -1482,11 +1696,11 @@ var init_server = __esm({
1482
1696
  }
1483
1697
  cleanupUploads() {
1484
1698
  try {
1485
- if (!fs4.existsSync(UPLOADS_DIR)) return;
1486
- const files = fs4.readdirSync(UPLOADS_DIR);
1699
+ if (!fs5.existsSync(UPLOADS_DIR)) return;
1700
+ const files = fs5.readdirSync(UPLOADS_DIR);
1487
1701
  for (const file of files) {
1488
1702
  try {
1489
- fs4.unlinkSync(path4.join(UPLOADS_DIR, file));
1703
+ fs5.unlinkSync(path5.join(UPLOADS_DIR, file));
1490
1704
  } catch {
1491
1705
  }
1492
1706
  }
@@ -1495,13 +1709,24 @@ var init_server = __esm({
1495
1709
  }
1496
1710
  }
1497
1711
  getSessionLabel(session) {
1498
- if (!session) return "unknown";
1499
- return session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
1712
+ return session ? sessionLabel(session) : "unknown";
1500
1713
  }
1501
1714
  jsonResponse(res, status, body) {
1502
1715
  res.writeHead(status, { "Content-Type": "application/json" });
1503
1716
  res.end(JSON.stringify(body));
1504
1717
  }
1718
+ authorized(req, allowQueryToken) {
1719
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1720
+ }
1721
+ rejectUpgrade(socket, status, text, pathname, req) {
1722
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1723
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1724
+ Connection: close\r
1725
+ Content-Length: 0\r
1726
+ \r
1727
+ `);
1728
+ socket.destroy();
1729
+ }
1505
1730
  isLocalRequest(req) {
1506
1731
  const addr = req.socket.remoteAddress;
1507
1732
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
@@ -1556,48 +1781,41 @@ var init_hub_client = __esm({
1556
1781
  init_logger();
1557
1782
  init_constants();
1558
1783
  HubClient = class {
1559
- constructor(sessionId2, sessionName2, hubHost2 = DEFAULT_HUB_HOST, hubPort2 = DEFAULT_HUB_PORT, token, getPeerName = () => void 0) {
1784
+ constructor(sessionId2, sessionName2, hubHost2 = DEFAULT_HUB_HOST, hubPort2 = DEFAULT_HUB_PORT, token, getPeerName = () => void 0, getRegistration = () => ({})) {
1560
1785
  this.sessionId = sessionId2;
1561
1786
  this.sessionName = sessionName2;
1562
1787
  this.hubHost = hubHost2;
1563
1788
  this.hubPort = hubPort2;
1564
1789
  this.token = token;
1565
1790
  this.getPeerName = getPeerName;
1791
+ this.getRegistration = getRegistration;
1566
1792
  }
1567
1793
  ws = null;
1568
1794
  reconnectTimer = null;
1569
1795
  messageHandlers = [];
1570
1796
  queue = [];
1571
1797
  connected = false;
1798
+ closed = false;
1572
1799
  connect() {
1800
+ this.closed = false;
1573
1801
  const tokenQuery = this.token ? `?token=${encodeURIComponent(this.token)}` : "";
1574
1802
  const url = `ws://${this.hubHost}:${this.hubPort}${WS_PATH_CHANNEL}${tokenQuery}`;
1575
1803
  logger.debug(`Connecting to hub at ${url}`);
1576
1804
  try {
1577
- this.ws = new WebSocket2(url);
1578
- this.ws.on("open", () => {
1805
+ const ws = new WebSocket2(url);
1806
+ this.ws = ws;
1807
+ ws.on("open", () => {
1808
+ if (this.ws !== ws) return;
1579
1809
  logger.info("Connected to hub");
1580
1810
  this.connected = true;
1581
- const registration = {
1582
- type: "register",
1583
- session: {
1584
- id: this.sessionId,
1585
- name: this.sessionName,
1586
- status: "idle",
1587
- connectedAt: Date.now(),
1588
- lastActivity: Date.now(),
1589
- cwd: process.cwd(),
1590
- channelEnabled: true,
1591
- peerName: this.getPeerName()
1592
- }
1593
- };
1594
- this.ws.send(JSON.stringify(registration));
1811
+ this.ws.send(JSON.stringify(this.registration()));
1595
1812
  for (const msg of this.queue) {
1596
1813
  this.ws.send(JSON.stringify(msg));
1597
1814
  }
1598
1815
  this.queue = [];
1599
1816
  });
1600
- this.ws.on("message", (data) => {
1817
+ ws.on("message", (data) => {
1818
+ if (this.ws !== ws) return;
1601
1819
  try {
1602
1820
  const msg = JSON.parse(data.toString());
1603
1821
  for (const handler of this.messageHandlers) {
@@ -1607,12 +1825,14 @@ var init_hub_client = __esm({
1607
1825
  logger.warn("Failed to parse hub message:", err);
1608
1826
  }
1609
1827
  });
1610
- this.ws.on("close", () => {
1828
+ ws.on("close", () => {
1829
+ if (this.ws !== ws) return;
1611
1830
  logger.info("Disconnected from hub");
1612
1831
  this.connected = false;
1613
- this.scheduleReconnect();
1832
+ if (!this.closed) this.scheduleReconnect();
1614
1833
  });
1615
- this.ws.on("error", (err) => {
1834
+ ws.on("error", (err) => {
1835
+ if (this.ws !== ws) return;
1616
1836
  logger.debug(`Hub connection error: ${err.message}`);
1617
1837
  this.connected = false;
1618
1838
  });
@@ -1635,6 +1855,7 @@ var init_hub_client = __esm({
1635
1855
  this.messageHandlers.push(handler);
1636
1856
  }
1637
1857
  disconnect() {
1858
+ this.closed = true;
1638
1859
  if (this.reconnectTimer) {
1639
1860
  clearTimeout(this.reconnectTimer);
1640
1861
  this.reconnectTimer = null;
@@ -1645,8 +1866,29 @@ var init_hub_client = __esm({
1645
1866
  }
1646
1867
  this.connected = false;
1647
1868
  }
1869
+ reregister() {
1870
+ if (this.connected && this.ws?.readyState === WebSocket2.OPEN) {
1871
+ this.ws.send(JSON.stringify(this.registration()));
1872
+ }
1873
+ }
1874
+ registration() {
1875
+ return {
1876
+ type: "register",
1877
+ session: {
1878
+ id: this.sessionId,
1879
+ name: this.sessionName,
1880
+ status: "idle",
1881
+ connectedAt: Date.now(),
1882
+ lastActivity: Date.now(),
1883
+ cwd: process.cwd(),
1884
+ channelEnabled: true,
1885
+ peerName: this.getPeerName(),
1886
+ ...this.getRegistration()
1887
+ }
1888
+ };
1889
+ }
1648
1890
  scheduleReconnect() {
1649
- if (this.reconnectTimer) return;
1891
+ if (this.reconnectTimer || this.closed) return;
1650
1892
  this.reconnectTimer = setTimeout(() => {
1651
1893
  this.reconnectTimer = null;
1652
1894
  this.connect();
@@ -1657,9 +1899,9 @@ var init_hub_client = __esm({
1657
1899
  });
1658
1900
 
1659
1901
  // src/channel/peer-name.ts
1660
- import fs5 from "fs";
1902
+ import fs6 from "fs";
1661
1903
  import os2 from "os";
1662
- import path5 from "path";
1904
+ import path6 from "path";
1663
1905
  function findPeerName(records, env) {
1664
1906
  const valid = records.filter((r) => typeof r === "object" && r !== null);
1665
1907
  const nameOf = (match) => {
@@ -1679,18 +1921,18 @@ function readPeerName(env = process.env) {
1679
1921
  sessionId: env.CLAUDE_CODE_SESSION_ID
1680
1922
  };
1681
1923
  if (!lookup.messagingSocket && !lookup.sessionId) return void 0;
1682
- const configDir = env.CLAUDE_CONFIG_DIR || path5.join(os2.homedir(), ".claude");
1683
- const dir = path5.join(configDir, "sessions");
1924
+ const configDir = env.CLAUDE_CONFIG_DIR || path6.join(os2.homedir(), ".claude");
1925
+ const dir = path6.join(configDir, "sessions");
1684
1926
  let files;
1685
1927
  try {
1686
- files = fs5.readdirSync(dir).filter((f) => f.endsWith(".json"));
1928
+ files = fs6.readdirSync(dir).filter((f) => f.endsWith(".json"));
1687
1929
  } catch {
1688
1930
  return void 0;
1689
1931
  }
1690
1932
  const records = [];
1691
1933
  for (const f of files) {
1692
1934
  try {
1693
- records.push(JSON.parse(fs5.readFileSync(path5.join(dir, f), "utf8")));
1935
+ records.push(JSON.parse(fs6.readFileSync(path6.join(dir, f), "utf8")));
1694
1936
  } catch {
1695
1937
  }
1696
1938
  }
@@ -1713,7 +1955,7 @@ import {
1713
1955
  } from "@modelcontextprotocol/sdk/types.js";
1714
1956
  import { z } from "zod";
1715
1957
  import { randomUUID as randomUUID4 } from "crypto";
1716
- import path6 from "path";
1958
+ import path7 from "path";
1717
1959
  async function main() {
1718
1960
  logger.info(`Starting MCP channel server (session: ${sessionId})`);
1719
1961
  hubClient.connect();
@@ -1782,7 +2024,7 @@ var init_server2 = __esm({
1782
2024
  init_hub_client();
1783
2025
  init_peer_name2();
1784
2026
  sessionId = randomUUID4();
1785
- sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path6.basename(process.cwd());
2027
+ sessionName = process.env.CLAUDE_ALARM_SESSION_NAME ?? path7.basename(process.cwd());
1786
2028
  peerName = readPeerName();
1787
2029
  server = new Server(
1788
2030
  {
@@ -1961,12 +2203,18 @@ init_config();
1961
2203
  init_constants();
1962
2204
  init_logger();
1963
2205
  init_crash_guard();
1964
- import { spawn } from "child_process";
1965
- import fs6 from "fs";
1966
- import path7 from "path";
2206
+ import { spawn as spawn2 } from "child_process";
2207
+ import fs7 from "fs";
2208
+ import path8 from "path";
1967
2209
  import readline from "readline";
1968
2210
  import { fileURLToPath as fileURLToPath2 } from "url";
1969
- var __dirname2 = path7.dirname(fileURLToPath2(import.meta.url));
2211
+ var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
2212
+ function authHeaders(token) {
2213
+ return token ? { Authorization: `Bearer ${token}` } : {};
2214
+ }
2215
+ function loginLink(displayHost, port, token) {
2216
+ return `http://${displayHost}:${port}/${token ? `?token=${encodeURIComponent(token)}` : ""}`;
2217
+ }
1970
2218
  function printUsage() {
1971
2219
  console.log(`
1972
2220
  claude-alarm - Monitor Claude Code sessions with notifications
@@ -1979,6 +2227,11 @@ Usage:
1979
2227
  claude-alarm setup [dir] Add claude-alarm to .mcp.json
1980
2228
  claude-alarm test Send a test notification
1981
2229
  claude-alarm token Show current auth token
2230
+ claude-alarm codex enable Start the Codex adapter together with the hub
2231
+ claude-alarm codex disable Stop starting the Codex adapter with the hub
2232
+ claude-alarm codex start Run the Codex adapter on its own (e.g. Codex on another PC)
2233
+ claude-alarm codex stop Stop a running Codex adapter
2234
+ claude-alarm codex status Show Codex adapter status
1982
2235
  claude-alarm help Show this help
1983
2236
 
1984
2237
  Quick start:
@@ -1987,8 +2240,8 @@ Quick start:
1987
2240
  }
1988
2241
  async function checkForUpdates() {
1989
2242
  try {
1990
- const pkgPath = path7.join(__dirname2, "..", "package.json");
1991
- const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
2243
+ const pkgPath = path8.join(__dirname2, "..", "package.json");
2244
+ const pkg = JSON.parse(fs7.readFileSync(pkgPath, "utf-8"));
1992
2245
  const currentVersion = pkg.version;
1993
2246
  const res = await fetch("https://registry.npmjs.org/@delt/claude-alarm/latest", {
1994
2247
  signal: AbortSignal.timeout(3e3)
@@ -2011,28 +2264,28 @@ async function hubStart(daemon) {
2011
2264
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
2012
2265
  const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
2013
2266
  checkForUpdates();
2014
- if (fs6.existsSync(PID_FILE)) {
2015
- const pid = parseInt(fs6.readFileSync(PID_FILE, "utf-8").trim(), 10);
2267
+ if (fs7.existsSync(PID_FILE)) {
2268
+ const pid = parseInt(fs7.readFileSync(PID_FILE, "utf-8").trim(), 10);
2016
2269
  if (isProcessRunning(pid)) {
2017
- console.log(`Hub is already running (PID: ${pid}) on http://${displayHost}:${port}`);
2270
+ console.log(`Hub is already running (PID: ${pid}). Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2018
2271
  return;
2019
2272
  }
2020
- fs6.unlinkSync(PID_FILE);
2273
+ fs7.unlinkSync(PID_FILE);
2021
2274
  }
2022
2275
  if (daemon) {
2023
2276
  ensureConfigDir();
2024
- const logFd = fs6.openSync(LOG_FILE, "a");
2025
- const hubScript = path7.join(__dirname2, "hub", "server.js");
2026
- const child = spawn(process.execPath, [hubScript], {
2277
+ const logFd = fs7.openSync(LOG_FILE, "a");
2278
+ const hubScript = path8.join(__dirname2, "hub", "server.js");
2279
+ const child = spawn2(process.execPath, [hubScript], {
2027
2280
  detached: true,
2028
2281
  stdio: ["ignore", logFd, logFd],
2029
2282
  env: { ...process.env }
2030
2283
  });
2031
2284
  if (child.pid) {
2032
- fs6.writeFileSync(PID_FILE, String(child.pid), "utf-8");
2285
+ fs7.writeFileSync(PID_FILE, String(child.pid), "utf-8");
2033
2286
  child.unref();
2034
2287
  console.log(`Hub started as daemon (PID: ${child.pid})`);
2035
- console.log(`Dashboard: http://${displayHost}:${port}`);
2288
+ console.log(`Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2036
2289
  console.log(`Token: ${config2.hub.token}`);
2037
2290
  console.log(`Logs: ${LOG_FILE}`);
2038
2291
  } else {
@@ -2041,6 +2294,7 @@ async function hubStart(daemon) {
2041
2294
  }
2042
2295
  } else {
2043
2296
  console.log(`Starting hub on http://${displayHost}:${port} (press Ctrl+C to stop)`);
2297
+ console.log(`Dashboard: ${loginLink(displayHost, port, config2.hub.token)}`);
2044
2298
  console.log(`Token: ${config2.hub.token}`);
2045
2299
  console.log(`Logs: ${LOG_FILE}`);
2046
2300
  installCrashGuard("hub foreground");
@@ -2049,11 +2303,11 @@ async function hubStart(daemon) {
2049
2303
  const hub = new HubServer2(config2);
2050
2304
  await hub.start();
2051
2305
  ensureConfigDir();
2052
- fs6.writeFileSync(PID_FILE, String(process.pid), "utf-8");
2306
+ fs7.writeFileSync(PID_FILE, String(process.pid), "utf-8");
2053
2307
  const shutdown = async () => {
2054
2308
  console.log("\nShutting down...");
2055
2309
  await hub.stop();
2056
- if (fs6.existsSync(PID_FILE)) fs6.unlinkSync(PID_FILE);
2310
+ if (fs7.existsSync(PID_FILE)) fs7.unlinkSync(PID_FILE);
2057
2311
  process.exit(0);
2058
2312
  };
2059
2313
  process.on("SIGINT", shutdown);
@@ -2061,18 +2315,18 @@ async function hubStart(daemon) {
2061
2315
  }
2062
2316
  }
2063
2317
  function hubStop() {
2064
- if (!fs6.existsSync(PID_FILE)) {
2318
+ if (!fs7.existsSync(PID_FILE)) {
2065
2319
  console.log("Hub is not running (no PID file found)");
2066
2320
  return;
2067
2321
  }
2068
- const pid = parseInt(fs6.readFileSync(PID_FILE, "utf-8").trim(), 10);
2322
+ const pid = parseInt(fs7.readFileSync(PID_FILE, "utf-8").trim(), 10);
2069
2323
  try {
2070
2324
  process.kill(pid, "SIGTERM");
2071
2325
  console.log(`Hub stopped (PID: ${pid})`);
2072
2326
  } catch {
2073
2327
  console.log("Hub process not found (may have already stopped)");
2074
2328
  }
2075
- fs6.unlinkSync(PID_FILE);
2329
+ fs7.unlinkSync(PID_FILE);
2076
2330
  }
2077
2331
  async function hubStatus() {
2078
2332
  const config2 = loadConfig();
@@ -2080,8 +2334,8 @@ async function hubStatus() {
2080
2334
  const port = config2.hub.port ?? DEFAULT_HUB_PORT;
2081
2335
  const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
2082
2336
  let pidInfo = "not running";
2083
- if (fs6.existsSync(PID_FILE)) {
2084
- const pid = parseInt(fs6.readFileSync(PID_FILE, "utf-8").trim(), 10);
2337
+ if (fs7.existsSync(PID_FILE)) {
2338
+ const pid = parseInt(fs7.readFileSync(PID_FILE, "utf-8").trim(), 10);
2085
2339
  if (isProcessRunning(pid)) {
2086
2340
  pidInfo = `running (PID: ${pid})`;
2087
2341
  } else {
@@ -2089,7 +2343,7 @@ async function hubStatus() {
2089
2343
  }
2090
2344
  }
2091
2345
  try {
2092
- const res = await fetch(`http://${host}:${port}/api/status`);
2346
+ const res = await fetch(`http://${host}:${port}/api/status`, { headers: authHeaders(config2.hub.token) });
2093
2347
  if (res.ok) {
2094
2348
  const data = await res.json();
2095
2349
  console.log(`Hub: running (PID: ${data.pid})`);
@@ -2153,7 +2407,7 @@ function ask(question) {
2153
2407
  }
2154
2408
  async function init() {
2155
2409
  const dir = process.cwd();
2156
- const projectName = path7.basename(dir);
2410
+ const projectName = path8.basename(dir);
2157
2411
  console.log(`
2158
2412
  claude-alarm init for "${projectName}"
2159
2413
  `);
@@ -2173,11 +2427,11 @@ claude-alarm init for "${projectName}"
2173
2427
  if (port) env.CLAUDE_ALARM_HUB_PORT = port;
2174
2428
  if (token) env.CLAUDE_ALARM_HUB_TOKEN = token;
2175
2429
  }
2176
- const mcpPath = path7.join(dir, ".mcp.json");
2430
+ const mcpPath = path8.join(dir, ".mcp.json");
2177
2431
  let mcpConfig = {};
2178
- if (fs6.existsSync(mcpPath)) {
2432
+ if (fs7.existsSync(mcpPath)) {
2179
2433
  try {
2180
- mcpConfig = JSON.parse(fs6.readFileSync(mcpPath, "utf-8"));
2434
+ mcpConfig = JSON.parse(fs7.readFileSync(mcpPath, "utf-8"));
2181
2435
  } catch {
2182
2436
  mcpConfig = {};
2183
2437
  }
@@ -2188,7 +2442,7 @@ claude-alarm init for "${projectName}"
2188
2442
  args: ["-y", "@delt/claude-alarm", "serve"],
2189
2443
  env
2190
2444
  };
2191
- fs6.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
2445
+ fs7.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
2192
2446
  console.log(`
2193
2447
  \u2713 Created ${mcpPath}`);
2194
2448
  if (remote.toLowerCase() !== "y") {
@@ -2198,7 +2452,7 @@ claude-alarm init for "${projectName}"
2198
2452
  const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
2199
2453
  let hubRunning = false;
2200
2454
  try {
2201
- const res = await fetch(`http://${host}:${port}/api/status`);
2455
+ const res = await fetch(`http://${host}:${port}/api/status`, { headers: authHeaders(config2.hub.token) });
2202
2456
  hubRunning = res.ok;
2203
2457
  } catch {
2204
2458
  }
@@ -2233,6 +2487,54 @@ function isProcessRunning(pid) {
2233
2487
  return false;
2234
2488
  }
2235
2489
  }
2490
+ function readCodexPid() {
2491
+ if (!fs7.existsSync(CODEX_PID_FILE)) return void 0;
2492
+ const pid = parseInt(fs7.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10);
2493
+ return Number.isNaN(pid) ? void 0 : pid;
2494
+ }
2495
+ function codexEnable(enabled) {
2496
+ setCodexEnabled(enabled);
2497
+ console.log(enabled ? "Codex adapter enabled. Restart the hub to apply: claude-alarm hub stop, then claude-alarm hub start" : "Codex adapter disabled. Restart the hub to apply.");
2498
+ }
2499
+ function codexStart() {
2500
+ const pid = readCodexPid();
2501
+ if (pid !== void 0 && isProcessRunning(pid)) {
2502
+ console.log(`Codex adapter is already running (PID: ${pid})`);
2503
+ return;
2504
+ }
2505
+ ensureConfigDir();
2506
+ const logFd = fs7.openSync(CODEX_LOG_FILE, "a");
2507
+ const child = spawn2(process.execPath, [path8.join(__dirname2, "codex", "main.js")], {
2508
+ detached: true,
2509
+ stdio: ["ignore", logFd, logFd],
2510
+ env: { ...process.env }
2511
+ });
2512
+ child.unref();
2513
+ console.log(`Codex adapter started (PID: ${child.pid}). Logs: ${CODEX_LOG_FILE}`);
2514
+ }
2515
+ function codexStop() {
2516
+ const pid = readCodexPid();
2517
+ if (pid === void 0) {
2518
+ console.log("Codex adapter is not running");
2519
+ return;
2520
+ }
2521
+ try {
2522
+ process.kill(pid, "SIGTERM");
2523
+ console.log(`Codex adapter stopped (PID: ${pid})`);
2524
+ } catch {
2525
+ console.log("Codex adapter process not found (may have already stopped)");
2526
+ }
2527
+ try {
2528
+ fs7.unlinkSync(CODEX_PID_FILE);
2529
+ } catch {
2530
+ }
2531
+ }
2532
+ function codexStatus() {
2533
+ const pid = readCodexPid();
2534
+ const state = pid === void 0 ? "not running" : isProcessRunning(pid) ? `running (PID: ${pid})` : "not running (stale PID file)";
2535
+ console.log(`Codex adapter: ${state}`);
2536
+ console.log(`Start with hub: ${loadConfig().codex?.enabled ? "enabled" : "disabled"}`);
2537
+ }
2236
2538
  async function main2() {
2237
2539
  const args = process.argv.slice(2);
2238
2540
  const cmd = args[0];
@@ -2264,6 +2566,19 @@ async function main2() {
2264
2566
  }
2265
2567
  return;
2266
2568
  }
2569
+ if (cmd === "codex") {
2570
+ if (sub === "enable") codexEnable(true);
2571
+ else if (sub === "disable") codexEnable(false);
2572
+ else if (sub === "start") codexStart();
2573
+ else if (sub === "stop") codexStop();
2574
+ else if (sub === "status") codexStatus();
2575
+ else {
2576
+ console.error(`Unknown codex command: ${sub}`);
2577
+ printUsage();
2578
+ process.exit(1);
2579
+ }
2580
+ return;
2581
+ }
2267
2582
  if (cmd === "setup") {
2268
2583
  setup(args[1]);
2269
2584
  return;