@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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/hub/server.ts
2
2
  import http from "http";
3
- import fs4 from "fs";
4
- import path4 from "path";
3
+ import fs5 from "fs";
4
+ import path5 from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { WebSocketServer, WebSocket } from "ws";
7
7
 
@@ -36,6 +36,8 @@ var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
36
36
  var PID_FILE = path.join(CONFIG_DIR, "hub.pid");
37
37
  var LOG_FILE = path.join(CONFIG_DIR, "hub.log");
38
38
  var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
39
+ var CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
40
+ var CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
39
41
  var WS_PATH_CHANNEL = "/ws/channel";
40
42
  var WS_PATH_DASHBOARD = "/ws/dashboard";
41
43
  var CHANNEL_SERVER_NAME = "claude-alarm";
@@ -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,15 +788,15 @@ 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
  function setupMcpConfig(targetDir) {
715
794
  const dir = targetDir ?? process.cwd();
716
- const mcpPath = path3.join(dir, ".mcp.json");
795
+ const mcpPath = path4.join(dir, ".mcp.json");
717
796
  let mcpConfig = {};
718
- if (fs2.existsSync(mcpPath)) {
797
+ if (fs3.existsSync(mcpPath)) {
719
798
  try {
720
- mcpConfig = JSON.parse(fs2.readFileSync(mcpPath, "utf-8"));
799
+ mcpConfig = JSON.parse(fs3.readFileSync(mcpPath, "utf-8"));
721
800
  } catch {
722
801
  mcpConfig = {};
723
802
  }
@@ -729,21 +808,21 @@ function setupMcpConfig(targetDir) {
729
808
  command: "npx",
730
809
  args: ["-y", "@delt/claude-alarm", "serve"],
731
810
  env: {
732
- CLAUDE_ALARM_SESSION_NAME: path3.basename(dir)
811
+ CLAUDE_ALARM_SESSION_NAME: path4.basename(dir)
733
812
  }
734
813
  };
735
- fs2.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
814
+ fs3.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
736
815
  return mcpPath;
737
816
  }
738
817
 
739
818
  // src/shared/crash-guard.ts
740
- import fs3 from "fs";
819
+ import fs4 from "fs";
741
820
  var BURST_WINDOW_MS = 6e4;
742
821
  var BURST_LIMIT = 10;
743
822
  function appendToLog(text) {
744
823
  try {
745
- fs3.mkdirSync(CONFIG_DIR, { recursive: true });
746
- fs3.appendFileSync(LOG_FILE, text, "utf-8");
824
+ fs4.mkdirSync(CONFIG_DIR, { recursive: true });
825
+ fs4.appendFileSync(LOG_FILE, text, "utf-8");
747
826
  } catch {
748
827
  }
749
828
  }
@@ -784,8 +863,66 @@ function logStartup(context) {
784
863
  `);
785
864
  }
786
865
 
866
+ // src/hub/auth.ts
867
+ import crypto from "crypto";
868
+ var SESSION_COOKIE = "ca_session";
869
+ var COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
870
+ function safeEqual(a, b) {
871
+ if (typeof a !== "string") return false;
872
+ const x = Buffer.from(a);
873
+ const y = Buffer.from(b);
874
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
875
+ }
876
+ function dashboardCookieValue(token) {
877
+ return crypto.createHmac("sha256", token).update("claude-alarm-dashboard").digest("hex");
878
+ }
879
+ function parseCookies(header) {
880
+ const out = {};
881
+ for (const part of (header ?? "").split(";")) {
882
+ const i = part.indexOf("=");
883
+ if (i <= 0) continue;
884
+ const raw = part.slice(i + 1).trim();
885
+ let value = raw;
886
+ try {
887
+ value = decodeURIComponent(raw);
888
+ } catch {
889
+ }
890
+ out[part.slice(0, i).trim()] = value;
891
+ }
892
+ return out;
893
+ }
894
+ function sessionCookieHeader(token, secure) {
895
+ return `${SESSION_COOKIE}=${dashboardCookieValue(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`;
896
+ }
897
+ function isAuthorized(req, token, allowQueryToken) {
898
+ const auth = req.headers.authorization;
899
+ if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token)) return true;
900
+ if (safeEqual(parseCookies(req.headers.cookie)[SESSION_COOKIE], dashboardCookieValue(token))) return true;
901
+ if (allowQueryToken) {
902
+ const q = new URL(req.url ?? "/", "http://hub").searchParams.get("token");
903
+ if (safeEqual(q, token)) return true;
904
+ }
905
+ return false;
906
+ }
907
+ function isCrossOrigin(req) {
908
+ const origin = req.headers.origin;
909
+ if (!origin) return false;
910
+ try {
911
+ return new URL(origin).host !== req.headers.host;
912
+ } catch {
913
+ return true;
914
+ }
915
+ }
916
+ function isJsonRequest(req) {
917
+ return String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json");
918
+ }
919
+ function isSecureRequest(req) {
920
+ return req.headers["x-forwarded-proto"] === "https";
921
+ }
922
+
787
923
  // src/hub/server.ts
788
- var __dirname = path4.dirname(fileURLToPath(import.meta.url));
924
+ var __dirname = path5.dirname(fileURLToPath(import.meta.url));
925
+ var MAX_WS_PAYLOAD = 16 * 1024 * 1024;
789
926
  var HubServer = class {
790
927
  httpServer;
791
928
  wssChannel;
@@ -803,13 +940,17 @@ var HubServer = class {
803
940
  heartbeatInterval;
804
941
  channelAlive = /* @__PURE__ */ new Map();
805
942
  // sessionId -> alive flag
943
+ socketOwners = /* @__PURE__ */ new WeakMap();
806
944
  host;
807
945
  port;
808
946
  token;
947
+ codexEnabled;
948
+ codexSupervisor;
809
949
  constructor(config) {
810
950
  this.host = config?.hub?.host ?? DEFAULT_HUB_HOST;
811
951
  this.port = config?.hub?.port ?? DEFAULT_HUB_PORT;
812
952
  this.token = config?.hub?.token;
953
+ this.codexEnabled = config?.codex?.enabled === true;
813
954
  if (config?.notifications) {
814
955
  this.notifier.configure({
815
956
  desktop: config.notifications.desktop
@@ -825,22 +966,23 @@ var HubServer = class {
825
966
  this.initTelegram(fullConfig.telegram);
826
967
  }
827
968
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
828
- this.wssChannel = new WebSocketServer({ noServer: true });
969
+ this.wssChannel = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
829
970
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
830
971
  this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
831
- this.wssDashboard = new WebSocketServer({ noServer: true });
972
+ this.wssDashboard = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD });
832
973
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
833
974
  this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
834
975
  this.httpServer.on("upgrade", (req, socket, head) => {
835
976
  socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
836
- const url = new URL(req.url, `http://${req.headers.host}`);
977
+ const url = new URL(req.url ?? "/", "http://hub");
837
978
  const pathname = url.pathname;
838
- if (this.token && !this.isLocalRequest(req)) {
839
- const wsToken = url.searchParams.get("token");
840
- if (wsToken !== this.token) {
841
- socket.destroy();
842
- return;
843
- }
979
+ if (isCrossOrigin(req)) {
980
+ this.rejectUpgrade(socket, 403, "Forbidden", pathname, req);
981
+ return;
982
+ }
983
+ if (!this.authorized(req, true)) {
984
+ this.rejectUpgrade(socket, 401, "Unauthorized", pathname, req);
985
+ return;
844
986
  }
845
987
  if (pathname === WS_PATH_CHANNEL) {
846
988
  this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
@@ -866,12 +1008,15 @@ var HubServer = class {
866
1008
  this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
867
1009
  const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
868
1010
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
1011
+ this.startCodexAdapter();
869
1012
  resolve();
870
1013
  });
871
1014
  });
872
1015
  }
873
1016
  stop() {
874
1017
  return new Promise((resolve) => {
1018
+ this.codexSupervisor?.stop();
1019
+ this.codexSupervisor = void 0;
875
1020
  if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
876
1021
  if (this.telegramBot) this.telegramBot.stopPolling();
877
1022
  for (const ws of this.channelSockets.values()) ws.terminate();
@@ -892,31 +1037,28 @@ var HubServer = class {
892
1037
  }
893
1038
  // --- HTTP Handler ---
894
1039
  handleHttp(req, res) {
895
- const url = new URL(req.url, `http://${req.headers.host}`);
896
- const origin = req.headers.origin;
897
- if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
898
- res.setHeader("Access-Control-Allow-Origin", origin);
899
- }
900
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
901
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
902
- if (req.method === "OPTIONS") {
903
- res.writeHead(204);
904
- res.end();
1040
+ const url = new URL(req.url ?? "/", "http://hub");
1041
+ if (isCrossOrigin(req)) {
1042
+ this.jsonResponse(res, 403, { error: "Cross-origin request rejected" });
905
1043
  return;
906
1044
  }
907
- if (url.pathname !== "/" && this.token) {
908
- if (!this.isLocalRequest(req)) {
909
- const authHeader = req.headers["authorization"];
910
- const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
911
- if (bearerToken !== this.token) {
912
- this.jsonResponse(res, 401, { error: "Unauthorized" });
913
- return;
914
- }
915
- }
1045
+ if (req.method === "POST" && !isJsonRequest(req)) {
1046
+ this.jsonResponse(res, 415, { error: "Content-Type must be application/json" });
1047
+ return;
916
1048
  }
917
1049
  if (url.pathname === "/" && req.method === "GET") {
918
- this.serveDashboard(res);
919
- } else if (url.pathname === "/api/sessions" && req.method === "GET") {
1050
+ this.serveDashboard(req, res, url);
1051
+ return;
1052
+ }
1053
+ if (url.pathname === "/api/login" && req.method === "POST") {
1054
+ this.handleLogin(req, res);
1055
+ return;
1056
+ }
1057
+ if (!this.authorized(req, false)) {
1058
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1059
+ return;
1060
+ }
1061
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
920
1062
  this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
921
1063
  } else if (url.pathname === "/api/status" && req.method === "GET") {
922
1064
  this.jsonResponse(res, 200, {
@@ -967,25 +1109,31 @@ var HubServer = class {
967
1109
  this.jsonResponse(res, 404, { error: "Not found" });
968
1110
  }
969
1111
  }
970
- serveDashboard(res) {
1112
+ serveDashboard(req, res, url) {
1113
+ const linkToken = url.searchParams.get("token");
1114
+ if (linkToken !== null && this.token && safeEqual(linkToken, this.token)) {
1115
+ res.writeHead(302, { Location: "/", "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1116
+ res.end();
1117
+ return;
1118
+ }
971
1119
  const candidates = [
972
- path4.join(__dirname, "..", "dashboard", "index.html"),
1120
+ path5.join(__dirname, "..", "dashboard", "index.html"),
973
1121
  // from dist/hub/
974
- path4.join(__dirname, "dashboard", "index.html"),
1122
+ path5.join(__dirname, "dashboard", "index.html"),
975
1123
  // from dist/ (bundled index.js)
976
- path4.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
1124
+ path5.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
977
1125
  // from dist/hub/ -> src/
978
- path4.join(__dirname, "..", "src", "dashboard", "index.html"),
1126
+ path5.join(__dirname, "..", "src", "dashboard", "index.html"),
979
1127
  // from dist/ -> src/
980
- path4.join(process.cwd(), "dist", "dashboard", "index.html"),
1128
+ path5.join(process.cwd(), "dist", "dashboard", "index.html"),
981
1129
  // from cwd
982
- path4.join(process.cwd(), "src", "dashboard", "index.html")
1130
+ path5.join(process.cwd(), "src", "dashboard", "index.html")
983
1131
  // from cwd/src
984
1132
  ];
985
1133
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
986
1134
  for (const candidate of candidates) {
987
- if (fs4.existsSync(candidate)) {
988
- const html = fs4.readFileSync(candidate, "utf-8");
1135
+ if (fs5.existsSync(candidate)) {
1136
+ const html = fs5.readFileSync(candidate, "utf-8");
989
1137
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
990
1138
  res.end(html);
991
1139
  return;
@@ -994,6 +1142,20 @@ var HubServer = class {
994
1142
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
995
1143
  res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
996
1144
  }
1145
+ async handleLogin(req, res) {
1146
+ const body = await this.readBody(req);
1147
+ if (!this.token) {
1148
+ res.writeHead(204);
1149
+ res.end();
1150
+ return;
1151
+ }
1152
+ if (typeof body?.token !== "string" || !safeEqual(body.token, this.token)) {
1153
+ this.jsonResponse(res, 401, { error: "Unauthorized" });
1154
+ return;
1155
+ }
1156
+ res.writeHead(204, { "Set-Cookie": sessionCookieHeader(this.token, isSecureRequest(req)) });
1157
+ res.end();
1158
+ }
997
1159
  async handleApiSend(req, res) {
998
1160
  const body = await this.readBody(req);
999
1161
  if (!body) {
@@ -1010,7 +1172,7 @@ var HubServer = class {
1010
1172
  this.jsonResponse(res, 404, { error: "Session not connected" });
1011
1173
  return;
1012
1174
  }
1013
- const msg = { type: "message_to_session", sessionId, content };
1175
+ const msg = { type: "message_to_session", sessionId, content, source: "api" };
1014
1176
  ws.send(JSON.stringify(msg));
1015
1177
  this.jsonResponse(res, 200, { ok: true });
1016
1178
  }
@@ -1070,6 +1232,22 @@ var HubServer = class {
1070
1232
  });
1071
1233
  }
1072
1234
  handleChannelMessage(ws, isLocal, msg) {
1235
+ if (msg.type === "register") {
1236
+ const id = msg.session.id;
1237
+ const owned = this.socketOwners.get(ws);
1238
+ if (owned && owned !== id) {
1239
+ logger.warn(`Rejected register for ${id}: connection already owns ${owned}`);
1240
+ return;
1241
+ }
1242
+ const holder = this.channelSockets.get(id);
1243
+ if (holder && holder !== ws) {
1244
+ this.socketOwners.delete(holder);
1245
+ holder.terminate();
1246
+ }
1247
+ this.socketOwners.set(ws, id);
1248
+ } else if ("sessionId" in msg && this.socketOwners.get(ws) !== msg.sessionId) {
1249
+ return;
1250
+ }
1073
1251
  switch (msg.type) {
1074
1252
  case "register": {
1075
1253
  const session = msg.session;
@@ -1093,7 +1271,6 @@ var HubServer = class {
1093
1271
  break;
1094
1272
  }
1095
1273
  case "peer_name": {
1096
- if (this.channelSockets.get(msg.sessionId) !== ws) break;
1097
1274
  const updated = this.sessions.setPeerName(msg.sessionId, msg.peerName);
1098
1275
  if (updated) {
1099
1276
  logger.info(`Peer name for ${msg.sessionId}: ${msg.peerName ?? "-"}`);
@@ -1169,7 +1346,7 @@ var HubServer = class {
1169
1346
  if (msg.type === "message_to_session") {
1170
1347
  const channelWs = this.channelSockets.get(msg.sessionId);
1171
1348
  if (channelWs?.readyState === WebSocket.OPEN) {
1172
- channelWs.send(JSON.stringify(msg));
1349
+ channelWs.send(JSON.stringify({ ...msg, source: "dashboard" }));
1173
1350
  }
1174
1351
  } else if (msg.type === "image_upload") {
1175
1352
  this.handleImageUpload(msg);
@@ -1214,11 +1391,11 @@ var HubServer = class {
1214
1391
  logger.warn("Image upload rejected: exceeds 10MB");
1215
1392
  return;
1216
1393
  }
1217
- fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
1394
+ fs5.mkdirSync(UPLOADS_DIR, { recursive: true });
1218
1395
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1219
1396
  const filename = `${randomUUID3()}.${ext}`;
1220
- const filePath = path4.join(UPLOADS_DIR, filename);
1221
- fs4.writeFileSync(filePath, buffer);
1397
+ const filePath = path5.join(UPLOADS_DIR, filename);
1398
+ fs5.writeFileSync(filePath, buffer);
1222
1399
  const forwardMsg = {
1223
1400
  type: "image_to_session",
1224
1401
  sessionId,
@@ -1231,7 +1408,7 @@ var HubServer = class {
1231
1408
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1232
1409
  setTimeout(() => {
1233
1410
  try {
1234
- fs4.unlinkSync(filePath);
1411
+ fs5.unlinkSync(filePath);
1235
1412
  } catch {
1236
1413
  }
1237
1414
  }, 5 * 60 * 1e3);
@@ -1259,7 +1436,7 @@ var HubServer = class {
1259
1436
  this.telegramBot.onMessageToSession = (sessionId, content) => {
1260
1437
  const channelWs = this.channelSockets.get(sessionId);
1261
1438
  if (channelWs?.readyState === WebSocket.OPEN) {
1262
- const msg = { type: "message_to_session", sessionId, content };
1439
+ const msg = { type: "message_to_session", sessionId, content, source: "telegram" };
1263
1440
  channelWs.send(JSON.stringify(msg));
1264
1441
  logger.info(`Telegram message forwarded to session: ${sessionId}`);
1265
1442
  }
@@ -1386,6 +1563,17 @@ var HubServer = class {
1386
1563
  this.jsonResponse(res, 500, { error: err.message });
1387
1564
  }
1388
1565
  }
1566
+ startCodexAdapter() {
1567
+ if (!this.codexEnabled) return;
1568
+ const script = resolveAdapterScript(__dirname);
1569
+ if (!script) {
1570
+ logger.warn("Codex adapter is enabled but codex/main.js was not found next to the hub");
1571
+ return;
1572
+ }
1573
+ this.codexSupervisor = new CodexSupervisor(script);
1574
+ this.codexSupervisor.start();
1575
+ logger.info("Codex adapter started");
1576
+ }
1389
1577
  startHeartbeat() {
1390
1578
  this.heartbeatInterval = setInterval(() => {
1391
1579
  for (const [sessionId, ws] of this.channelSockets) {
@@ -1401,11 +1589,11 @@ var HubServer = class {
1401
1589
  }
1402
1590
  cleanupUploads() {
1403
1591
  try {
1404
- if (!fs4.existsSync(UPLOADS_DIR)) return;
1405
- const files = fs4.readdirSync(UPLOADS_DIR);
1592
+ if (!fs5.existsSync(UPLOADS_DIR)) return;
1593
+ const files = fs5.readdirSync(UPLOADS_DIR);
1406
1594
  for (const file of files) {
1407
1595
  try {
1408
- fs4.unlinkSync(path4.join(UPLOADS_DIR, file));
1596
+ fs5.unlinkSync(path5.join(UPLOADS_DIR, file));
1409
1597
  } catch {
1410
1598
  }
1411
1599
  }
@@ -1414,13 +1602,24 @@ var HubServer = class {
1414
1602
  }
1415
1603
  }
1416
1604
  getSessionLabel(session) {
1417
- if (!session) return "unknown";
1418
- return session.displayName || session.cwd?.replace(/^.*[/\\]/, "") || session.name;
1605
+ return session ? sessionLabel(session) : "unknown";
1419
1606
  }
1420
1607
  jsonResponse(res, status, body) {
1421
1608
  res.writeHead(status, { "Content-Type": "application/json" });
1422
1609
  res.end(JSON.stringify(body));
1423
1610
  }
1611
+ authorized(req, allowQueryToken) {
1612
+ return !this.token || isAuthorized(req, this.token, allowQueryToken);
1613
+ }
1614
+ rejectUpgrade(socket, status, text, pathname, req) {
1615
+ logger.warn(`Rejected ${pathname} upgrade (${status}) from ${req.socket.remoteAddress}`);
1616
+ socket.write(`HTTP/1.1 ${status} ${text}\r
1617
+ Connection: close\r
1618
+ Content-Length: 0\r
1619
+ \r
1620
+ `);
1621
+ socket.destroy();
1622
+ }
1424
1623
  isLocalRequest(req) {
1425
1624
  const addr = req.socket.remoteAddress;
1426
1625
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
@@ -1467,48 +1666,41 @@ if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.arg
1467
1666
  // src/channel/hub-client.ts
1468
1667
  import WebSocket2 from "ws";
1469
1668
  var HubClient = class {
1470
- constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token, getPeerName = () => void 0) {
1669
+ constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token, getPeerName = () => void 0, getRegistration = () => ({})) {
1471
1670
  this.sessionId = sessionId;
1472
1671
  this.sessionName = sessionName;
1473
1672
  this.hubHost = hubHost;
1474
1673
  this.hubPort = hubPort;
1475
1674
  this.token = token;
1476
1675
  this.getPeerName = getPeerName;
1676
+ this.getRegistration = getRegistration;
1477
1677
  }
1478
1678
  ws = null;
1479
1679
  reconnectTimer = null;
1480
1680
  messageHandlers = [];
1481
1681
  queue = [];
1482
1682
  connected = false;
1683
+ closed = false;
1483
1684
  connect() {
1685
+ this.closed = false;
1484
1686
  const tokenQuery = this.token ? `?token=${encodeURIComponent(this.token)}` : "";
1485
1687
  const url = `ws://${this.hubHost}:${this.hubPort}${WS_PATH_CHANNEL}${tokenQuery}`;
1486
1688
  logger.debug(`Connecting to hub at ${url}`);
1487
1689
  try {
1488
- this.ws = new WebSocket2(url);
1489
- this.ws.on("open", () => {
1690
+ const ws = new WebSocket2(url);
1691
+ this.ws = ws;
1692
+ ws.on("open", () => {
1693
+ if (this.ws !== ws) return;
1490
1694
  logger.info("Connected to hub");
1491
1695
  this.connected = true;
1492
- const registration = {
1493
- type: "register",
1494
- session: {
1495
- id: this.sessionId,
1496
- name: this.sessionName,
1497
- status: "idle",
1498
- connectedAt: Date.now(),
1499
- lastActivity: Date.now(),
1500
- cwd: process.cwd(),
1501
- channelEnabled: true,
1502
- peerName: this.getPeerName()
1503
- }
1504
- };
1505
- this.ws.send(JSON.stringify(registration));
1696
+ this.ws.send(JSON.stringify(this.registration()));
1506
1697
  for (const msg of this.queue) {
1507
1698
  this.ws.send(JSON.stringify(msg));
1508
1699
  }
1509
1700
  this.queue = [];
1510
1701
  });
1511
- this.ws.on("message", (data) => {
1702
+ ws.on("message", (data) => {
1703
+ if (this.ws !== ws) return;
1512
1704
  try {
1513
1705
  const msg = JSON.parse(data.toString());
1514
1706
  for (const handler of this.messageHandlers) {
@@ -1518,12 +1710,14 @@ var HubClient = class {
1518
1710
  logger.warn("Failed to parse hub message:", err);
1519
1711
  }
1520
1712
  });
1521
- this.ws.on("close", () => {
1713
+ ws.on("close", () => {
1714
+ if (this.ws !== ws) return;
1522
1715
  logger.info("Disconnected from hub");
1523
1716
  this.connected = false;
1524
- this.scheduleReconnect();
1717
+ if (!this.closed) this.scheduleReconnect();
1525
1718
  });
1526
- this.ws.on("error", (err) => {
1719
+ ws.on("error", (err) => {
1720
+ if (this.ws !== ws) return;
1527
1721
  logger.debug(`Hub connection error: ${err.message}`);
1528
1722
  this.connected = false;
1529
1723
  });
@@ -1546,6 +1740,7 @@ var HubClient = class {
1546
1740
  this.messageHandlers.push(handler);
1547
1741
  }
1548
1742
  disconnect() {
1743
+ this.closed = true;
1549
1744
  if (this.reconnectTimer) {
1550
1745
  clearTimeout(this.reconnectTimer);
1551
1746
  this.reconnectTimer = null;
@@ -1556,8 +1751,29 @@ var HubClient = class {
1556
1751
  }
1557
1752
  this.connected = false;
1558
1753
  }
1754
+ reregister() {
1755
+ if (this.connected && this.ws?.readyState === WebSocket2.OPEN) {
1756
+ this.ws.send(JSON.stringify(this.registration()));
1757
+ }
1758
+ }
1759
+ registration() {
1760
+ return {
1761
+ type: "register",
1762
+ session: {
1763
+ id: this.sessionId,
1764
+ name: this.sessionName,
1765
+ status: "idle",
1766
+ connectedAt: Date.now(),
1767
+ lastActivity: Date.now(),
1768
+ cwd: process.cwd(),
1769
+ channelEnabled: true,
1770
+ peerName: this.getPeerName(),
1771
+ ...this.getRegistration()
1772
+ }
1773
+ };
1774
+ }
1559
1775
  scheduleReconnect() {
1560
- if (this.reconnectTimer) return;
1776
+ if (this.reconnectTimer || this.closed) return;
1561
1777
  this.reconnectTimer = setTimeout(() => {
1562
1778
  this.reconnectTimer = null;
1563
1779
  this.connect();
@@ -1567,6 +1783,8 @@ var HubClient = class {
1567
1783
  export {
1568
1784
  CHANNEL_SERVER_NAME,
1569
1785
  CHANNEL_SERVER_VERSION,
1786
+ CODEX_LOG_FILE,
1787
+ CODEX_PID_FILE,
1570
1788
  CONFIG_DIR,
1571
1789
  CONFIG_FILE,
1572
1790
  DEFAULT_HUB_HOST,