@delt/claude-alarm 0.7.1 → 0.8.1

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.
@@ -150,11 +150,43 @@
150
150
  padding: 12px;
151
151
  margin-bottom: 8px;
152
152
  cursor: pointer;
153
- transition: border-color 0.15s;
153
+ transition: border-color 0.15s, opacity 0.15s;
154
+ display: flex;
155
+ align-items: flex-start;
156
+ gap: 8px;
154
157
  }
155
158
  .session-card:hover, .session-card.active {
156
159
  border-color: var(--accent);
157
160
  }
161
+ .session-card.dragging { opacity: 0.4; }
162
+ .session-card.drag-over { border-color: var(--accent); border-style: dashed; }
163
+ .session-body { flex: 1; min-width: 0; }
164
+ .drag-handle {
165
+ flex-shrink: 0;
166
+ color: var(--text-dim);
167
+ font-size: 14px;
168
+ line-height: 1.4;
169
+ cursor: grab;
170
+ opacity: 0.45;
171
+ user-select: none;
172
+ padding-top: 1px;
173
+ transition: opacity 0.15s, color 0.15s;
174
+ }
175
+ .session-card:hover .drag-handle { opacity: 0.8; }
176
+ .drag-handle:hover { opacity: 1 !important; color: var(--accent); }
177
+ .drag-handle:active { cursor: grabbing; }
178
+ .name-edit-input {
179
+ width: 100%;
180
+ background: var(--bg);
181
+ border: 1px solid var(--accent);
182
+ border-radius: 4px;
183
+ color: var(--text);
184
+ font-size: 14px;
185
+ font-weight: 500;
186
+ padding: 1px 5px;
187
+ outline: none;
188
+ font-family: inherit;
189
+ }
158
190
  .session-name {
159
191
  font-size: 14px;
160
192
  font-weight: 500;
@@ -940,6 +972,7 @@
940
972
  unread: {},
941
973
  waitingReply: {},
942
974
  permissionRequests: {}, // sessionId -> [{ requestId, toolName, description, inputPreview, timestamp, resolved, behavior }]
975
+ sessionMeta: { names: {}, order: [] }, // custom names + order, keyed by volatile sessionId
943
976
  };
944
977
 
945
978
  const $ = (sel) => document.querySelector(sel);
@@ -978,6 +1011,67 @@
978
1011
 
979
1012
  loadMessages();
980
1013
 
1014
+ // --- Session meta persistence (custom names + order) ---
1015
+ // Keyed by sessionId, which is volatile: same id survives a browser refresh
1016
+ // (terminal stays alive) but changes on reconnect/server restart, so renamed
1017
+ // names and custom order naturally reset when a session truly ends.
1018
+ const META_STORAGE_KEY = 'claude-alarm-session-meta';
1019
+
1020
+ function saveSessionMeta() {
1021
+ try { localStorage.setItem(META_STORAGE_KEY, JSON.stringify(state.sessionMeta)); } catch {}
1022
+ }
1023
+
1024
+ function loadSessionMeta() {
1025
+ try {
1026
+ const raw = localStorage.getItem(META_STORAGE_KEY);
1027
+ if (raw) {
1028
+ const data = JSON.parse(raw);
1029
+ state.sessionMeta = {
1030
+ names: (data && typeof data.names === 'object' && data.names) || {},
1031
+ order: Array.isArray(data && data.order) ? data.order : [],
1032
+ };
1033
+ }
1034
+ } catch {}
1035
+ }
1036
+
1037
+ // Drop meta + messages for sessions that no longer exist. Must run only after
1038
+ // a sessions_list arrives (state.sessions populated), never on initial load.
1039
+ function pruneSessionData() {
1040
+ const live = new Set(Object.keys(state.sessions));
1041
+ let metaChanged = false;
1042
+ for (const id of Object.keys(state.sessionMeta.names)) {
1043
+ if (!live.has(id)) { delete state.sessionMeta.names[id]; metaChanged = true; }
1044
+ }
1045
+ const trimmedOrder = state.sessionMeta.order.filter(id => live.has(id));
1046
+ if (trimmedOrder.length !== state.sessionMeta.order.length) {
1047
+ state.sessionMeta.order = trimmedOrder;
1048
+ metaChanged = true;
1049
+ }
1050
+ if (metaChanged) saveSessionMeta();
1051
+
1052
+ let msgChanged = false;
1053
+ for (const id of Object.keys(state.messages)) {
1054
+ if (!live.has(id)) { delete state.messages[id]; msgChanged = true; }
1055
+ }
1056
+ if (msgChanged) saveMessages();
1057
+ }
1058
+
1059
+ loadSessionMeta();
1060
+
1061
+ // Ordered session ids: explicitly-ordered ones first (in saved order),
1062
+ // then any newly-arrived sessions in registration order.
1063
+ function getOrderedSessionIds() {
1064
+ const liveSet = new Set(Object.keys(state.sessions));
1065
+ const ordered = state.sessionMeta.order.filter(id => liveSet.has(id));
1066
+ const orderedSet = new Set(ordered);
1067
+ const rest = Object.keys(state.sessions).filter(id => !orderedSet.has(id));
1068
+ return [...ordered, ...rest];
1069
+ }
1070
+
1071
+ function sessionDisplayName(s) {
1072
+ return state.sessionMeta.names[s.id] || s.displayName || s.name;
1073
+ }
1074
+
981
1075
  // --- Theme ---
982
1076
  function initTheme() {
983
1077
  const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
@@ -1054,6 +1148,7 @@
1054
1148
  case 'sessions_list':
1055
1149
  state.sessions = {};
1056
1150
  msg.sessions.forEach(s => { state.sessions[s.id] = s; });
1151
+ pruneSessionData();
1057
1152
  renderSessions();
1058
1153
  break;
1059
1154
  case 'session_connected':
@@ -1063,6 +1158,9 @@
1063
1158
  break;
1064
1159
  case 'session_disconnected':
1065
1160
  delete state.sessions[msg.sessionId];
1161
+ delete state.sessionMeta.names[msg.sessionId];
1162
+ state.sessionMeta.order = state.sessionMeta.order.filter(id => id !== msg.sessionId);
1163
+ saveSessionMeta();
1066
1164
  if (state.selectedSession === msg.sessionId) { state.selectedSession = null; renderMessages(); }
1067
1165
  renderSessions();
1068
1166
  break;
@@ -1121,7 +1219,7 @@
1121
1219
  // --- Render ---
1122
1220
  function renderSessions() {
1123
1221
  const el = $('#sessionsList');
1124
- const ids = Object.keys(state.sessions);
1222
+ const ids = getOrderedSessionIds();
1125
1223
  if (!ids.length) {
1126
1224
  el.innerHTML = '<div class="no-sessions">No active sessions.<br>Click + to see connection commands.</div>';
1127
1225
  return;
@@ -1131,18 +1229,128 @@
1131
1229
  const active = state.selectedSession === id ? ' active' : '';
1132
1230
  const cwdDisplay = s.cwd ? s.cwd.replace(/^.*[/\\]/, '') : '';
1133
1231
  const unread = state.unread[id] || 0;
1134
- const dispName = s.displayName || s.name;
1232
+ const dispName = sessionDisplayName(s);
1135
1233
  const connTime = new Date(s.connectedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1136
1234
  return `<div class="session-card${active}" data-id="${id}">
1137
- <div class="session-name">${esc(dispName)}${unread ? `<span class="unread-badge">${unread}</span>` : ''}</div>
1138
- ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)} · ${connTime}</div>` : `<div class="session-cwd">${connTime}</div>`}
1139
- <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
1235
+ <span class="drag-handle" title="Drag to reorder">&#10303;</span>
1236
+ <div class="session-body">
1237
+ <div class="session-name" title="Double-click to rename">${esc(dispName)}${unread ? `<span class="unread-badge">${unread}</span>` : ''}</div>
1238
+ ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)} &middot; ${connTime}</div>` : `<div class="session-cwd">${connTime}</div>`}
1239
+ <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
1240
+ </div>
1140
1241
  </div>`;
1141
1242
  }).join('');
1243
+ bindSessionCards(el);
1244
+ updateImageUI();
1245
+ }
1246
+
1247
+ function bindSessionCards(el) {
1142
1248
  el.querySelectorAll('.session-card').forEach(card => {
1143
- card.addEventListener('click', () => selectSession(card.dataset.id));
1249
+ const id = card.dataset.id;
1250
+ card.addEventListener('click', (e) => {
1251
+ if (e.target.closest('.drag-handle')) return;
1252
+ if (e.target.classList.contains('name-edit-input')) return;
1253
+ selectSession(id);
1254
+ });
1255
+ const nameEl = card.querySelector('.session-name');
1256
+ nameEl.addEventListener('dblclick', (e) => {
1257
+ e.stopPropagation();
1258
+ startRename(id, nameEl);
1259
+ });
1260
+ // Drag only via handle: enable draggable on handle press, reset on dragend
1261
+ const handle = card.querySelector('.drag-handle');
1262
+ handle.addEventListener('mousedown', () => card.setAttribute('draggable', 'true'));
1263
+ handle.addEventListener('touchstart', () => card.setAttribute('draggable', 'true'), { passive: true });
1264
+ // Reset draggable if the handle was pressed but no drag occurred
1265
+ card.addEventListener('mouseup', () => card.setAttribute('draggable', 'false'));
1266
+ card.addEventListener('dragstart', (e) => onDragStart(e, id, card));
1267
+ card.addEventListener('dragend', () => onDragEnd(card));
1268
+ card.addEventListener('dragover', (e) => onDragOver(e, card));
1269
+ card.addEventListener('drop', (e) => onDrop(e, id, card));
1144
1270
  });
1145
- updateImageUI();
1271
+ }
1272
+
1273
+ // --- Rename (inline edit) ---
1274
+ function startRename(id, nameEl) {
1275
+ const s = state.sessions[id];
1276
+ if (!s) return;
1277
+ const input = document.createElement('input');
1278
+ input.type = 'text';
1279
+ input.className = 'name-edit-input';
1280
+ input.value = sessionDisplayName(s);
1281
+ nameEl.innerHTML = '';
1282
+ nameEl.appendChild(input);
1283
+ input.focus();
1284
+ input.select();
1285
+
1286
+ let done = false;
1287
+ const commit = (save) => {
1288
+ if (done) return;
1289
+ done = true;
1290
+ if (save) {
1291
+ const val = input.value.trim();
1292
+ const baseName = s.displayName || s.name;
1293
+ if (!val || val === baseName) delete state.sessionMeta.names[id];
1294
+ else state.sessionMeta.names[id] = val;
1295
+ saveSessionMeta();
1296
+ }
1297
+ renderSessions();
1298
+ };
1299
+ input.addEventListener('keydown', (e) => {
1300
+ e.stopPropagation();
1301
+ if (e.key === 'Enter') { e.preventDefault(); commit(true); }
1302
+ else if (e.key === 'Escape') { e.preventDefault(); commit(false); }
1303
+ });
1304
+ input.addEventListener('blur', () => commit(true));
1305
+ input.addEventListener('click', (e) => e.stopPropagation());
1306
+ }
1307
+
1308
+ // --- Reorder (drag & drop) ---
1309
+ let dragSrcId = null;
1310
+
1311
+ function onDragStart(e, id, card) {
1312
+ dragSrcId = id;
1313
+ card.classList.add('dragging');
1314
+ e.dataTransfer.effectAllowed = 'move';
1315
+ try { e.dataTransfer.setData('text/plain', id); } catch {}
1316
+ }
1317
+
1318
+ function onDragEnd(card) {
1319
+ card.classList.remove('dragging');
1320
+ card.setAttribute('draggable', 'false');
1321
+ document.querySelectorAll('.session-card.drag-over').forEach(c => c.classList.remove('drag-over'));
1322
+ dragSrcId = null;
1323
+ }
1324
+
1325
+ function onDragOver(e, card) {
1326
+ if (!dragSrcId || card.dataset.id === dragSrcId) return;
1327
+ e.preventDefault();
1328
+ e.dataTransfer.dropEffect = 'move';
1329
+ document.querySelectorAll('.session-card.drag-over').forEach(c => { if (c !== card) c.classList.remove('drag-over'); });
1330
+ card.classList.add('drag-over');
1331
+ }
1332
+
1333
+ function onDrop(e, targetId, card) {
1334
+ e.preventDefault();
1335
+ card.classList.remove('drag-over');
1336
+ if (!dragSrcId || dragSrcId === targetId) return;
1337
+ const rect = card.getBoundingClientRect();
1338
+ const after = (e.clientY - rect.top) > rect.height / 2;
1339
+ reorderSessions(dragSrcId, targetId, after);
1340
+ }
1341
+
1342
+ function reorderSessions(srcId, targetId, after) {
1343
+ const order = getOrderedSessionIds();
1344
+ const from = order.indexOf(srcId);
1345
+ if (from === -1) return;
1346
+ order.splice(from, 1);
1347
+ let to = order.indexOf(targetId);
1348
+ if (to === -1) return;
1349
+ if (after) to += 1;
1350
+ order.splice(to, 0, srcId);
1351
+ state.sessionMeta.order = order;
1352
+ saveSessionMeta();
1353
+ renderSessions();
1146
1354
  }
1147
1355
 
1148
1356
  function selectSession(id) {
@@ -1152,7 +1360,13 @@
1152
1360
  $('#msgInput').disabled = false;
1153
1361
  $('#sendBtn').disabled = false;
1154
1362
  $('#msgInput').placeholder = 'Send a message to session... (Shift+Enter for new line)';
1155
- renderSessions();
1363
+ // Update active highlight + clear unread badge in place. A full renderSessions()
1364
+ // here would replace the card DOM mid-click and break the rename double-click.
1365
+ document.querySelectorAll('#sessionsList .session-card').forEach(c => {
1366
+ const isSel = c.dataset.id === id;
1367
+ c.classList.toggle('active', isSel);
1368
+ if (isSel) { const badge = c.querySelector('.unread-badge'); if (badge) badge.remove(); }
1369
+ });
1156
1370
  renderMessages();
1157
1371
  updateImageUI();
1158
1372
  $('#msgInput').focus();
@@ -1177,7 +1391,7 @@
1177
1391
  }
1178
1392
 
1179
1393
  const s = state.sessions[state.selectedSession];
1180
- header.textContent = s ? (s.displayName || s.name) : state.selectedSession.slice(0, 12);
1394
+ header.textContent = s ? sessionDisplayName(s) : state.selectedSession.slice(0, 12);
1181
1395
 
1182
1396
  const msgs = state.messages[state.selectedSession] || [];
1183
1397
  if (!msgs.length) {
@@ -1224,7 +1438,7 @@
1224
1438
  el.innerHTML = state.notifications.slice(0, 50).map((n, i) => {
1225
1439
  const timeStr = relativeTime(n.time);
1226
1440
  const session = state.sessions[n.sessionId];
1227
- const sName = session ? session.name : n.sessionId.slice(0, 8);
1441
+ const sName = session ? sessionDisplayName(session) : n.sessionId.slice(0, 8);
1228
1442
  // Check if this is an unresolved permission request
1229
1443
  let permButtons = '';
1230
1444
  if (n.permRequestId) {
@@ -1559,6 +1773,7 @@
1559
1773
  // Keyboard shortcuts: Enter=allow, Esc=deny — works even in textarea when permission is pending
1560
1774
  document.addEventListener('keydown', (e) => {
1561
1775
  if (e.key !== 'Enter' && e.key !== 'Escape') return;
1776
+ if (e.target.classList && e.target.classList.contains('name-edit-input')) return;
1562
1777
  const sid = state.selectedSession;
1563
1778
  if (!sid) return;
1564
1779
  const reqs = state.permissionRequests[sid] || [];
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/hub/server.ts
4
4
  import http from "http";
5
- import fs3 from "fs";
5
+ import fs4 from "fs";
6
6
  import path4 from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { WebSocketServer, WebSocket } from "ws";
@@ -653,6 +653,54 @@ function saveConfig(config) {
653
653
  fs2.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
654
654
  }
655
655
 
656
+ // src/shared/crash-guard.ts
657
+ import fs3 from "fs";
658
+ var BURST_WINDOW_MS = 6e4;
659
+ var BURST_LIMIT = 10;
660
+ function appendToLog(text) {
661
+ try {
662
+ fs3.mkdirSync(CONFIG_DIR, { recursive: true });
663
+ fs3.appendFileSync(LOG_FILE, text, "utf-8");
664
+ } catch {
665
+ }
666
+ }
667
+ function describe(err) {
668
+ if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
669
+ try {
670
+ return JSON.stringify(err);
671
+ } catch {
672
+ return String(err);
673
+ }
674
+ }
675
+ function installCrashGuard(context) {
676
+ let recent = [];
677
+ const handle = (kind, err) => {
678
+ const stack = describe(err);
679
+ appendToLog(`
680
+ [${(/* @__PURE__ */ new Date()).toISOString()}] ${kind} (${context})
681
+ ${stack}
682
+ `);
683
+ logger.error(`${kind} caught by crash guard: ${stack}`);
684
+ const now = Date.now();
685
+ recent = recent.filter((t) => now - t < BURST_WINDOW_MS);
686
+ recent.push(now);
687
+ if (recent.length > BURST_LIMIT) {
688
+ const msg = `${recent.length} unhandled errors within ${BURST_WINDOW_MS / 1e3}s \u2014 exiting`;
689
+ appendToLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
690
+ `);
691
+ logger.error(msg);
692
+ process.exit(1);
693
+ }
694
+ };
695
+ process.on("uncaughtException", (err) => handle("uncaughtException", err));
696
+ process.on("unhandledRejection", (reason) => handle("unhandledRejection", reason));
697
+ }
698
+ function logStartup(context) {
699
+ appendToLog(`
700
+ [${(/* @__PURE__ */ new Date()).toISOString()}] ${context} started (pid ${process.pid})
701
+ `);
702
+ }
703
+
656
704
  // src/hub/server.ts
657
705
  var __dirname = path4.dirname(fileURLToPath(import.meta.url));
658
706
  var HubServer = class {
@@ -696,9 +744,12 @@ var HubServer = class {
696
744
  this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
697
745
  this.wssChannel = new WebSocketServer({ noServer: true });
698
746
  this.wssChannel.on("connection", (ws, req) => this.handleChannelConnection(ws, req));
747
+ this.wssChannel.on("error", (err) => logger.warn(`Channel WebSocket server error: ${err.message}`));
699
748
  this.wssDashboard = new WebSocketServer({ noServer: true });
700
749
  this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
750
+ this.wssDashboard.on("error", (err) => logger.warn(`Dashboard WebSocket server error: ${err.message}`));
701
751
  this.httpServer.on("upgrade", (req, socket, head) => {
752
+ socket.on("error", (err) => logger.debug(`Upgrade socket error: ${err.message}`));
702
753
  const url = new URL(req.url, `http://${req.headers.host}`);
703
754
  const pathname = url.pathname;
704
755
  if (this.token && !this.isLocalRequest(req)) {
@@ -725,8 +776,11 @@ var HubServer = class {
725
776
  this.cleanupUploads();
726
777
  this.startHeartbeat();
727
778
  return new Promise((resolve, reject) => {
728
- this.httpServer.on("error", reject);
779
+ const onStartupError = (err) => reject(err);
780
+ this.httpServer.once("error", onStartupError);
729
781
  this.httpServer.listen(this.port, this.host, () => {
782
+ this.httpServer.removeListener("error", onStartupError);
783
+ this.httpServer.on("error", (err) => logger.error(`HTTP server error: ${err.message}`));
730
784
  const displayHost = this.host === "0.0.0.0" ? "127.0.0.1" : this.host;
731
785
  logger.info(`Hub server listening on http://${displayHost}:${this.port}`);
732
786
  resolve();
@@ -847,8 +901,8 @@ var HubServer = class {
847
901
  ];
848
902
  logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
849
903
  for (const candidate of candidates) {
850
- if (fs3.existsSync(candidate)) {
851
- const html = fs3.readFileSync(candidate, "utf-8");
904
+ if (fs4.existsSync(candidate)) {
905
+ const html = fs4.readFileSync(candidate, "utf-8");
852
906
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
853
907
  res.end(html);
854
908
  return;
@@ -895,6 +949,10 @@ var HubServer = class {
895
949
  handleChannelConnection(ws, req) {
896
950
  const isLocal = this.isLocalRequest(req);
897
951
  logger.info(`Channel server connected (local: ${isLocal})`);
952
+ ws.on("error", (err) => {
953
+ logger.warn(`Channel WebSocket error: ${err.message}`);
954
+ ws.terminate();
955
+ });
898
956
  ws.on("pong", () => {
899
957
  for (const [sessionId, sock] of this.channelSockets) {
900
958
  if (sock === ws) {
@@ -1004,6 +1062,10 @@ var HubServer = class {
1004
1062
  handleDashboardConnection(ws) {
1005
1063
  this.dashboardSockets.add(ws);
1006
1064
  logger.info(`Dashboard connected (total: ${this.dashboardSockets.size})`);
1065
+ ws.on("error", (err) => {
1066
+ logger.warn(`Dashboard WebSocket error: ${err.message}`);
1067
+ ws.terminate();
1068
+ });
1007
1069
  const sessionsMsg = {
1008
1070
  type: "sessions_list",
1009
1071
  sessions: this.sessions.getAll()
@@ -1060,11 +1122,11 @@ var HubServer = class {
1060
1122
  logger.warn("Image upload rejected: exceeds 10MB");
1061
1123
  return;
1062
1124
  }
1063
- fs3.mkdirSync(UPLOADS_DIR, { recursive: true });
1125
+ fs4.mkdirSync(UPLOADS_DIR, { recursive: true });
1064
1126
  const ext = mimeType.split("/")[1] === "jpeg" ? "jpg" : mimeType.split("/")[1];
1065
1127
  const filename = `${randomUUID3()}.${ext}`;
1066
1128
  const filePath = path4.join(UPLOADS_DIR, filename);
1067
- fs3.writeFileSync(filePath, buffer);
1129
+ fs4.writeFileSync(filePath, buffer);
1068
1130
  const forwardMsg = {
1069
1131
  type: "image_to_session",
1070
1132
  sessionId,
@@ -1077,7 +1139,7 @@ var HubServer = class {
1077
1139
  logger.info(`Image saved and forwarded: ${filename} (${buffer.length} bytes)`);
1078
1140
  setTimeout(() => {
1079
1141
  try {
1080
- fs3.unlinkSync(filePath);
1142
+ fs4.unlinkSync(filePath);
1081
1143
  } catch {
1082
1144
  }
1083
1145
  }, 5 * 60 * 1e3);
@@ -1247,11 +1309,11 @@ var HubServer = class {
1247
1309
  }
1248
1310
  cleanupUploads() {
1249
1311
  try {
1250
- if (!fs3.existsSync(UPLOADS_DIR)) return;
1251
- const files = fs3.readdirSync(UPLOADS_DIR);
1312
+ if (!fs4.existsSync(UPLOADS_DIR)) return;
1313
+ const files = fs4.readdirSync(UPLOADS_DIR);
1252
1314
  for (const file of files) {
1253
1315
  try {
1254
- fs3.unlinkSync(path4.join(UPLOADS_DIR, file));
1316
+ fs4.unlinkSync(path4.join(UPLOADS_DIR, file));
1255
1317
  } catch {
1256
1318
  }
1257
1319
  }
@@ -1295,6 +1357,8 @@ var HubServer = class {
1295
1357
  }
1296
1358
  };
1297
1359
  if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.argv[1].endsWith("hub/server.ts"))) {
1360
+ installCrashGuard("hub daemon");
1361
+ logStartup("Hub daemon");
1298
1362
  const config = loadConfig();
1299
1363
  const hub = new HubServer(config);
1300
1364
  hub.start().catch((err) => {