@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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/hub/server.ts
4
4
  import http from "http";
5
- import fs4 from "fs";
6
- import path4 from "path";
5
+ import fs5 from "fs";
6
+ import path5 from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { WebSocketServer, WebSocket } from "ws";
9
9
 
@@ -38,6 +38,8 @@ var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
38
38
  var PID_FILE = path.join(CONFIG_DIR, "hub.pid");
39
39
  var LOG_FILE = path.join(CONFIG_DIR, "hub.log");
40
40
  var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
41
+ var CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
42
+ var CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
41
43
  var WS_PATH_CHANNEL = "/ws/channel";
42
44
  var WS_PATH_DASHBOARD = "/ws/dashboard";
43
45
 
@@ -51,9 +53,9 @@ function isValidPeerName(name) {
51
53
  var SessionManager = class {
52
54
  sessions = /* @__PURE__ */ new Map();
53
55
  register(session) {
54
- const baseName = session.cwd?.replace(/^.*[/\\]/, "") || session.name;
56
+ const baseName = this.baseName(session);
55
57
  const existing = Array.from(this.sessions.values()).filter(
56
- (s) => s.id !== session.id && (s.cwd?.replace(/^.*[/\\]/, "") || s.name) === baseName
58
+ (s) => s.id !== session.id && this.baseName(s) === baseName
57
59
  );
58
60
  if (existing.length > 0) {
59
61
  const usedNums = existing.map((s) => {
@@ -107,6 +109,9 @@ var SessionManager = class {
107
109
  count() {
108
110
  return this.sessions.size;
109
111
  }
112
+ baseName(session) {
113
+ return session.title || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
114
+ }
110
115
  };
111
116
 
112
117
  // src/hub/notifier.ts
@@ -127,7 +132,7 @@ var Notifier = class {
127
132
  async notify(title, message, level = "info") {
128
133
  await this.notifyWithSession(void 0, void 0, title, message, level);
129
134
  }
130
- async notifyWithSession(sessionId, sessionLabel, title, message, level = "info") {
135
+ async notifyWithSession(sessionId, sessionLabel2, title, message, level = "info") {
131
136
  const promises = [];
132
137
  if (this.desktopEnabled) {
133
138
  promises.push(this.sendDesktop(title, message, level));
@@ -135,8 +140,8 @@ var Notifier = class {
135
140
  for (const webhook of this.webhooks) {
136
141
  promises.push(this.sendWebhook(webhook, title, message, level));
137
142
  }
138
- if (this.telegramBot && sessionId && sessionLabel) {
139
- promises.push(this.telegramBot.sendNotification(sessionId, sessionLabel, title, message));
143
+ if (this.telegramBot && sessionId && sessionLabel2) {
144
+ promises.push(this.telegramBot.sendNotification(sessionId, sessionLabel2, title, message));
140
145
  }
141
146
  await Promise.allSettled(promises);
142
147
  }
@@ -222,6 +227,14 @@ var Notifier = class {
222
227
  import fs from "fs";
223
228
  import path2 from "path";
224
229
  import { randomUUID } from "crypto";
230
+
231
+ // src/shared/session-label.ts
232
+ function sessionLabel(session) {
233
+ const base = session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
234
+ return session.agentKind === "codex" ? `Codex \xB7 ${base}` : base;
235
+ }
236
+
237
+ // src/hub/telegram.ts
225
238
  var TELEGRAM_API = "https://api.telegram.org/bot";
226
239
  var TelegramBot = class _TelegramBot {
227
240
  config;
@@ -360,15 +373,14 @@ ${this.mdToHtml(message)}`;
360
373
  const pending = this.pendingMessages.get(msg.chat.id);
361
374
  if (pending) {
362
375
  this.pendingMessages.delete(msg.chat.id);
363
- const sessions2 = this.getSessions?.() ?? [];
364
- const idx = parseInt(selectMatch[1], 10) - 1;
365
- if (idx >= 0 && idx < sessions2.length) {
376
+ const session = this.pendingSession(pending, parseInt(selectMatch[1], 10) - 1);
377
+ if (session) {
366
378
  if (pending.photoFileId) {
367
- await this.deliverPhotoToSessionByFileId(sessions2[idx].id, pending.photoFileId, pending.caption);
379
+ await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
368
380
  } else if (pending.text) {
369
- this.deliverToSession(sessions2[idx].id, pending.text);
381
+ this.deliverToSession(session.id, pending.text);
370
382
  }
371
- this.sendMessage(`Sent to [${this.getLabel(sessions2[idx])}]`);
383
+ this.sendMessage(`Sent to [${this.getLabel(session)}]`);
372
384
  } else {
373
385
  this.sendMessage("Invalid session number.");
374
386
  }
@@ -389,11 +401,12 @@ ${this.mdToHtml(message)}`;
389
401
  }
390
402
  return;
391
403
  }
404
+ const sessionIds = sessions.map((s) => s.id);
392
405
  if (hasPhoto) {
393
406
  const largest = msg.photo[msg.photo.length - 1];
394
- this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text });
407
+ this.pendingMessages.set(msg.chat.id, { photoFileId: largest.file_id, caption: text, sessionIds });
395
408
  } else {
396
- this.pendingMessages.set(msg.chat.id, { text });
409
+ this.pendingMessages.set(msg.chat.id, { text, sessionIds });
397
410
  }
398
411
  const buttons = sessions.map((s, i) => ({
399
412
  text: this.getLabel(s),
@@ -450,11 +463,16 @@ ${this.mdToHtml(message)}`;
450
463
  logger.warn(`Telegram photo download failed: ${err.message}`);
451
464
  }
452
465
  }
466
+ // Buttons are numbered against the list shown when they were sent; Codex sessions come and go, so the live list may have shifted.
467
+ pendingSession(pending, idx) {
468
+ const id = pending.sessionIds[idx];
469
+ return id ? this.getSessions?.().find((s) => s.id === id) : void 0;
470
+ }
453
471
  getLabel(session) {
454
- return session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
472
+ return sessionLabel(session);
455
473
  }
456
474
  /** Send a permission request with inline buttons */
457
- async sendPermissionRequest(sessionId, sessionLabel, requestId, toolName, description, inputPreview) {
475
+ async sendPermissionRequest(sessionId, sessionLabel2, requestId, toolName, description, inputPreview) {
458
476
  const isNotify = toolName.endsWith("__notify") || toolName === "notify";
459
477
  let preview = inputPreview;
460
478
  let kind = "code";
@@ -507,7 +525,7 @@ ${this.mdToHtml(message)}`;
507
525
  const mcpMatch = toolName.match(/__([^_]+)$/);
508
526
  if (mcpMatch) displayTool = mcpMatch[1].charAt(0).toUpperCase() + mcpMatch[1].slice(1);
509
527
  }
510
- const text = `\u26A0\uFE0F <b>Permission Request</b> \u2014 ${this.escHtml(sessionLabel)}
528
+ const text = `\u26A0\uFE0F <b>Permission Request</b> \u2014 ${this.escHtml(sessionLabel2)}
511
529
 
512
530
  \u{1F527} <b>${this.escHtml(displayTool)}</b>
513
531
  ${previewHtml}${truncNote}`;
@@ -521,6 +539,7 @@ ${previewHtml}${truncNote}`;
521
539
  }
522
540
  async handleCallbackQuery(query) {
523
541
  if (!query.data) return;
542
+ if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
524
543
  if (query.data.startsWith("sess:")) {
525
544
  await this.handleSessionSelectCallback(query);
526
545
  return;
@@ -546,22 +565,18 @@ ${label}`);
546
565
  const parts = query.data.split(":");
547
566
  if (parts.length < 3) return;
548
567
  const [, idxStr, chatIdStr] = parts;
549
- const idx = parseInt(idxStr, 10);
550
568
  const chatId = parseInt(chatIdStr, 10);
551
- const sessions = this.getSessions?.() ?? [];
552
- if (idx < 0 || idx >= sessions.length) {
569
+ const pending = this.pendingMessages.get(chatId);
570
+ const session = pending ? this.pendingSession(pending, parseInt(idxStr, 10)) : void 0;
571
+ if (!pending || !session) {
553
572
  await this.answerCallbackQuery(query.id, "Session not found");
554
573
  return;
555
574
  }
556
- const session = sessions[idx];
557
- const pending = this.pendingMessages.get(chatId);
558
575
  this.pendingMessages.delete(chatId);
559
- if (pending) {
560
- if (pending.photoFileId) {
561
- await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
562
- } else if (pending.text) {
563
- this.deliverToSession(session.id, pending.text);
564
- }
576
+ if (pending.photoFileId) {
577
+ await this.deliverPhotoToSessionByFileId(session.id, pending.photoFileId, pending.caption);
578
+ } else if (pending.text) {
579
+ this.deliverToSession(session.id, pending.text);
565
580
  }
566
581
  await this.answerCallbackQuery(query.id, `Sent to ${this.getLabel(session)}`);
567
582
  if (query.message) {
@@ -623,8 +638,8 @@ ${label}`);
623
638
  css: "css",
624
639
  scss: "scss"
625
640
  };
626
- langOf(path5) {
627
- const m = /\.([a-z0-9]+)$/i.exec(path5 || "");
641
+ langOf(path6) {
642
+ const m = /\.([a-z0-9]+)$/i.exec(path6 || "");
628
643
  return m ? _TelegramBot.LANG_BY_EXT[m[1].toLowerCase()] ?? "" : "";
629
644
  }
630
645
  /** Convert markdown to Telegram HTML (escape first, then apply formatting) */
@@ -667,9 +682,73 @@ ${bodyLines.join("\n")}`;
667
682
  }
668
683
  };
669
684
 
670
- // src/shared/config.ts
685
+ // src/hub/codex-supervisor.ts
686
+ import { spawn } from "child_process";
671
687
  import fs2 from "fs";
672
688
  import path3 from "path";
689
+ var HEALTHY_RUN_MS = 6e4;
690
+ function resolveAdapterScript(baseDir) {
691
+ return [path3.join(baseDir, "..", "codex", "main.js"), path3.join(baseDir, "codex", "main.js")].find((p) => fs2.existsSync(p));
692
+ }
693
+ var CodexSupervisor = class {
694
+ constructor(script, spawnFn = spawn, minDelayMs = 2e3, maxDelayMs = 6e4, stopGraceMs = 3e3) {
695
+ this.script = script;
696
+ this.spawnFn = spawnFn;
697
+ this.minDelayMs = minDelayMs;
698
+ this.maxDelayMs = maxDelayMs;
699
+ this.stopGraceMs = stopGraceMs;
700
+ this.delay = minDelayMs;
701
+ }
702
+ child;
703
+ timer;
704
+ stopped = true;
705
+ delay;
706
+ start() {
707
+ this.stopped = false;
708
+ this.launch();
709
+ }
710
+ stop() {
711
+ this.stopped = true;
712
+ if (this.timer) clearTimeout(this.timer);
713
+ this.timer = void 0;
714
+ const child = this.child;
715
+ this.child = void 0;
716
+ if (!child) return;
717
+ child.stdin?.end();
718
+ const force = setTimeout(() => child.kill(), this.stopGraceMs);
719
+ force.unref();
720
+ child.once("exit", () => clearTimeout(force));
721
+ }
722
+ launch() {
723
+ const startedAt = Date.now();
724
+ const child = this.spawnFn(process.execPath, [this.script, "--watch-stdin"], {
725
+ detached: true,
726
+ stdio: ["pipe", "pipe", "pipe"],
727
+ windowsHide: true
728
+ });
729
+ this.child = child;
730
+ child.stdout?.on("data", (d) => process.stdout.write(d));
731
+ child.stderr?.on("data", (d) => process.stderr.write(d));
732
+ child.stdin?.on("error", () => {
733
+ });
734
+ child.on("error", (err) => logger.warn(`Codex adapter failed to start: ${err.message}`));
735
+ child.on("exit", (code, signal) => {
736
+ if (this.child === child) this.child = void 0;
737
+ if (this.stopped || code === 0) return;
738
+ if (Date.now() - startedAt > HEALTHY_RUN_MS) this.delay = this.minDelayMs;
739
+ logger.warn(`Codex adapter exited (${signal ?? code}); restarting in ${this.delay}ms`);
740
+ this.timer = setTimeout(() => {
741
+ this.timer = void 0;
742
+ if (!this.stopped) this.launch();
743
+ }, this.delay);
744
+ this.delay = Math.min(this.delay * 2, this.maxDelayMs);
745
+ });
746
+ }
747
+ };
748
+
749
+ // src/shared/config.ts
750
+ import fs3 from "fs";
751
+ import path4 from "path";
673
752
  import { randomUUID as randomUUID2 } from "crypto";
674
753
  var DEFAULT_CONFIG = {
675
754
  hub: {
@@ -683,18 +762,18 @@ var DEFAULT_CONFIG = {
683
762
  webhooks: []
684
763
  };
685
764
  function ensureConfigDir() {
686
- if (!fs2.existsSync(CONFIG_DIR)) {
687
- fs2.mkdirSync(CONFIG_DIR, { recursive: true });
765
+ if (!fs3.existsSync(CONFIG_DIR)) {
766
+ fs3.mkdirSync(CONFIG_DIR, { recursive: true });
688
767
  }
689
768
  }
690
769
  function loadConfig() {
691
770
  ensureConfigDir();
692
771
  let config;
693
- if (!fs2.existsSync(CONFIG_FILE)) {
772
+ if (!fs3.existsSync(CONFIG_FILE)) {
694
773
  config = { ...DEFAULT_CONFIG, hub: { ...DEFAULT_CONFIG.hub } };
695
774
  } else {
696
775
  try {
697
- const raw = fs2.readFileSync(CONFIG_FILE, "utf-8");
776
+ const raw = fs3.readFileSync(CONFIG_FILE, "utf-8");
698
777
  const parsed = JSON.parse(raw);
699
778
  config = { ...DEFAULT_CONFIG, ...parsed, hub: { ...DEFAULT_CONFIG.hub, ...parsed.hub }, ...parsed.telegram ? { telegram: parsed.telegram } : {} };
700
779
  } catch {
@@ -709,17 +788,17 @@ function loadConfig() {
709
788
  }
710
789
  function saveConfig(config) {
711
790
  ensureConfigDir();
712
- fs2.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
791
+ fs3.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
713
792
  }
714
793
 
715
794
  // src/shared/crash-guard.ts
716
- import fs3 from "fs";
795
+ import fs4 from "fs";
717
796
  var BURST_WINDOW_MS = 6e4;
718
797
  var BURST_LIMIT = 10;
719
798
  function appendToLog(text) {
720
799
  try {
721
- fs3.mkdirSync(CONFIG_DIR, { recursive: true });
722
- fs3.appendFileSync(LOG_FILE, text, "utf-8");
800
+ fs4.mkdirSync(CONFIG_DIR, { recursive: true });
801
+ fs4.appendFileSync(LOG_FILE, text, "utf-8");
723
802
  } catch {
724
803
  }
725
804
  }
@@ -760,8 +839,66 @@ function logStartup(context) {
760
839
  `);
761
840
  }
762
841
 
842
+ // src/hub/auth.ts
843
+ import crypto from "crypto";
844
+ var SESSION_COOKIE = "ca_session";
845
+ var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
846
+ function safeEqual(a, b) {
847
+ if (typeof a !== "string") return false;
848
+ const x = Buffer.from(a);
849
+ const y = Buffer.from(b);
850
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
851
+ }
852
+ function dashboardCookieValue(token) {
853
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
854
+ }
855
+ function parseCookies(header) {
856
+ const out = {};
857
+ for (const part of (header ?? "").split(";")) {
858
+ const i = part.indexOf("=");
859
+ if (i <= 0) continue;
860
+ const raw = part.slice(i + 1).trim();
861
+ let value = raw;
862
+ try {
863
+ value = decodeURIComponent(raw);
864
+ } catch {
865
+ }
866
+ out[part.slice(0, i).trim()] = value;
867
+ }
868
+ return out;
869
+ }
870
+ function sessionCookieHeader(token, secure) {
871
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
872
+ }
873
+ function isAuthorized(req, token, allowQueryToken) {
874
+ const auth = req.headers.authorization;
875
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
876
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
877
+ if (allowQueryToken) {
878
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
879
+ if (safeEqual(q, token)) return true;
880
+ }
881
+ return false;
882
+ }
883
+ function isCrossOrigin(req) {
884
+ const origin = req.headers.origin;
885
+ if (!origin) return false;
886
+ try {
887
+ return new URL(origin).host !== req.headers.host;
888
+ } catch {
889
+ return true;
890
+ }
891
+ }
892
+ function isJsonRequest(req) {
893
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
894
+ }
895
+ function isSecureRequest(req) {
896
+ return req.headers["x-forwarded-proto"] === "https";
897
+ }
898
+
763
899
  // src/hub/server.ts
764
- var __dirname = path4.dirname(fileURLToPath(import.meta.url));
900
+ var __dirname = path5.dirname(fileURLToPath(import.meta.url));
901
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
765
902
  var HubServer = class {
766
903
  httpServer;
767
904
  wssChannel;
@@ -779,13 +916,17 @@ var HubServer = class {
779
916
  heartbeatInterval;
780
917
  channelAlive = /* @__PURE__ */ new Map();
781
918
  // sessionId -> alive flag
919
+ socketOwners = /* @__PURE__ */ new WeakMap();
782
920
  host;
783
921
  port;
784
922
  token;
923
+ codexEnabled;
924
+ codexSupervisor;
785
925
  constructor(config) {
786
926
  this.host = config?.hub?.host ?? DEFAULT_HUB_HOST;
787
927
  this.port = config?.hub?.port ?? DEFAULT_HUB_PORT;
788
928
  this.token = config?.hub?.token;
929
+ this.codexEnabled = config?.codex?.enabled === true;
789
930
  if (config?.notifications) {
790
931
  this.notifier.configure({
791
932
  desktop: config.notifications.desktop
@@ -801,22 +942,23 @@ var HubServer = class {
801
942
  this.initTelegram(fullConfig.telegram);
802
943
  }
803
944
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
804
- this.wssChannel = new WebSocketServer({ noServer: true });
945
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
805
946
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
806
947
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
807
- this.wssDashboard = new WebSocketServer({ noServer: true });
948
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
808
949
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
809
950
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
810
951
  this.httpServer.on("upgrade", (req, socket, head) => {
811
952
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
812
- const url = new URL(req.url, `http://${req.headers.host}`);
953
+ const url = new URL(req.url ?? "/", "http://hub");
813
954
  const pathname = url.pathname;
814
- if (this.token && !this.isLocalRequest(req)) {
815
- const wsToken = url.searchParams.get("token");
816
- if (wsToken !== this.token) {
817
- socket.destroy();
818
- return;
819
- }
955
+ if (isCrossOrigin(req)) {
956
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
957
+ return;
958
+ }
959
+ if (!this.authorized(req, true)) {
960
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
961
+ return;
820
962
  }
821
963
  if (pathname === WS_PATH_CHANNEL) {
822
964
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -842,12 +984,15 @@ var HubServer = class {
842
984
  this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
843
985
  const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
844
986
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
987
+ this.startCodexAdapter();
845
988
  resolve();
846
989
  });
847
990
  });
848
991
  }
849
992
  stop() {
850
993
  return new Promise((resolve) => {
994
+ this.codexSupervisor?.stop();
995
+ this.codexSupervisor = void 0;
851
996
  if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
852
997
  if (this.telegramBot) this.telegramBot.stopPolling();
853
998
  for (const ws of this.channelSockets.values()) ws.terminate();
@@ -868,31 +1013,28 @@ var HubServer = class {
868
1013
  }
869
1014
  // --- HTTP Handler ---
870
1015
  handleHttp(req, res) {
871
- const url = new URL(req.url, `http://${req.headers.host}`);
872
- const origin = req.headers.origin;
873
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
874
- res.setHeader("Access-Control-Allow-Origin", origin);
875
- }
876
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
877
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
878
- if (req.method === "OPTIONS") {
879
- res.writeHead(204);
880
- res.end();
1016
+ const url = new URL(req.url ?? "/", "http://hub");
1017
+ if (isCrossOrigin(req)) {
1018
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
881
1019
  return;
882
1020
  }
883
- if (url.pathname !== "/" && this.token) {
884
- if (!this.isLocalRequest(req)) {
885
- const authHeader = req.headers["authorization"];
886
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
887
- if (bearerToken !== this.token) {
888
- this.jsonResponse(res, 401, { error: "Unauthorized" });
889
- return;
890
- }
891
- }
1021
+ if (req.method === "POST" && !isJsonRequest(req)) {
1022
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
1023
+ return;
892
1024
  }
893
1025
  if (url.pathname === "/" && req.method === "GET") {
894
- this.serveDashboard(res);
895
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
1026
+ this.serveDashboard(req, res, url);
1027
+ return;
1028
+ }
1029
+ if (url.pathname === "/api/login" && req.method === "POST") {
1030
+ this.handleLogin(req, res);
1031
+ return;
1032
+ }
1033
+ if (!this.authorized(req, false)) {
1034
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1035
+ return;
1036
+ }
1037
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
896
1038
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
897
1039
  } else if (url.pathname === "/api/status" && req.method === "GET") {
898
1040
  this.jsonResponse(res, 200, {
@@ -943,25 +1085,31 @@ var HubServer = class {
943
1085
  this.jsonResponse(res, 404, { error: "Not found" });
944
1086
  }
945
1087
  }
946
- serveDashboard(res) {
1088
+ serveDashboard(req, res, url) {
1089
+ const linkToken = url.searchParams.get("token");
1090
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1091
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1092
+ res.end();
1093
+ return;
1094
+ }
947
1095
  const candidates = [
948
- path4.join(__dirname, "..", "dashboard", "index.html"),
1096
+ path5.join(__dirname, "..", "dashboard", "index.html"),
949
1097
  // from dist/hub/
950
- path4.join(__dirname, "dashboard", "index.html"),
1098
+ path5.join(__dirname, "dashboard", "index.html"),
951
1099
  // from dist/ (bundled index.js)
952
- path4.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1100
+ path5.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
953
1101
  // from dist/hub/ -> src/
954
- path4.join(__dirname, "..", "src", "dashboard", "index.html"),
1102
+ path5.join(__dirname, "..", "src", "dashboard", "index.html"),
955
1103
  // from dist/ -> src/
956
- path4.join(process.cwd(), "dist", "dashboard", "index.html"),
1104
+ path5.join(process.cwd(), "dist", "dashboard", "index.html"),
957
1105
  // from cwd
958
- path4.join(process.cwd(), "src", "dashboard", "index.html")
1106
+ path5.join(process.cwd(), "src", "dashboard", "index.html")
959
1107
  // from cwd/src
960
1108
  ];
961
1109
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
962
1110
  for (const candidate of candidates) {
963
- if (fs4.existsSync(candidate)) {
964
- const html = fs4.readFileSync(candidate, "utf-8");
1111
+ if (fs5.existsSync(candidate)) {
1112
+ const html = fs5.readFileSync(candidate, "utf-8");
965
1113
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
966
1114
  res.end(html);
967
1115
  return;
@@ -970,6 +1118,20 @@ var HubServer = class {
970
1118
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
971
1119
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
972
1120
  }
1121
+ async handleLogin(req, res) {
1122
+ const body = await this.readBody(req);
1123
+ if (!this.token) {
1124
+ res.writeHead(204);
1125
+ res.end();
1126
+ return;
1127
+ }
1128
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1129
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1130
+ return;
1131
+ }
1132
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1133
+ res.end();
1134
+ }
973
1135
  async handleApiSend(req, res) {
974
1136
  const body = await this.readBody(req);
975
1137
  if (!body) {
@@ -986,7 +1148,7 @@ var HubServer = class {
986
1148
  this.jsonResponse(res, 404, { error: "Session not connected" });
987
1149
  return;
988
1150
  }
989
- const msg = { type: "message_to_session", sessionId, content };
1151
+ const msg = { type: "message_to_session", sessionId, content, source: "api" };
990
1152
  ws.send(JSON.stringify(msg));
991
1153
  this.jsonResponse(res, 200, { ok: true });
992
1154
  }
@@ -1046,6 +1208,22 @@ var HubServer = class {
1046
1208
  });
1047
1209
  }
1048
1210
  handleChannelMessage(ws, isLocal, msg) {
1211
+ if (msg.type === "register") {
1212
+ const id = msg.session.id;
1213
+ const owned = this.socketOwners.get(ws);
1214
+ if (owned && owned !== id) {
1215
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1216
+ return;
1217
+ }
1218
+ const holder = this.channelSockets.get(id);
1219
+ if (holder && holder !== ws) {
1220
+ this.socketOwners.delete(holder);
1221
+ holder.terminate();
1222
+ }
1223
+ this.socketOwners.set(ws, id);
1224
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1225
+ return;
1226
+ }
1049
1227
  switch (msg.type) {
1050
1228
  case "register": {
1051
1229
  const session = msg.session;
@@ -1069,7 +1247,6 @@ var HubServer = class {
1069
1247
  break;
1070
1248
  }
1071
1249
  case "peer_name": {
1072
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1073
1250
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1074
1251
  if (updated) {
1075
1252
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1145,7 +1322,7 @@ var HubServer = class {
1145
1322
  if (msg.type === "message_to_session") {
1146
1323
  const channelWs = this.channelSockets.get(msg.sessionId);
1147
1324
  if (channelWs?.readyState === WebSocket.OPEN) {
1148
- channelWs.send(JSON.stringify(msg));
1325
+ channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1149
1326
  }
1150
1327
  } else if (msg.type === "image_upload") {
1151
1328
  this.handleImageUpload(msg);
@@ -1190,11 +1367,11 @@ var HubServer = class {
1190
1367
  logger.warn("Image upload rejected: exceeds 10MB");
1191
1368
  return;
1192
1369
  }
1193
- fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
1370
+ fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
1194
1371
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1195
1372
  const filename = `${randomUUID3()}.${ext}`;
1196
- const filePath = path4.join(UPLOADS_DIR, filename);
1197
- fs4.writeFileSync(filePath, buffer);
1373
+ const filePath = path5.join(UPLOADS_DIR, filename);
1374
+ fs5.writeFileSync(filePath, buffer);
1198
1375
  const forwardMsg = {
1199
1376
  type: "image_to_session",
1200
1377
  sessionId,
@@ -1207,7 +1384,7 @@ var HubServer = class {
1207
1384
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1208
1385
  setTimeout(() => {
1209
1386
  try {
1210
- fs4.unlinkSync(filePath);
1387
+ fs5.unlinkSync(filePath);
1211
1388
  } catch {
1212
1389
  }
1213
1390
  }, 5 * 60 * 1e3);
@@ -1235,7 +1412,7 @@ var HubServer = class {
1235
1412
  this.telegramBot.onMessageToSession = (sessionId, content) => {
1236
1413
  const channelWs = this.channelSockets.get(sessionId);
1237
1414
  if (channelWs?.readyState === WebSocket.OPEN) {
1238
- const msg = { type: "message_to_session", sessionId, content };
1415
+ const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1239
1416
  channelWs.send(JSON.stringify(msg));
1240
1417
  logger.info(`Telegram message forwarded to session: ${sessionId}`);
1241
1418
  }
@@ -1362,6 +1539,17 @@ var HubServer = class {
1362
1539
  this.jsonResponse(res, 500, { error: err.message });
1363
1540
  }
1364
1541
  }
1542
+ startCodexAdapter() {
1543
+ if (!this.codexEnabled) return;
1544
+ const script = resolveAdapterScript(__dirname);
1545
+ if (!script) {
1546
+ logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1547
+ return;
1548
+ }
1549
+ this.codexSupervisor = new CodexSupervisor(script);
1550
+ this.codexSupervisor.start();
1551
+ logger.info("Codex adapter started");
1552
+ }
1365
1553
  startHeartbeat() {
1366
1554
  this.heartbeatInterval = setInterval(() => {
1367
1555
  for (const [sessionId, ws] of this.channelSockets) {
@@ -1377,11 +1565,11 @@ var HubServer = class {
1377
1565
  }
1378
1566
  cleanupUploads() {
1379
1567
  try {
1380
- if (!fs4.existsSync(UPLOADS_DIR)) return;
1381
- const files = fs4.readdirSync(UPLOADS_DIR);
1568
+ if (!fs5.existsSync(UPLOADS_DIR)) return;
1569
+ const files = fs5.readdirSync(UPLOADS_DIR);
1382
1570
  for (const file of files) {
1383
1571
  try {
1384
- fs4.unlinkSync(path4.join(UPLOADS_DIR, file));
1572
+ fs5.unlinkSync(path5.join(UPLOADS_DIR, file));
1385
1573
  } catch {
1386
1574
  }
1387
1575
  }
@@ -1390,13 +1578,24 @@ var HubServer = class {
1390
1578
  }
1391
1579
  }
1392
1580
  getSessionLabel(session) {
1393
- if (!session) return "unknown";
1394
- return session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
1581
+ return session ? sessionLabel(session) : "unknown";
1395
1582
  }
1396
1583
  jsonResponse(res, status, body) {
1397
1584
  res.writeHead(status, { "Content-Type": "application/json" });
1398
1585
  res.end(JSON.stringify(body));
1399
1586
  }
1587
+ authorized(req, allowQueryToken) {
1588
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1589
+ }
1590
+ rejectUpgrade(socket, status, text, pathname, req) {
1591
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1592
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1593
+ Connection: close\r
1594
+ Content-Length: 0\r
1595
+ \r
1596
+ `);
1597
+ socket.destroy();
1598
+ }
1400
1599
  isLocalRequest(req) {
1401
1600
  const addr = req.socket.remoteAddress;
1402
1601
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";