@delt/claude-alarm 0.1.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/README.md +166 -0
- package/dist/channel/server.js +333 -0
- package/dist/channel/server.js.map +1 -0
- package/dist/cli.js +921 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashboard/index.html +580 -0
- package/dist/hub/server.js +512 -0
- package/dist/hub/server.js.map +1 -0
- package/dist/index.d.ts +196 -0
- package/dist/index.js +696 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/dashboard/index.html +580 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// src/hub/server.ts
|
|
2
|
+
import http from "http";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path2 from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
7
|
+
|
|
8
|
+
// src/shared/logger.ts
|
|
9
|
+
var logger = {
|
|
10
|
+
info(msg, ...args) {
|
|
11
|
+
console.error(`[claude-alarm] ${msg}`, ...args);
|
|
12
|
+
},
|
|
13
|
+
warn(msg, ...args) {
|
|
14
|
+
console.error(`[claude-alarm WARN] ${msg}`, ...args);
|
|
15
|
+
},
|
|
16
|
+
error(msg, ...args) {
|
|
17
|
+
console.error(`[claude-alarm ERROR] ${msg}`, ...args);
|
|
18
|
+
},
|
|
19
|
+
debug(msg, ...args) {
|
|
20
|
+
if (process.env.CLAUDE_ALARM_DEBUG) {
|
|
21
|
+
console.error(`[claude-alarm DEBUG] ${msg}`, ...args);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/shared/constants.ts
|
|
27
|
+
import path from "path";
|
|
28
|
+
import os from "os";
|
|
29
|
+
var DEFAULT_HUB_HOST = "127.0.0.1";
|
|
30
|
+
var DEFAULT_HUB_PORT = 7890;
|
|
31
|
+
var CONFIG_DIR = path.join(os.homedir(), ".claude-alarm");
|
|
32
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
33
|
+
var PID_FILE = path.join(CONFIG_DIR, "hub.pid");
|
|
34
|
+
var LOG_FILE = path.join(CONFIG_DIR, "hub.log");
|
|
35
|
+
var WS_PATH_CHANNEL = "/ws/channel";
|
|
36
|
+
var WS_PATH_DASHBOARD = "/ws/dashboard";
|
|
37
|
+
var CHANNEL_SERVER_NAME = "claude-alarm";
|
|
38
|
+
var CHANNEL_SERVER_VERSION = "0.1.0";
|
|
39
|
+
|
|
40
|
+
// src/hub/session-manager.ts
|
|
41
|
+
var SessionManager = class {
|
|
42
|
+
sessions = /* @__PURE__ */ new Map();
|
|
43
|
+
register(session) {
|
|
44
|
+
this.sessions.set(session.id, { ...session });
|
|
45
|
+
}
|
|
46
|
+
unregister(sessionId) {
|
|
47
|
+
const session = this.sessions.get(sessionId);
|
|
48
|
+
this.sessions.delete(sessionId);
|
|
49
|
+
return session;
|
|
50
|
+
}
|
|
51
|
+
updateStatus(sessionId, status) {
|
|
52
|
+
const session = this.sessions.get(sessionId);
|
|
53
|
+
if (session) {
|
|
54
|
+
session.status = status;
|
|
55
|
+
session.lastActivity = Date.now();
|
|
56
|
+
}
|
|
57
|
+
return session;
|
|
58
|
+
}
|
|
59
|
+
updateActivity(sessionId) {
|
|
60
|
+
const session = this.sessions.get(sessionId);
|
|
61
|
+
if (session) {
|
|
62
|
+
session.lastActivity = Date.now();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
get(sessionId) {
|
|
66
|
+
return this.sessions.get(sessionId);
|
|
67
|
+
}
|
|
68
|
+
getAll() {
|
|
69
|
+
return Array.from(this.sessions.values());
|
|
70
|
+
}
|
|
71
|
+
count() {
|
|
72
|
+
return this.sessions.size;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/hub/notifier.ts
|
|
77
|
+
import notifier from "node-notifier";
|
|
78
|
+
import { execFile } from "child_process";
|
|
79
|
+
var Notifier = class {
|
|
80
|
+
webhooks = [];
|
|
81
|
+
desktopEnabled = true;
|
|
82
|
+
notificationSettingsOpened = false;
|
|
83
|
+
dashboardUrl;
|
|
84
|
+
configure(options) {
|
|
85
|
+
if (options.dashboardUrl) this.dashboardUrl = options.dashboardUrl;
|
|
86
|
+
if (options.desktop !== void 0) this.desktopEnabled = options.desktop;
|
|
87
|
+
if (options.webhooks) this.webhooks = options.webhooks;
|
|
88
|
+
}
|
|
89
|
+
async notify(title, message, level = "info") {
|
|
90
|
+
const promises = [];
|
|
91
|
+
if (this.desktopEnabled) {
|
|
92
|
+
promises.push(this.sendDesktop(title, message, level));
|
|
93
|
+
}
|
|
94
|
+
for (const webhook of this.webhooks) {
|
|
95
|
+
promises.push(this.sendWebhook(webhook, title, message, level));
|
|
96
|
+
}
|
|
97
|
+
await Promise.allSettled(promises);
|
|
98
|
+
}
|
|
99
|
+
async sendDesktop(title, message, _level) {
|
|
100
|
+
if (process.platform === "win32") {
|
|
101
|
+
const enabled = await this.checkWindowsNotifications();
|
|
102
|
+
if (!enabled) {
|
|
103
|
+
this.openNotificationSettings();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const notification = notifier.notify(
|
|
109
|
+
{
|
|
110
|
+
title: `Claude Alarm: ${title}`,
|
|
111
|
+
message,
|
|
112
|
+
sound: true,
|
|
113
|
+
wait: true
|
|
114
|
+
},
|
|
115
|
+
(err) => {
|
|
116
|
+
if (err) {
|
|
117
|
+
logger.warn(`Desktop notification failed: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
resolve();
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
if (this.dashboardUrl && notification) {
|
|
123
|
+
const url = this.dashboardUrl;
|
|
124
|
+
notification.on("click", () => {
|
|
125
|
+
execFile("powershell", ["-Command", `Start-Process "${url}"`]);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
checkWindowsNotifications() {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
execFile(
|
|
133
|
+
"powershell",
|
|
134
|
+
["-Command", '(Get-ItemProperty -Path "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\PushNotifications" -Name ToastEnabled -ErrorAction SilentlyContinue).ToastEnabled'],
|
|
135
|
+
(err, stdout) => {
|
|
136
|
+
if (err) {
|
|
137
|
+
resolve(true);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const value = stdout.trim();
|
|
141
|
+
resolve(value !== "0");
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
openNotificationSettings() {
|
|
147
|
+
if (this.notificationSettingsOpened) return;
|
|
148
|
+
this.notificationSettingsOpened = true;
|
|
149
|
+
logger.warn("Windows notifications are disabled. Opening notification settings...");
|
|
150
|
+
logger.warn("Please enable notifications for this app, then try again.");
|
|
151
|
+
if (process.platform === "win32") {
|
|
152
|
+
execFile("powershell", ["-Command", "Start-Process ms-settings:notifications"]);
|
|
153
|
+
}
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
this.notificationSettingsOpened = false;
|
|
156
|
+
}, 5 * 60 * 1e3);
|
|
157
|
+
}
|
|
158
|
+
async sendWebhook(webhook, title, message, level) {
|
|
159
|
+
try {
|
|
160
|
+
const response = await fetch(webhook.url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
...webhook.headers
|
|
165
|
+
},
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
title,
|
|
168
|
+
message,
|
|
169
|
+
level,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
source: "claude-alarm"
|
|
172
|
+
})
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
logger.warn(`Webhook ${webhook.url} returned ${response.status}`);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logger.warn(`Webhook ${webhook.url} failed: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// src/hub/server.ts
|
|
184
|
+
var __dirname = path2.dirname(fileURLToPath(import.meta.url));
|
|
185
|
+
var HubServer = class {
|
|
186
|
+
httpServer;
|
|
187
|
+
wssChannel;
|
|
188
|
+
wssDashboard;
|
|
189
|
+
sessions = new SessionManager();
|
|
190
|
+
notifier = new Notifier();
|
|
191
|
+
startTime = Date.now();
|
|
192
|
+
// Map sessionId -> channel WebSocket
|
|
193
|
+
channelSockets = /* @__PURE__ */ new Map();
|
|
194
|
+
// All connected dashboard WebSockets
|
|
195
|
+
dashboardSockets = /* @__PURE__ */ new Set();
|
|
196
|
+
host;
|
|
197
|
+
port;
|
|
198
|
+
token;
|
|
199
|
+
constructor(config) {
|
|
200
|
+
this.host = config?.hub?.host ?? DEFAULT_HUB_HOST;
|
|
201
|
+
this.port = config?.hub?.port ?? DEFAULT_HUB_PORT;
|
|
202
|
+
this.token = config?.hub?.token;
|
|
203
|
+
if (config?.notifications) {
|
|
204
|
+
this.notifier.configure({
|
|
205
|
+
desktop: config.notifications.desktop
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (config?.webhooks) {
|
|
209
|
+
this.notifier.configure({ webhooks: config.webhooks });
|
|
210
|
+
}
|
|
211
|
+
this.notifier.configure({ dashboardUrl: `http://${this.host}:${this.port}` });
|
|
212
|
+
this.httpServer = http.createServer((req, res) => this.handleHttp(req, res));
|
|
213
|
+
this.wssChannel = new WebSocketServer({ noServer: true });
|
|
214
|
+
this.wssChannel.on("connection", (ws) => this.handleChannelConnection(ws));
|
|
215
|
+
this.wssDashboard = new WebSocketServer({ noServer: true });
|
|
216
|
+
this.wssDashboard.on("connection", (ws) => this.handleDashboardConnection(ws));
|
|
217
|
+
this.httpServer.on("upgrade", (req, socket, head) => {
|
|
218
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
219
|
+
const pathname = url.pathname;
|
|
220
|
+
if (this.token && !this.isLocalRequest(req)) {
|
|
221
|
+
const wsToken = url.searchParams.get("token");
|
|
222
|
+
if (wsToken !== this.token) {
|
|
223
|
+
socket.destroy();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (pathname === WS_PATH_CHANNEL) {
|
|
228
|
+
this.wssChannel.handleUpgrade(req, socket, head, (ws) => {
|
|
229
|
+
this.wssChannel.emit("connection", ws, req);
|
|
230
|
+
});
|
|
231
|
+
} else if (pathname === WS_PATH_DASHBOARD) {
|
|
232
|
+
this.wssDashboard.handleUpgrade(req, socket, head, (ws) => {
|
|
233
|
+
this.wssDashboard.emit("connection", ws, req);
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
socket.destroy();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async start() {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
this.httpServer.on("error", reject);
|
|
243
|
+
this.httpServer.listen(this.port, this.host, () => {
|
|
244
|
+
logger.info(`Hub server listening on http://${this.host}:${this.port}`);
|
|
245
|
+
resolve();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
stop() {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
for (const ws of this.channelSockets.values()) ws.close();
|
|
252
|
+
for (const ws of this.dashboardSockets) ws.close();
|
|
253
|
+
this.wssChannel.close();
|
|
254
|
+
this.wssDashboard.close();
|
|
255
|
+
this.httpServer.close(() => {
|
|
256
|
+
logger.info("Hub server stopped");
|
|
257
|
+
resolve();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// --- HTTP Handler ---
|
|
262
|
+
handleHttp(req, res) {
|
|
263
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
264
|
+
const origin = req.headers.origin;
|
|
265
|
+
if (origin && (origin.includes("127.0.0.1") || origin.includes("localhost"))) {
|
|
266
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
267
|
+
}
|
|
268
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
269
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
270
|
+
if (req.method === "OPTIONS") {
|
|
271
|
+
res.writeHead(204);
|
|
272
|
+
res.end();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (url.pathname !== "/" && this.token) {
|
|
276
|
+
if (!this.isLocalRequest(req)) {
|
|
277
|
+
const authHeader = req.headers["authorization"];
|
|
278
|
+
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
279
|
+
if (bearerToken !== this.token) {
|
|
280
|
+
this.jsonResponse(res, 401, { error: "Unauthorized" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
286
|
+
this.serveDashboard(res);
|
|
287
|
+
} else if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
288
|
+
this.jsonResponse(res, 200, { sessions: this.sessions.getAll() });
|
|
289
|
+
} else if (url.pathname === "/api/status" && req.method === "GET") {
|
|
290
|
+
this.jsonResponse(res, 200, {
|
|
291
|
+
running: true,
|
|
292
|
+
pid: process.pid,
|
|
293
|
+
port: this.port,
|
|
294
|
+
sessions: this.sessions.count(),
|
|
295
|
+
uptime: Date.now() - this.startTime
|
|
296
|
+
});
|
|
297
|
+
} else if (url.pathname === "/api/send" && req.method === "POST") {
|
|
298
|
+
this.handleApiSend(req, res);
|
|
299
|
+
} else if (url.pathname === "/api/notify" && req.method === "POST") {
|
|
300
|
+
this.handleApiNotify(req, res);
|
|
301
|
+
} else {
|
|
302
|
+
this.jsonResponse(res, 404, { error: "Not found" });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
serveDashboard(res) {
|
|
306
|
+
const candidates = [
|
|
307
|
+
path2.join(__dirname, "..", "dashboard", "index.html"),
|
|
308
|
+
// from dist/hub/
|
|
309
|
+
path2.join(__dirname, "..", "..", "src", "dashboard", "index.html"),
|
|
310
|
+
// from dist/hub/ -> src/
|
|
311
|
+
path2.join(process.cwd(), "dist", "dashboard", "index.html"),
|
|
312
|
+
// from cwd
|
|
313
|
+
path2.join(process.cwd(), "src", "dashboard", "index.html")
|
|
314
|
+
// from cwd/src
|
|
315
|
+
];
|
|
316
|
+
logger.debug(`Dashboard candidates: ${JSON.stringify(candidates)}`);
|
|
317
|
+
for (const candidate of candidates) {
|
|
318
|
+
if (fs.existsSync(candidate)) {
|
|
319
|
+
const html = fs.readFileSync(candidate, "utf-8");
|
|
320
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
321
|
+
res.end(html);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
326
|
+
res.end("<html><body><h1>claude-alarm</h1><p>Dashboard HTML not found. Reinstall the package.</p></body></html>");
|
|
327
|
+
}
|
|
328
|
+
async handleApiSend(req, res) {
|
|
329
|
+
const body = await this.readBody(req);
|
|
330
|
+
if (!body) {
|
|
331
|
+
this.jsonResponse(res, 400, { error: "Invalid JSON" });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const { sessionId, content } = body;
|
|
335
|
+
if (!sessionId || !content) {
|
|
336
|
+
this.jsonResponse(res, 400, { error: "sessionId and content are required" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const ws = this.channelSockets.get(sessionId);
|
|
340
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
341
|
+
this.jsonResponse(res, 404, { error: "Session not connected" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const msg = { type: "message_to_session", sessionId, content };
|
|
345
|
+
ws.send(JSON.stringify(msg));
|
|
346
|
+
this.jsonResponse(res, 200, { ok: true });
|
|
347
|
+
}
|
|
348
|
+
async handleApiNotify(req, res) {
|
|
349
|
+
const body = await this.readBody(req);
|
|
350
|
+
if (!body) {
|
|
351
|
+
this.jsonResponse(res, 400, { error: "Invalid JSON" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const { title, message, level } = body;
|
|
355
|
+
if (!title || !message) {
|
|
356
|
+
this.jsonResponse(res, 400, { error: "title and message are required" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
await this.notifier.notify(title, message, level ?? "info");
|
|
360
|
+
this.jsonResponse(res, 200, { ok: true });
|
|
361
|
+
}
|
|
362
|
+
// --- Channel WebSocket ---
|
|
363
|
+
handleChannelConnection(ws) {
|
|
364
|
+
logger.info("Channel server connected");
|
|
365
|
+
ws.on("message", (data) => {
|
|
366
|
+
try {
|
|
367
|
+
const msg = JSON.parse(data.toString());
|
|
368
|
+
this.handleChannelMessage(ws, msg);
|
|
369
|
+
} catch {
|
|
370
|
+
logger.warn("Invalid message from channel");
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
ws.on("close", () => {
|
|
374
|
+
for (const [sessionId, sock] of this.channelSockets) {
|
|
375
|
+
if (sock === ws) {
|
|
376
|
+
const session = this.sessions.unregister(sessionId);
|
|
377
|
+
this.channelSockets.delete(sessionId);
|
|
378
|
+
logger.info(`Channel disconnected: ${sessionId}`);
|
|
379
|
+
this.broadcastToDashboards({
|
|
380
|
+
type: "session_disconnected",
|
|
381
|
+
sessionId
|
|
382
|
+
});
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
handleChannelMessage(ws, msg) {
|
|
389
|
+
switch (msg.type) {
|
|
390
|
+
case "register": {
|
|
391
|
+
const session = msg.session;
|
|
392
|
+
this.sessions.register(session);
|
|
393
|
+
this.channelSockets.set(session.id, ws);
|
|
394
|
+
logger.info(`Session registered: ${session.id} (${session.name})`);
|
|
395
|
+
this.broadcastToDashboards({ type: "session_connected", session });
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case "status": {
|
|
399
|
+
const updated = this.sessions.updateStatus(msg.sessionId, msg.status);
|
|
400
|
+
if (updated) {
|
|
401
|
+
this.broadcastToDashboards({ type: "session_updated", session: updated });
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case "notify": {
|
|
406
|
+
this.sessions.updateActivity(msg.sessionId);
|
|
407
|
+
this.notifier.notify(msg.title, msg.message, msg.level ?? "info");
|
|
408
|
+
this.broadcastToDashboards({
|
|
409
|
+
type: "notification",
|
|
410
|
+
sessionId: msg.sessionId,
|
|
411
|
+
title: msg.title,
|
|
412
|
+
message: msg.message,
|
|
413
|
+
level: msg.level,
|
|
414
|
+
timestamp: Date.now()
|
|
415
|
+
});
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case "reply": {
|
|
419
|
+
this.sessions.updateActivity(msg.sessionId);
|
|
420
|
+
this.broadcastToDashboards({
|
|
421
|
+
type: "reply_from_session",
|
|
422
|
+
sessionId: msg.sessionId,
|
|
423
|
+
content: msg.content,
|
|
424
|
+
timestamp: Date.now()
|
|
425
|
+
});
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// --- Dashboard WebSocket ---
|
|
431
|
+
handleDashboardConnection(ws) {
|
|
432
|
+
this.dashboardSockets.add(ws);
|
|
433
|
+
logger.info(`Dashboard connected (total: ${this.dashboardSockets.size})`);
|
|
434
|
+
const sessionsMsg = {
|
|
435
|
+
type: "sessions_list",
|
|
436
|
+
sessions: this.sessions.getAll()
|
|
437
|
+
};
|
|
438
|
+
ws.send(JSON.stringify(sessionsMsg));
|
|
439
|
+
ws.on("message", (data) => {
|
|
440
|
+
try {
|
|
441
|
+
const msg = JSON.parse(data.toString());
|
|
442
|
+
if (msg.type === "message_to_session") {
|
|
443
|
+
const channelWs = this.channelSockets.get(msg.sessionId);
|
|
444
|
+
if (channelWs?.readyState === WebSocket.OPEN) {
|
|
445
|
+
channelWs.send(JSON.stringify(msg));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
logger.warn("Invalid message from dashboard");
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
ws.on("close", () => {
|
|
453
|
+
this.dashboardSockets.delete(ws);
|
|
454
|
+
logger.info(`Dashboard disconnected (total: ${this.dashboardSockets.size})`);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// --- Helpers ---
|
|
458
|
+
broadcastToDashboards(msg) {
|
|
459
|
+
const payload = JSON.stringify(msg);
|
|
460
|
+
for (const ws of this.dashboardSockets) {
|
|
461
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
462
|
+
ws.send(payload);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
jsonResponse(res, status, body) {
|
|
467
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
468
|
+
res.end(JSON.stringify(body));
|
|
469
|
+
}
|
|
470
|
+
isLocalRequest(req) {
|
|
471
|
+
const addr = req.socket.remoteAddress;
|
|
472
|
+
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
473
|
+
}
|
|
474
|
+
readBody(req, maxSize = 1024 * 1024) {
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
let data = "";
|
|
477
|
+
let size = 0;
|
|
478
|
+
req.on("data", (chunk) => {
|
|
479
|
+
size += chunk.length;
|
|
480
|
+
if (size > maxSize) {
|
|
481
|
+
req.destroy();
|
|
482
|
+
resolve(null);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
data += chunk;
|
|
486
|
+
});
|
|
487
|
+
req.on("end", () => {
|
|
488
|
+
try {
|
|
489
|
+
resolve(JSON.parse(data));
|
|
490
|
+
} catch {
|
|
491
|
+
resolve(null);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
if (process.argv[1] && (process.argv[1].endsWith("hub/server.js") || process.argv[1].endsWith("hub/server.ts"))) {
|
|
498
|
+
const hub = new HubServer();
|
|
499
|
+
hub.start().catch((err) => {
|
|
500
|
+
logger.error("Failed to start hub:", err);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
});
|
|
503
|
+
const shutdown = () => {
|
|
504
|
+
hub.stop().then(() => process.exit(0));
|
|
505
|
+
};
|
|
506
|
+
process.on("SIGINT", shutdown);
|
|
507
|
+
process.on("SIGTERM", shutdown);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/channel/hub-client.ts
|
|
511
|
+
import WebSocket2 from "ws";
|
|
512
|
+
var HubClient = class {
|
|
513
|
+
constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token) {
|
|
514
|
+
this.sessionId = sessionId;
|
|
515
|
+
this.sessionName = sessionName;
|
|
516
|
+
this.hubHost = hubHost;
|
|
517
|
+
this.hubPort = hubPort;
|
|
518
|
+
this.token = token;
|
|
519
|
+
}
|
|
520
|
+
ws = null;
|
|
521
|
+
reconnectTimer = null;
|
|
522
|
+
messageHandlers = [];
|
|
523
|
+
queue = [];
|
|
524
|
+
connected = false;
|
|
525
|
+
connect() {
|
|
526
|
+
const tokenQuery = this.token ? `?token=${encodeURIComponent(this.token)}` : "";
|
|
527
|
+
const url = `ws://${this.hubHost}:${this.hubPort}${WS_PATH_CHANNEL}${tokenQuery}`;
|
|
528
|
+
logger.debug(`Connecting to hub at ${url}`);
|
|
529
|
+
try {
|
|
530
|
+
this.ws = new WebSocket2(url);
|
|
531
|
+
this.ws.on("open", () => {
|
|
532
|
+
logger.info("Connected to hub");
|
|
533
|
+
this.connected = true;
|
|
534
|
+
const registration = {
|
|
535
|
+
type: "register",
|
|
536
|
+
session: {
|
|
537
|
+
id: this.sessionId,
|
|
538
|
+
name: this.sessionName,
|
|
539
|
+
status: "idle",
|
|
540
|
+
connectedAt: Date.now(),
|
|
541
|
+
lastActivity: Date.now(),
|
|
542
|
+
cwd: process.cwd()
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
this.ws.send(JSON.stringify(registration));
|
|
546
|
+
for (const msg of this.queue) {
|
|
547
|
+
this.ws.send(JSON.stringify(msg));
|
|
548
|
+
}
|
|
549
|
+
this.queue = [];
|
|
550
|
+
});
|
|
551
|
+
this.ws.on("message", (data) => {
|
|
552
|
+
try {
|
|
553
|
+
const msg = JSON.parse(data.toString());
|
|
554
|
+
for (const handler of this.messageHandlers) {
|
|
555
|
+
handler(msg);
|
|
556
|
+
}
|
|
557
|
+
} catch (err) {
|
|
558
|
+
logger.warn("Failed to parse hub message:", err);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
this.ws.on("close", () => {
|
|
562
|
+
logger.info("Disconnected from hub");
|
|
563
|
+
this.connected = false;
|
|
564
|
+
this.scheduleReconnect();
|
|
565
|
+
});
|
|
566
|
+
this.ws.on("error", (err) => {
|
|
567
|
+
logger.debug(`Hub connection error: ${err.message}`);
|
|
568
|
+
this.connected = false;
|
|
569
|
+
});
|
|
570
|
+
} catch {
|
|
571
|
+
logger.debug("Failed to connect to hub, will retry");
|
|
572
|
+
this.scheduleReconnect();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
send(msg) {
|
|
576
|
+
if (this.connected && this.ws?.readyState === WebSocket2.OPEN) {
|
|
577
|
+
this.ws.send(JSON.stringify(msg));
|
|
578
|
+
} else {
|
|
579
|
+
if (this.queue.length < 100) {
|
|
580
|
+
this.queue.push(msg);
|
|
581
|
+
}
|
|
582
|
+
logger.debug("Hub not connected, message queued");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
onMessage(handler) {
|
|
586
|
+
this.messageHandlers.push(handler);
|
|
587
|
+
}
|
|
588
|
+
disconnect() {
|
|
589
|
+
if (this.reconnectTimer) {
|
|
590
|
+
clearTimeout(this.reconnectTimer);
|
|
591
|
+
this.reconnectTimer = null;
|
|
592
|
+
}
|
|
593
|
+
if (this.ws) {
|
|
594
|
+
this.ws.close();
|
|
595
|
+
this.ws = null;
|
|
596
|
+
}
|
|
597
|
+
this.connected = false;
|
|
598
|
+
}
|
|
599
|
+
scheduleReconnect() {
|
|
600
|
+
if (this.reconnectTimer) return;
|
|
601
|
+
this.reconnectTimer = setTimeout(() => {
|
|
602
|
+
this.reconnectTimer = null;
|
|
603
|
+
this.connect();
|
|
604
|
+
}, 5e3);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/shared/config.ts
|
|
609
|
+
import fs2 from "fs";
|
|
610
|
+
import path3 from "path";
|
|
611
|
+
import { randomUUID } from "crypto";
|
|
612
|
+
var DEFAULT_CONFIG = {
|
|
613
|
+
hub: {
|
|
614
|
+
host: DEFAULT_HUB_HOST,
|
|
615
|
+
port: DEFAULT_HUB_PORT
|
|
616
|
+
},
|
|
617
|
+
notifications: {
|
|
618
|
+
desktop: true,
|
|
619
|
+
sound: true
|
|
620
|
+
},
|
|
621
|
+
webhooks: []
|
|
622
|
+
};
|
|
623
|
+
function ensureConfigDir() {
|
|
624
|
+
if (!fs2.existsSync(CONFIG_DIR)) {
|
|
625
|
+
fs2.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function loadConfig() {
|
|
629
|
+
ensureConfigDir();
|
|
630
|
+
let config;
|
|
631
|
+
if (!fs2.existsSync(CONFIG_FILE)) {
|
|
632
|
+
config = { ...DEFAULT_CONFIG, hub: { ...DEFAULT_CONFIG.hub } };
|
|
633
|
+
} else {
|
|
634
|
+
try {
|
|
635
|
+
const raw = fs2.readFileSync(CONFIG_FILE, "utf-8");
|
|
636
|
+
const parsed = JSON.parse(raw);
|
|
637
|
+
config = { ...DEFAULT_CONFIG, ...parsed, hub: { ...DEFAULT_CONFIG.hub, ...parsed.hub } };
|
|
638
|
+
} catch {
|
|
639
|
+
config = { ...DEFAULT_CONFIG, hub: { ...DEFAULT_CONFIG.hub } };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (!config.hub.token) {
|
|
643
|
+
config.hub.token = randomUUID();
|
|
644
|
+
saveConfig(config);
|
|
645
|
+
}
|
|
646
|
+
return config;
|
|
647
|
+
}
|
|
648
|
+
function saveConfig(config) {
|
|
649
|
+
ensureConfigDir();
|
|
650
|
+
fs2.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
|
|
651
|
+
}
|
|
652
|
+
function setupMcpConfig(targetDir) {
|
|
653
|
+
const dir = targetDir ?? process.cwd();
|
|
654
|
+
const mcpPath = path3.join(dir, ".mcp.json");
|
|
655
|
+
let mcpConfig = {};
|
|
656
|
+
if (fs2.existsSync(mcpPath)) {
|
|
657
|
+
try {
|
|
658
|
+
mcpConfig = JSON.parse(fs2.readFileSync(mcpPath, "utf-8"));
|
|
659
|
+
} catch {
|
|
660
|
+
mcpConfig = {};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!mcpConfig.mcpServers) {
|
|
664
|
+
mcpConfig.mcpServers = {};
|
|
665
|
+
}
|
|
666
|
+
mcpConfig.mcpServers["claude-alarm"] = {
|
|
667
|
+
command: "npx",
|
|
668
|
+
args: ["-y", "@delt/claude-alarm"],
|
|
669
|
+
env: {
|
|
670
|
+
CLAUDE_ALARM_SESSION_NAME: path3.basename(dir)
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
fs2.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
|
|
674
|
+
return mcpPath;
|
|
675
|
+
}
|
|
676
|
+
export {
|
|
677
|
+
CHANNEL_SERVER_NAME,
|
|
678
|
+
CHANNEL_SERVER_VERSION,
|
|
679
|
+
CONFIG_DIR,
|
|
680
|
+
CONFIG_FILE,
|
|
681
|
+
DEFAULT_HUB_HOST,
|
|
682
|
+
DEFAULT_HUB_PORT,
|
|
683
|
+
HubClient,
|
|
684
|
+
HubServer,
|
|
685
|
+
LOG_FILE,
|
|
686
|
+
Notifier,
|
|
687
|
+
PID_FILE,
|
|
688
|
+
SessionManager,
|
|
689
|
+
WS_PATH_CHANNEL,
|
|
690
|
+
WS_PATH_DASHBOARD,
|
|
691
|
+
loadConfig,
|
|
692
|
+
logger,
|
|
693
|
+
saveConfig,
|
|
694
|
+
setupMcpConfig
|
|
695
|
+
};
|
|
696
|
+
//# sourceMappingURL=index.js.map
|