sensorium-mcp 2.17.26 → 2.17.27
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/dashboard/routes/threads.d.ts.map +1 -1
- package/dist/dashboard/routes/threads.js +18 -5
- package/dist/dashboard/routes/threads.js.map +1 -1
- package/dist/data/memory/bootstrap.js +2 -2
- package/dist/data/memory/bootstrap.js.map +1 -1
- package/dist/data/memory/consolidation.d.ts.map +1 -1
- package/dist/data/memory/consolidation.js +75 -4
- package/dist/data/memory/consolidation.js.map +1 -1
- package/dist/data/memory/index.d.ts +1 -0
- package/dist/data/memory/index.d.ts.map +1 -1
- package/dist/data/memory/index.js +1 -0
- package/dist/data/memory/index.js.map +1 -1
- package/dist/data/memory/quality-scoring.d.ts +32 -0
- package/dist/data/memory/quality-scoring.d.ts.map +1 -0
- package/dist/data/memory/quality-scoring.js +182 -0
- package/dist/data/memory/quality-scoring.js.map +1 -0
- package/dist/data/memory/semantic.d.ts +12 -0
- package/dist/data/memory/semantic.d.ts.map +1 -1
- package/dist/data/memory/semantic.js +45 -2
- package/dist/data/memory/semantic.js.map +1 -1
- package/dist/data/memory/thread-registry.d.ts +7 -0
- package/dist/data/memory/thread-registry.d.ts.map +1 -1
- package/dist/data/memory/thread-registry.js +11 -1
- package/dist/data/memory/thread-registry.js.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/tools/defs/memory-defs.d.ts.map +1 -1
- package/dist/tools/defs/memory-defs.js +19 -0
- package/dist/tools/defs/memory-defs.js.map +1 -1
- package/dist/tools/memory-tools.d.ts.map +1 -1
- package/dist/tools/memory-tools.js +15 -0
- package/dist/tools/memory-tools.js.map +1 -1
- package/dist/tools/thread-lifecycle.d.ts.map +1 -1
- package/dist/tools/thread-lifecycle.js +31 -17
- package/dist/tools/thread-lifecycle.js.map +1 -1
- package/package.json +10 -2
- package/scripts/install-supervisor.ps1 +67 -0
- package/scripts/install-supervisor.sh +43 -0
- package/scripts/start-supervisor.ps1 +46 -0
- package/scripts/start-supervisor.sh +20 -0
- package/supervisor/config.go +140 -0
- package/supervisor/go.mod +3 -0
- package/supervisor/health.go +390 -0
- package/supervisor/health_test.go +93 -0
- package/supervisor/keeper.go +303 -0
- package/supervisor/keeper_test.go +27 -0
- package/supervisor/lock.go +56 -0
- package/supervisor/lock_test.go +54 -0
- package/supervisor/log.go +114 -0
- package/supervisor/log_test.go +45 -0
- package/supervisor/main.go +325 -0
- package/supervisor/notify.go +53 -0
- package/supervisor/process.go +222 -0
- package/supervisor/process_test.go +94 -0
- package/supervisor/process_unix.go +14 -0
- package/supervisor/process_windows.go +15 -0
- package/supervisor/updater.go +281 -0
- package/templates/coding-task.default.md +12 -0
- package/dist/claude-keeper.d.ts +0 -24
- package/dist/claude-keeper.d.ts.map +0 -1
- package/dist/claude-keeper.js +0 -374
- package/dist/claude-keeper.js.map +0 -1
- package/dist/watcher-service.d.ts +0 -2
- package/dist/watcher-service.d.ts.map +0 -1
- package/dist/watcher-service.js +0 -997
- package/dist/watcher-service.js.map +0 -1
package/dist/watcher-service.js
DELETED
|
@@ -1,997 +0,0 @@
|
|
|
1
|
-
/** Watcher Service — Node.js replacement for update-watcher.ps1. Run: npx sensorium-mcp --watcher */
|
|
2
|
-
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync, unlinkSync } from "node:fs";
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
|
-
import { homedir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
-
import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
-
import { startClaudeKeeper } from "./claude-keeper.js";
|
|
12
|
-
import { cleanupExpiredWorkers } from "./tools/thread-lifecycle.js";
|
|
13
|
-
import { initMemoryDb } from "./data/memory/schema.js";
|
|
14
|
-
import { errorMessage } from "./utils.js";
|
|
15
|
-
process.on("uncaughtException", (err) => {
|
|
16
|
-
console.error(`[fatal] Uncaught exception: ${err.stack ?? err}`);
|
|
17
|
-
process.exit(1);
|
|
18
|
-
});
|
|
19
|
-
process.on("unhandledRejection", (reason) => {
|
|
20
|
-
console.error(`[fatal] Unhandled rejection: ${reason instanceof Error ? reason.stack : reason}`);
|
|
21
|
-
process.exit(1);
|
|
22
|
-
});
|
|
23
|
-
// Configuration ---------------------------------------------------------------
|
|
24
|
-
const CONFIG = {
|
|
25
|
-
mode: process.env.WATCHER_MODE || "development",
|
|
26
|
-
pollAtHour: parseInt(process.env.WATCHER_POLL_HOUR || "4", 10),
|
|
27
|
-
pollIntervalSeconds: parseInt(process.env.WATCHER_POLL_INTERVAL || "60", 10),
|
|
28
|
-
gracePeriodSeconds: parseInt(process.env.WATCHER_GRACE_PERIOD || ((process.env.WATCHER_MODE || "development") === "development" ? "10" : "300"), 10),
|
|
29
|
-
idleThresholdSeconds: 300, maxIdleWaitSeconds: 300,
|
|
30
|
-
minUptimeSeconds: 600,
|
|
31
|
-
httpPort: parseInt(process.env.WATCHER_PORT || "3848", 10),
|
|
32
|
-
mcpStartCommand: process.env.MCP_START_COMMAND || "npx -y sensorium-mcp@latest",
|
|
33
|
-
dataDir: join(homedir(), ".remote-copilot-mcp"),
|
|
34
|
-
// Always-on CLI keeper
|
|
35
|
-
mcpHttpPort: parseInt(process.env.MCP_HTTP_PORT || "0", 10),
|
|
36
|
-
mcpHttpSecret: process.env.MCP_HTTP_SECRET || null,
|
|
37
|
-
};
|
|
38
|
-
const P = {
|
|
39
|
-
flag: join(CONFIG.dataDir, "maintenance.flag"), ver: join(CONFIG.dataDir, "current-version.txt"),
|
|
40
|
-
activity: join(CONFIG.dataDir, "last-activity.txt"), pid: join(CONFIG.dataDir, "server.pid"),
|
|
41
|
-
lock: join(CONFIG.dataDir, "watcher.lock"),
|
|
42
|
-
};
|
|
43
|
-
let startTime = Date.now();
|
|
44
|
-
let managedChild = null;
|
|
45
|
-
let httpSrv = null;
|
|
46
|
-
let updateInProgress = false;
|
|
47
|
-
let consecutiveHealthFailures = 0;
|
|
48
|
-
const HEALTH_FAIL_THRESHOLD = 3;
|
|
49
|
-
const keepers = new Map();
|
|
50
|
-
let keeperPollerHandle = null;
|
|
51
|
-
let sessionSweeperHandle = null;
|
|
52
|
-
let workerCleanupHandle = null;
|
|
53
|
-
// Helpers ---------------------------------------------------------------------
|
|
54
|
-
let _logFd = null;
|
|
55
|
-
function getLogFd() {
|
|
56
|
-
if (_logFd !== null)
|
|
57
|
-
return _logFd;
|
|
58
|
-
try {
|
|
59
|
-
ensureDir();
|
|
60
|
-
const logPath = join(CONFIG.dataDir, "watcher.log");
|
|
61
|
-
_logFd = openSync(logPath, "a");
|
|
62
|
-
return _logFd;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function log(level, msg) {
|
|
69
|
-
const text = msg instanceof Error ? `${msg.message}\n${msg.stack}` : String(msg);
|
|
70
|
-
const line = `[${new Date().toISOString().replace("T", " ").slice(0, 19)}] [${level}] ${text}`;
|
|
71
|
-
console.log(line);
|
|
72
|
-
try {
|
|
73
|
-
const fd = getLogFd();
|
|
74
|
-
if (fd !== null)
|
|
75
|
-
writeSync(fd, line + "\n");
|
|
76
|
-
}
|
|
77
|
-
catch { /* best-effort file logging */ }
|
|
78
|
-
}
|
|
79
|
-
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
80
|
-
function ensureDir() { if (!existsSync(CONFIG.dataDir))
|
|
81
|
-
mkdirSync(CONFIG.dataDir, { recursive: true }); }
|
|
82
|
-
function uptimeS() { return (Date.now() - startTime) / 1000; }
|
|
83
|
-
// ── Lightweight Telegram notification (no dependency on telegram.ts) ─────────
|
|
84
|
-
const TG_TOKEN = process.env.TELEGRAM_TOKEN ?? "";
|
|
85
|
-
const TG_CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
|
86
|
-
/**
|
|
87
|
-
* Send a Telegram message from the watcher process.
|
|
88
|
-
* Uses raw fetch against the Bot API — does not depend on the main server
|
|
89
|
-
* or any agent connections. If token/chatId are missing, silently no-ops.
|
|
90
|
-
*/
|
|
91
|
-
async function notifyOperator(text, threadId) {
|
|
92
|
-
if (!TG_TOKEN || !TG_CHAT_ID)
|
|
93
|
-
return;
|
|
94
|
-
try {
|
|
95
|
-
let resolvedThreadId = threadId;
|
|
96
|
-
if (threadId) {
|
|
97
|
-
try {
|
|
98
|
-
const { resolveTelegramTopicId } = await import("./data/memory/thread-registry.js");
|
|
99
|
-
const { initMemoryDb } = await import("./memory.js");
|
|
100
|
-
resolvedThreadId = resolveTelegramTopicId(initMemoryDb(), threadId);
|
|
101
|
-
}
|
|
102
|
-
catch { /* use original threadId as fallback */ }
|
|
103
|
-
}
|
|
104
|
-
const body = { chat_id: TG_CHAT_ID, text, parse_mode: "HTML" };
|
|
105
|
-
if (resolvedThreadId)
|
|
106
|
-
body.message_thread_id = resolvedThreadId;
|
|
107
|
-
await fetch(`https://api.telegram.org/bot${TG_TOKEN}/sendMessage`, {
|
|
108
|
-
method: "POST",
|
|
109
|
-
headers: { "Content-Type": "application/json" },
|
|
110
|
-
body: JSON.stringify(body),
|
|
111
|
-
signal: AbortSignal.timeout(10_000),
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
catch (err) {
|
|
115
|
-
log("WARN", `Telegram notify failed: ${errorMessage(err)}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function msUntilHour(h) {
|
|
119
|
-
const now = new Date(), t = new Date(now);
|
|
120
|
-
t.setHours(h, 0, 0, 0);
|
|
121
|
-
if (t.getTime() <= now.getTime())
|
|
122
|
-
t.setDate(t.getDate() + 1);
|
|
123
|
-
return t.getTime() - now.getTime();
|
|
124
|
-
}
|
|
125
|
-
function safeRead(p) {
|
|
126
|
-
try {
|
|
127
|
-
return existsSync(p) ? readFileSync(p, "utf-8").trim() || null : null;
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
function alive(pid) {
|
|
134
|
-
if (process.platform === "win32") {
|
|
135
|
-
try {
|
|
136
|
-
const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { encoding: "utf-8", timeout: 5000, windowsHide: true });
|
|
137
|
-
return result.status === 0 && new RegExp(`\\b${pid}\\b`).test(result.stdout);
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
process.kill(pid, 0);
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/** Kill a single process without affecting its children. */
|
|
152
|
-
async function killPidOnly(pid) {
|
|
153
|
-
if (process.platform === "win32") {
|
|
154
|
-
spawnSync("taskkill", ["/F", "/PID", String(pid)], { timeout: 10000, windowsHide: true });
|
|
155
|
-
await sleep(2000);
|
|
156
|
-
if (alive(pid)) {
|
|
157
|
-
spawnSync("taskkill", ["/F", "/PID", String(pid)], { timeout: 10000, windowsHide: true });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
try {
|
|
162
|
-
process.kill(pid, "SIGTERM");
|
|
163
|
-
}
|
|
164
|
-
catch { /**/ }
|
|
165
|
-
await sleep(2000);
|
|
166
|
-
if (alive(pid)) {
|
|
167
|
-
try {
|
|
168
|
-
process.kill(pid, "SIGKILL");
|
|
169
|
-
}
|
|
170
|
-
catch { /**/ }
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
/** Kill a process and its entire process tree. */
|
|
175
|
-
async function killPidTree(pid) {
|
|
176
|
-
if (process.platform === "win32") {
|
|
177
|
-
// spawnSync instead of execSync: taskkill returns non-zero if ANY child
|
|
178
|
-
// can't be killed, causing execSync to throw even on partial success.
|
|
179
|
-
spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { timeout: 10000, windowsHide: true });
|
|
180
|
-
await sleep(3000);
|
|
181
|
-
if (alive(pid)) {
|
|
182
|
-
spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { timeout: 10000, windowsHide: true });
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
try {
|
|
187
|
-
process.kill(pid, "SIGTERM");
|
|
188
|
-
}
|
|
189
|
-
catch { /**/ }
|
|
190
|
-
await sleep(2000);
|
|
191
|
-
if (alive(pid)) {
|
|
192
|
-
try {
|
|
193
|
-
process.kill(pid, "SIGKILL");
|
|
194
|
-
}
|
|
195
|
-
catch { /**/ }
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function atomicWrite(path, data) {
|
|
200
|
-
const tmp = path + ".tmp";
|
|
201
|
-
writeFileSync(tmp, data, "utf-8");
|
|
202
|
-
renameSync(tmp, path);
|
|
203
|
-
}
|
|
204
|
-
// Version & flag management ---------------------------------------------------
|
|
205
|
-
function getLocalVersion() { return safeRead(P.ver); }
|
|
206
|
-
function setLocalVersion(v) { ensureDir(); atomicWrite(P.ver, v); }
|
|
207
|
-
function writeMaintenanceFlag(v) {
|
|
208
|
-
ensureDir();
|
|
209
|
-
atomicWrite(P.flag, JSON.stringify({ version: v, timestamp: new Date().toISOString() }));
|
|
210
|
-
log("INFO", `Maintenance flag written for v${v}`);
|
|
211
|
-
}
|
|
212
|
-
function removeMaintenanceFlag() {
|
|
213
|
-
try {
|
|
214
|
-
if (existsSync(P.flag)) {
|
|
215
|
-
unlinkSync(P.flag);
|
|
216
|
-
log("INFO", "Maintenance flag removed.");
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
catch { /**/ }
|
|
220
|
-
}
|
|
221
|
-
function flagExists() { return existsSync(P.flag); }
|
|
222
|
-
// Activity heartbeat ----------------------------------------------------------
|
|
223
|
-
function activityAgeSec() {
|
|
224
|
-
const raw = safeRead(P.activity);
|
|
225
|
-
if (!raw)
|
|
226
|
-
return null;
|
|
227
|
-
const e = parseInt(raw, 10);
|
|
228
|
-
return Number.isNaN(e) ? null : (Date.now() - e) / 1000;
|
|
229
|
-
}
|
|
230
|
-
// Process management (PID file) -----------------------------------------------
|
|
231
|
-
function readPid() {
|
|
232
|
-
const raw = safeRead(P.pid);
|
|
233
|
-
if (!raw)
|
|
234
|
-
return null;
|
|
235
|
-
const n = parseInt(raw, 10);
|
|
236
|
-
return Number.isNaN(n) ? null : n;
|
|
237
|
-
}
|
|
238
|
-
function writePid(pid) { ensureDir(); atomicWrite(P.pid, String(pid)); }
|
|
239
|
-
function rmPid() { try {
|
|
240
|
-
if (existsSync(P.pid))
|
|
241
|
-
unlinkSync(P.pid);
|
|
242
|
-
}
|
|
243
|
-
catch { /**/ } }
|
|
244
|
-
function startMcpServer() {
|
|
245
|
-
const parts = CONFIG.mcpStartCommand.split(/\s+/);
|
|
246
|
-
const cmd = parts[0];
|
|
247
|
-
const args = parts.slice(1);
|
|
248
|
-
log("INFO", `Starting MCP server: ${CONFIG.mcpStartCommand}`);
|
|
249
|
-
try {
|
|
250
|
-
const child = spawn(cmd, args, { stdio: "ignore", windowsHide: true, shell: true });
|
|
251
|
-
child.on("error", (err) => log("ERROR", `MCP server spawn error: ${err.message}`));
|
|
252
|
-
if (child.pid) {
|
|
253
|
-
writePid(child.pid);
|
|
254
|
-
managedChild = child;
|
|
255
|
-
child.unref();
|
|
256
|
-
log("INFO", `MCP server started (PID ${child.pid})`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
log("ERROR", `Failed to start MCP server: ${err}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
async function stopMcpServer() {
|
|
264
|
-
const pid = readPid();
|
|
265
|
-
if (pid && alive(pid)) {
|
|
266
|
-
log("INFO", `Stopping MCP server (PID ${pid})...`);
|
|
267
|
-
await killPidTree(pid);
|
|
268
|
-
}
|
|
269
|
-
rmPid();
|
|
270
|
-
managedChild = null;
|
|
271
|
-
// Fallback: kill whatever is listening on the MCP port.
|
|
272
|
-
// The PID file tracks the securevault wrapper, but the inner node.js process
|
|
273
|
-
// may survive if the wrapper exits first (detached process on Windows).
|
|
274
|
-
if (process.platform === "win32") {
|
|
275
|
-
try {
|
|
276
|
-
const port = CONFIG.mcpHttpPort || 3847;
|
|
277
|
-
const netstat = spawnSync("cmd", ["/c", `netstat -aon | findstr ":${port}.*LISTENING"`], { timeout: 5000, encoding: "utf8", windowsHide: true });
|
|
278
|
-
const m = (netstat.stdout || "").match(/\s(\d+)\s*$/m);
|
|
279
|
-
if (m) {
|
|
280
|
-
const orphanPid = parseInt(m[1], 10);
|
|
281
|
-
if (Number.isFinite(orphanPid) && orphanPid > 0) {
|
|
282
|
-
log("INFO", `Killing orphan process PID=${orphanPid} still on port ${port}`);
|
|
283
|
-
spawnSync("taskkill", ["/F", "/T", "/PID", String(orphanPid)], { timeout: 10000, windowsHide: true });
|
|
284
|
-
await sleep(2000);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
catch { /* no process on port — good */ }
|
|
289
|
-
}
|
|
290
|
-
log("INFO", `MCP server stopped.`);
|
|
291
|
-
}
|
|
292
|
-
// Server readiness check ------------------------------------------------------
|
|
293
|
-
/** Poll the MCP HTTP server until it responds or 60s timeout. */
|
|
294
|
-
async function waitForServerReady() {
|
|
295
|
-
const port = CONFIG.mcpHttpPort || 3847;
|
|
296
|
-
const deadline = Date.now() + 60_000;
|
|
297
|
-
while (Date.now() < deadline) {
|
|
298
|
-
try {
|
|
299
|
-
const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "OPTIONS", signal: AbortSignal.timeout(2000) });
|
|
300
|
-
if (res.status < 500) {
|
|
301
|
-
log("INFO", "MCP server ready (HTTP responding).");
|
|
302
|
-
// On Windows with shell:true, the stored PID may be a transient cmd.exe.
|
|
303
|
-
// Update the PID file with the actual node process listening on the port.
|
|
304
|
-
if (process.platform === "win32") {
|
|
305
|
-
try {
|
|
306
|
-
const netstat = execSync(`netstat -aon | findstr ":${port}.*LISTENING"`, { timeout: 5000, encoding: "utf8" });
|
|
307
|
-
const m = netstat.match(/\s(\d+)\s*$/m);
|
|
308
|
-
if (m) {
|
|
309
|
-
const realPid = parseInt(m[1], 10);
|
|
310
|
-
const storedPid = readPid();
|
|
311
|
-
if (realPid !== storedPid) {
|
|
312
|
-
writePid(realPid);
|
|
313
|
-
log("INFO", `Updated server PID: ${storedPid} → ${realPid}`);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
catch { /**/ }
|
|
318
|
-
}
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
catch { /* server not up yet */ }
|
|
323
|
-
await sleep(2000);
|
|
324
|
-
}
|
|
325
|
-
log("WARN", "MCP server did not respond within 60s — proceeding anyway.");
|
|
326
|
-
}
|
|
327
|
-
/** Single-shot HTTP liveness probe. Returns true if MCP server is responding. */
|
|
328
|
-
async function isMcpServerHealthy() {
|
|
329
|
-
const port = CONFIG.mcpHttpPort || 3847;
|
|
330
|
-
try {
|
|
331
|
-
const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "OPTIONS", signal: AbortSignal.timeout(3000) });
|
|
332
|
-
return res.status < 500;
|
|
333
|
-
}
|
|
334
|
-
catch {
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
// npx cache clearing ----------------------------------------------------------
|
|
339
|
-
function clearNpxCache() {
|
|
340
|
-
const base = process.platform === "win32"
|
|
341
|
-
? join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "npm-cache", "_npx")
|
|
342
|
-
: join(homedir(), ".npm", "_npx");
|
|
343
|
-
if (!existsSync(base))
|
|
344
|
-
return;
|
|
345
|
-
log("INFO", "Clearing sensorium-mcp from npx cache...");
|
|
346
|
-
try {
|
|
347
|
-
for (const e of readdirSync(base)) {
|
|
348
|
-
const pkgDir = join(base, e, "node_modules", "sensorium-mcp");
|
|
349
|
-
if (existsSync(pkgDir))
|
|
350
|
-
rmSync(pkgDir, { recursive: true, force: true });
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
catch (err) {
|
|
354
|
-
log("WARN", `Cache clear error: ${err}`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
// Stale process cleanup -------------------------------------------------------
|
|
358
|
-
async function killStale() {
|
|
359
|
-
const pid = readPid();
|
|
360
|
-
if (!pid)
|
|
361
|
-
return;
|
|
362
|
-
if (!alive(pid)) {
|
|
363
|
-
rmPid();
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
try {
|
|
367
|
-
if (!existsSync(P.ver) || !existsSync(P.pid))
|
|
368
|
-
return;
|
|
369
|
-
if (statSync(P.pid).mtimeMs < statSync(P.ver).mtimeMs - 60_000) {
|
|
370
|
-
log("WARN", `Killing stale PID ${pid}`);
|
|
371
|
-
await killPidTree(pid);
|
|
372
|
-
rmPid();
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
catch { /**/ }
|
|
376
|
-
}
|
|
377
|
-
// Registry check --------------------------------------------------------------
|
|
378
|
-
const REGISTRY_URL = "https://registry.npmjs.org/sensorium-mcp/latest";
|
|
379
|
-
// Ghost thread re-spawn helpers -----------------------------------------------
|
|
380
|
-
/**
|
|
381
|
-
* Parse a single PID file and return pid + name, or null if unparseable.
|
|
382
|
-
*/
|
|
383
|
-
function parsePidFile(filePath) {
|
|
384
|
-
try {
|
|
385
|
-
const raw = readFileSync(filePath, "utf-8").trim();
|
|
386
|
-
try {
|
|
387
|
-
const meta = JSON.parse(raw);
|
|
388
|
-
return Number.isFinite(meta.pid) ? meta : null;
|
|
389
|
-
}
|
|
390
|
-
catch {
|
|
391
|
-
const pid = Number(raw);
|
|
392
|
-
return Number.isFinite(pid) ? { pid } : null;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
return null;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* Clean up PID files for dead processes.
|
|
401
|
-
*/
|
|
402
|
-
function readGhostThreads() {
|
|
403
|
-
const pidsDir = join(CONFIG.dataDir, "pids");
|
|
404
|
-
try {
|
|
405
|
-
for (const file of readdirSync(pidsDir)) {
|
|
406
|
-
if (!file.endsWith(".pid"))
|
|
407
|
-
continue;
|
|
408
|
-
const fullPath = join(pidsDir, file);
|
|
409
|
-
const parsed = parsePidFile(fullPath);
|
|
410
|
-
if (!parsed || !alive(parsed.pid)) {
|
|
411
|
-
try {
|
|
412
|
-
unlinkSync(fullPath);
|
|
413
|
-
}
|
|
414
|
-
catch { /* ignore */ }
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
catch { /* pids dir may not exist */ }
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Kill all agent processes tracked in PID files and remove the files.
|
|
422
|
-
* Called from the watcher (which runs outside securevault) because
|
|
423
|
-
* the server's own execSync("taskkill") may fail within the securevault sandbox.
|
|
424
|
-
*/
|
|
425
|
-
/** Clean up stale agent PID files. Agents are non-detached children of the
|
|
426
|
-
* server, so stopMcpServer() kills them via taskkill /F /T on the server PID.
|
|
427
|
-
* This just removes leftover PID files. */
|
|
428
|
-
async function cleanAgentPidFiles() {
|
|
429
|
-
const pidsDir = join(CONFIG.dataDir, "pids");
|
|
430
|
-
try {
|
|
431
|
-
for (const file of readdirSync(pidsDir)) {
|
|
432
|
-
if (!file.endsWith(".pid"))
|
|
433
|
-
continue;
|
|
434
|
-
const fullPath = join(pidsDir, file);
|
|
435
|
-
const parsed = parsePidFile(fullPath);
|
|
436
|
-
if (!parsed) {
|
|
437
|
-
try {
|
|
438
|
-
unlinkSync(fullPath);
|
|
439
|
-
}
|
|
440
|
-
catch { /**/ }
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
if (!alive(parsed.pid)) {
|
|
444
|
-
try {
|
|
445
|
-
unlinkSync(fullPath);
|
|
446
|
-
}
|
|
447
|
-
catch { /**/ }
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
catch { /* pids dir may not exist */ }
|
|
452
|
-
}
|
|
453
|
-
async function getRemoteVersion() {
|
|
454
|
-
try {
|
|
455
|
-
const r = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
|
|
456
|
-
if (!r.ok) {
|
|
457
|
-
log("WARN", `Registry returned HTTP ${r.status}`);
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
return (await r.json()).version ?? null;
|
|
461
|
-
}
|
|
462
|
-
catch (err) {
|
|
463
|
-
log("ERROR", `Registry check failed: ${err}`);
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
// Update orchestration --------------------------------------------------------
|
|
468
|
-
async function checkAndUpdate() {
|
|
469
|
-
if (updateInProgress) {
|
|
470
|
-
log("INFO", "Update already in progress, skipping");
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
updateInProgress = true;
|
|
474
|
-
try {
|
|
475
|
-
if (uptimeS() > 120) {
|
|
476
|
-
// Use HTTP health probe instead of PID liveness — on Windows, shell: true
|
|
477
|
-
// gives back the transient shell PID, not the real MCP server process.
|
|
478
|
-
// The shell exits quickly, making alive(pid) return false even when the
|
|
479
|
-
// server is healthy, causing a false-restart loop.
|
|
480
|
-
if (!(await isMcpServerHealthy())) {
|
|
481
|
-
consecutiveHealthFailures++;
|
|
482
|
-
if (consecutiveHealthFailures >= HEALTH_FAIL_THRESHOLD) {
|
|
483
|
-
log("WARN", `Server not running (${consecutiveHealthFailures} consecutive failures) — restarting...`);
|
|
484
|
-
await notifyOperator("\u26A0\uFE0F Watcher: server process not running — restarting...");
|
|
485
|
-
startMcpServer();
|
|
486
|
-
startTime = Date.now();
|
|
487
|
-
consecutiveHealthFailures = 0;
|
|
488
|
-
}
|
|
489
|
-
else {
|
|
490
|
-
log("INFO", `Health check failed (${consecutiveHealthFailures}/${HEALTH_FAIL_THRESHOLD}), waiting...`);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
else {
|
|
494
|
-
consecutiveHealthFailures = 0;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const remote = await getRemoteVersion();
|
|
498
|
-
if (!remote)
|
|
499
|
-
return;
|
|
500
|
-
const local = getLocalVersion();
|
|
501
|
-
if (!local) {
|
|
502
|
-
setLocalVersion(remote);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
if (remote === local) {
|
|
506
|
-
log("INFO", `Up to date: v${local}`);
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
if (uptimeS() < CONFIG.minUptimeSeconds) {
|
|
510
|
-
log("INFO", "Deferring update — too early.");
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
log("INFO", `Update: v${local} → v${remote}`);
|
|
514
|
-
await notifyOperator(`\u2699\uFE0F Watcher: updating sensorium v${local} \u2192 v${remote}. Grace period ${CONFIG.gracePeriodSeconds}s...`);
|
|
515
|
-
writeMaintenanceFlag(remote);
|
|
516
|
-
log("INFO", `Grace period ${CONFIG.gracePeriodSeconds}s...`);
|
|
517
|
-
await sleep(CONFIG.gracePeriodSeconds * 1000);
|
|
518
|
-
// Clean up stale PID files before killing the server
|
|
519
|
-
readGhostThreads();
|
|
520
|
-
await stopMcpServer();
|
|
521
|
-
await cleanAgentPidFiles();
|
|
522
|
-
clearNpxCache();
|
|
523
|
-
setLocalVersion(remote);
|
|
524
|
-
startMcpServer();
|
|
525
|
-
startTime = Date.now();
|
|
526
|
-
await waitForServerReady();
|
|
527
|
-
await killStale();
|
|
528
|
-
removeMaintenanceFlag();
|
|
529
|
-
// The new server spawns keepAlive threads on startup.
|
|
530
|
-
// Keepers detect crash-recovery needs via their regular poll cycle.
|
|
531
|
-
await notifyOperator(`\u2705 Watcher: update to v${remote} complete. Server ready.`);
|
|
532
|
-
log("INFO", `Update to v${remote} complete.`);
|
|
533
|
-
}
|
|
534
|
-
catch (err) {
|
|
535
|
-
log("ERROR", `Update failed: ${err}`);
|
|
536
|
-
removeMaintenanceFlag();
|
|
537
|
-
return; // Don't self-restart on failure
|
|
538
|
-
}
|
|
539
|
-
finally {
|
|
540
|
-
updateInProgress = false;
|
|
541
|
-
}
|
|
542
|
-
// Self-restart OUTSIDE try/finally so updateInProgress is always reset.
|
|
543
|
-
// selfRestart() calls process.exit() and never returns.
|
|
544
|
-
selfRestart();
|
|
545
|
-
}
|
|
546
|
-
// Claude CLI keeper -----------------------------------------------------------
|
|
547
|
-
/** Spawn a new watcher process with fresh code and exit. */
|
|
548
|
-
function selfRestart() {
|
|
549
|
-
log("INFO", "Self-restarting watcher to load new code...");
|
|
550
|
-
// Clean up keepers, timers, HTTP server
|
|
551
|
-
if (keeperPollerHandle)
|
|
552
|
-
clearInterval(keeperPollerHandle);
|
|
553
|
-
if (sessionSweeperHandle)
|
|
554
|
-
clearInterval(sessionSweeperHandle);
|
|
555
|
-
if (workerCleanupHandle)
|
|
556
|
-
clearInterval(workerCleanupHandle);
|
|
557
|
-
for (const [, entry] of keepers) {
|
|
558
|
-
void entry.handle.stop();
|
|
559
|
-
}
|
|
560
|
-
keepers.clear();
|
|
561
|
-
httpSrv?.close();
|
|
562
|
-
releaseLock();
|
|
563
|
-
// Re-run the same command that started this watcher.
|
|
564
|
-
// Use WATCHER_START_COMMAND env var if set, otherwise default.
|
|
565
|
-
// Redirect stdout/stderr to the watcher log file.
|
|
566
|
-
const logPath = join(CONFIG.dataDir, "watcher.log");
|
|
567
|
-
const logFd = openSync(logPath, "a");
|
|
568
|
-
const startCmd = process.env.WATCHER_START_COMMAND
|
|
569
|
-
|| "securevault run npx -y sensorium-mcp@latest --watcher --prefer-online --profile SENSORIUM";
|
|
570
|
-
log("INFO", `Restart command: ${startCmd}`);
|
|
571
|
-
const child = spawn(startCmd, [], {
|
|
572
|
-
detached: true,
|
|
573
|
-
stdio: ["ignore", logFd, logFd],
|
|
574
|
-
shell: true,
|
|
575
|
-
windowsHide: true,
|
|
576
|
-
});
|
|
577
|
-
child.unref();
|
|
578
|
-
log("INFO", `New watcher spawned (PID ${child.pid}). Exiting old watcher.`);
|
|
579
|
-
process.exit(0);
|
|
580
|
-
}
|
|
581
|
-
const KEEPER_SETTINGS_POLL_MS = 2 * 60_000;
|
|
582
|
-
/**
|
|
583
|
-
* Read keeper settings from the thread_registry via the HTTP API.
|
|
584
|
-
* Returns empty array if the server is not ready (keeper won't start until server is up).
|
|
585
|
-
*/
|
|
586
|
-
async function readAllKeeperSettings() {
|
|
587
|
-
const port = CONFIG.mcpHttpPort || 3847;
|
|
588
|
-
try {
|
|
589
|
-
const res = await fetch(`http://127.0.0.1:${port}/api/threads`, {
|
|
590
|
-
headers: CONFIG.mcpHttpSecret ? { 'Authorization': `Bearer ${CONFIG.mcpHttpSecret}` } : {},
|
|
591
|
-
signal: AbortSignal.timeout(5_000),
|
|
592
|
-
});
|
|
593
|
-
if (!res.ok)
|
|
594
|
-
return null;
|
|
595
|
-
const body = (await res.json());
|
|
596
|
-
const all = body.threads ?? [];
|
|
597
|
-
return all
|
|
598
|
-
.filter((r) => r.keepAlive)
|
|
599
|
-
.filter((r) => Number.isInteger(r.threadId) && r.threadId > 0)
|
|
600
|
-
.map((r) => ({
|
|
601
|
-
enabled: true,
|
|
602
|
-
threadId: r.threadId,
|
|
603
|
-
maxRetries: (typeof r.maxRetries === 'number' ? r.maxRetries : null) ?? 5,
|
|
604
|
-
cooldownMs: (typeof r.cooldownMs === 'number' ? r.cooldownMs : null) ?? 300_000,
|
|
605
|
-
client: typeof r.client === 'string' ? r.client : 'claude',
|
|
606
|
-
sessionName: (typeof r.name === 'string' ? r.name : null) ?? `thread-${r.threadId}`,
|
|
607
|
-
workingDirectory: typeof r.workingDirectory === 'string' ? r.workingDirectory : undefined,
|
|
608
|
-
}));
|
|
609
|
-
}
|
|
610
|
-
catch {
|
|
611
|
-
return null;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
function keeperSettingsChanged(a, b) {
|
|
615
|
-
return a.maxRetries !== b.maxRetries
|
|
616
|
-
|| a.cooldownMs !== b.cooldownMs
|
|
617
|
-
|| a.client !== b.client
|
|
618
|
-
|| a.sessionName !== b.sessionName
|
|
619
|
-
|| a.workingDirectory !== b.workingDirectory;
|
|
620
|
-
}
|
|
621
|
-
let applyingSettings = false;
|
|
622
|
-
async function applyKeeperSettings() {
|
|
623
|
-
if (applyingSettings)
|
|
624
|
-
return;
|
|
625
|
-
applyingSettings = true;
|
|
626
|
-
try {
|
|
627
|
-
if (CONFIG.mcpHttpPort <= 0)
|
|
628
|
-
return;
|
|
629
|
-
const allSettings = await readAllKeeperSettings();
|
|
630
|
-
if (allSettings === null) {
|
|
631
|
-
log("WARN", "Keeper settings unavailable — preserving existing keepers.");
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
const desiredThreadIds = new Set(allSettings.map(s => s.threadId));
|
|
635
|
-
// Stop keepers for threads that are no longer configured
|
|
636
|
-
for (const [tid, entry] of keepers) {
|
|
637
|
-
if (!desiredThreadIds.has(tid)) {
|
|
638
|
-
log("INFO", `Keep-alive disabled for thread ${tid} — stopping keeper.`);
|
|
639
|
-
await entry.handle.stop();
|
|
640
|
-
keepers.delete(tid);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
// Start or restart keepers
|
|
644
|
-
for (const settings of allSettings) {
|
|
645
|
-
const existing = keepers.get(settings.threadId);
|
|
646
|
-
// Restart if settings changed
|
|
647
|
-
if (existing && keeperSettingsChanged(existing.settings, settings)) {
|
|
648
|
-
log("INFO", `Keep-alive settings changed for thread ${settings.threadId} — restarting keeper.`);
|
|
649
|
-
await existing.handle.stop();
|
|
650
|
-
keepers.delete(settings.threadId);
|
|
651
|
-
}
|
|
652
|
-
// Start if not running
|
|
653
|
-
if (!keepers.has(settings.threadId)) {
|
|
654
|
-
try {
|
|
655
|
-
const handle = await startClaudeKeeper({
|
|
656
|
-
threadId: settings.threadId,
|
|
657
|
-
sessionName: settings.sessionName,
|
|
658
|
-
client: settings.client,
|
|
659
|
-
mcpHttpPort: CONFIG.mcpHttpPort,
|
|
660
|
-
mcpHttpSecret: CONFIG.mcpHttpSecret,
|
|
661
|
-
workingDirectory: settings.workingDirectory,
|
|
662
|
-
maxRetries: settings.maxRetries,
|
|
663
|
-
cooldownMs: settings.cooldownMs,
|
|
664
|
-
onDeath: (tid, name) => {
|
|
665
|
-
void notifyOperator(`💀 <b>${name}</b> session died — restarting…`, tid);
|
|
666
|
-
},
|
|
667
|
-
});
|
|
668
|
-
keepers.set(settings.threadId, { handle, settings });
|
|
669
|
-
}
|
|
670
|
-
catch (err) {
|
|
671
|
-
log("ERROR", `Failed to start keeper for thread ${settings.threadId}: ${errorMessage(err)}`);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
finally {
|
|
677
|
-
applyingSettings = false;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
function startKeeperPoller() {
|
|
681
|
-
keeperPollerHandle = setInterval(() => {
|
|
682
|
-
void applyKeeperSettings().catch((err) => log("ERROR", `Keeper settings poll failed: ${err}`));
|
|
683
|
-
}, KEEPER_SETTINGS_POLL_MS);
|
|
684
|
-
}
|
|
685
|
-
// Main loop -------------------------------------------------------------------
|
|
686
|
-
async function runLoop() {
|
|
687
|
-
log("INFO", `Watcher starting in ${CONFIG.mode} mode.`);
|
|
688
|
-
ensureDir();
|
|
689
|
-
// Clear stale maintenance flag left by a previous crash/update cycle.
|
|
690
|
-
// If the flag exists on watcher startup, the old update is done (or crashed).
|
|
691
|
-
if (flagExists()) {
|
|
692
|
-
log("WARN", "Stale maintenance.flag found on startup \u2014 removing.");
|
|
693
|
-
removeMaintenanceFlag();
|
|
694
|
-
}
|
|
695
|
-
const pid = readPid();
|
|
696
|
-
if (!pid || !alive(pid)) {
|
|
697
|
-
// Always ensure a fresh server on watcher startup — the old server
|
|
698
|
-
// may have stale code from a previous watcher's update cycle.
|
|
699
|
-
await stopMcpServer(); // port-based fallback kills orphan processes
|
|
700
|
-
await cleanAgentPidFiles(); // remove stale PID files
|
|
701
|
-
startMcpServer();
|
|
702
|
-
}
|
|
703
|
-
void applyKeeperSettings().catch((err) => log("ERROR", `Keeper failed to start: ${err}`));
|
|
704
|
-
// The first applyKeeperSettings call almost always fails because the MCP server
|
|
705
|
-
// hasn't finished booting yet (takes 10-60s). Retry after 30s to cover the gap
|
|
706
|
-
// before the 2-minute poller kicks in.
|
|
707
|
-
setTimeout(() => {
|
|
708
|
-
void applyKeeperSettings().catch((err) => log("ERROR", `Keeper retry failed: ${err}`));
|
|
709
|
-
}, 30_000);
|
|
710
|
-
startKeeperPoller();
|
|
711
|
-
if (CONFIG.mode === "production") {
|
|
712
|
-
while (true) {
|
|
713
|
-
const ms = msUntilHour(CONFIG.pollAtHour);
|
|
714
|
-
log("INFO", `Next check in ${Math.round(ms / 60_000)}m (at ${CONFIG.pollAtHour}:00).`);
|
|
715
|
-
await sleep(ms);
|
|
716
|
-
try {
|
|
717
|
-
await checkAndUpdate();
|
|
718
|
-
}
|
|
719
|
-
catch (err) {
|
|
720
|
-
log("ERROR", err);
|
|
721
|
-
removeMaintenanceFlag();
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
else {
|
|
726
|
-
while (true) {
|
|
727
|
-
try {
|
|
728
|
-
await killStale();
|
|
729
|
-
await checkAndUpdate();
|
|
730
|
-
}
|
|
731
|
-
catch (err) {
|
|
732
|
-
log("ERROR", err);
|
|
733
|
-
removeMaintenanceFlag();
|
|
734
|
-
}
|
|
735
|
-
await sleep(CONFIG.pollIntervalSeconds * 1000);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
// MCP server (await_server_ready) — in-process HTTP ---------------------------
|
|
740
|
-
function createWatcherMcp() {
|
|
741
|
-
const srv = new Server({ name: "sensorium-watcher", version: "1.0.0" }, { capabilities: { tools: {}, logging: {} } });
|
|
742
|
-
srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
|
|
743
|
-
name: "await_server_ready",
|
|
744
|
-
description: "Blocks until sensorium finishes updating (maintenance.flag removed) or 120s timeout. Safe to call multiple times — returns immediately if no update in progress.",
|
|
745
|
-
inputSchema: { type: "object", properties: {
|
|
746
|
-
threadId: { type: "number", description: "Telegram thread ID for reconnecting via start_session." },
|
|
747
|
-
} },
|
|
748
|
-
}] }));
|
|
749
|
-
srv.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
750
|
-
if (req.params.name !== "await_server_ready")
|
|
751
|
-
return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
|
|
752
|
-
const tid = req.params.arguments?.threadId ?? 0;
|
|
753
|
-
const lbl = tid ? `threadId=${tid}` : `threadId=<your thread>`;
|
|
754
|
-
if (!flagExists()) {
|
|
755
|
-
return { content: [{ type: "text", text: `Server ready. Call start_session with ${lbl}.` }] };
|
|
756
|
-
}
|
|
757
|
-
// Non-blocking: return immediately with retry instruction
|
|
758
|
-
return { content: [{ type: "text", text: `Update in progress. Use Desktop Commander to run: Start-Sleep -Seconds 30 — then call await_server_ready again with ${lbl}. Repeat until ready.` }] };
|
|
759
|
-
});
|
|
760
|
-
return srv;
|
|
761
|
-
}
|
|
762
|
-
function parseBody(req) {
|
|
763
|
-
const MAX_BODY = 1_048_576; // 1 MB
|
|
764
|
-
return new Promise((resolve, reject) => {
|
|
765
|
-
const chunks = [];
|
|
766
|
-
let size = 0;
|
|
767
|
-
req.on("data", (c) => {
|
|
768
|
-
size += c.length;
|
|
769
|
-
if (size > MAX_BODY) {
|
|
770
|
-
req.destroy(new Error("Request body exceeds 1 MB limit"));
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
chunks.push(c);
|
|
774
|
-
});
|
|
775
|
-
req.on("end", () => { try {
|
|
776
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
777
|
-
}
|
|
778
|
-
catch (e) {
|
|
779
|
-
reject(e);
|
|
780
|
-
} });
|
|
781
|
-
req.on("error", reject);
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
function startHttpMcp(port) {
|
|
785
|
-
const transports = new Map();
|
|
786
|
-
const sessionCreated = new Map();
|
|
787
|
-
// Session TTL: 24 hours. Ghost threads establish their watcher session at
|
|
788
|
-
// startup but may not need await_server_ready for hours. The old 600 s
|
|
789
|
-
// timeout caused sessions to expire before the first update arrived,
|
|
790
|
-
// making await_server_ready fail with "Bad Request".
|
|
791
|
-
const SESSION_TTL_MS = 86_400_000;
|
|
792
|
-
sessionSweeperHandle = setInterval(() => {
|
|
793
|
-
for (const [sid, ts] of sessionCreated) {
|
|
794
|
-
if (Date.now() - ts > SESSION_TTL_MS) {
|
|
795
|
-
transports.get(sid)?.close?.();
|
|
796
|
-
transports.delete(sid);
|
|
797
|
-
sessionCreated.delete(sid);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}, 60_000);
|
|
801
|
-
httpSrv = createServer(async (req, res) => {
|
|
802
|
-
// CORS: restrict to localhost origins (match http-server.ts pattern)
|
|
803
|
-
const origin = req.headers.origin ?? "";
|
|
804
|
-
const allowedOrigin = origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) ? origin : "";
|
|
805
|
-
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
806
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
807
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Authorization");
|
|
808
|
-
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
|
|
809
|
-
if (req.method === "OPTIONS") {
|
|
810
|
-
res.writeHead(204);
|
|
811
|
-
res.end();
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
if (req.url !== "/mcp") {
|
|
815
|
-
res.writeHead(404);
|
|
816
|
-
res.end("Not Found");
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
const sid = req.headers["mcp-session-id"];
|
|
820
|
-
try {
|
|
821
|
-
if (req.method === "POST") {
|
|
822
|
-
const body = await parseBody(req);
|
|
823
|
-
const existing = sid ? transports.get(sid) : undefined;
|
|
824
|
-
if (existing) {
|
|
825
|
-
await existing.handleRequest(req, res, body);
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
// If the client sent a session ID we don't recognise (expired /
|
|
829
|
-
// watcher restarted), return 404 per the MCP Streamable HTTP spec
|
|
830
|
-
// so the client re-initialises instead of giving up.
|
|
831
|
-
if (sid && !existing && !isInitializeRequest(body)) {
|
|
832
|
-
log("WARN", `Unknown session ${sid} — returning 404 to trigger re-init`);
|
|
833
|
-
res.writeHead(404);
|
|
834
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found — please re-initialize" }, id: null }));
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
if (isInitializeRequest(body)) {
|
|
838
|
-
const t = new StreamableHTTPServerTransport({
|
|
839
|
-
sessionIdGenerator: () => randomUUID(),
|
|
840
|
-
onsessioninitialized: (s) => { transports.set(s, t); sessionCreated.set(s, Date.now()); },
|
|
841
|
-
});
|
|
842
|
-
t.onclose = () => { const s = t.sessionId; if (s) {
|
|
843
|
-
transports.delete(s);
|
|
844
|
-
sessionCreated.delete(s);
|
|
845
|
-
} };
|
|
846
|
-
await (createWatcherMcp()).connect(t);
|
|
847
|
-
await t.handleRequest(req, res, body);
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
res.writeHead(400);
|
|
851
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: expected initialize" }, id: null }));
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
if (req.method === "GET" || req.method === "DELETE") {
|
|
855
|
-
const t = sid ? transports.get(sid) : undefined;
|
|
856
|
-
if (t) {
|
|
857
|
-
await t.handleRequest(req, res);
|
|
858
|
-
}
|
|
859
|
-
else {
|
|
860
|
-
res.writeHead(404);
|
|
861
|
-
res.end("Session not found");
|
|
862
|
-
}
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
res.writeHead(405);
|
|
866
|
-
res.end("Method Not Allowed");
|
|
867
|
-
}
|
|
868
|
-
catch (err) {
|
|
869
|
-
log("ERROR", `HTTP handler error: ${err}`);
|
|
870
|
-
if (!res.headersSent) {
|
|
871
|
-
res.writeHead(500);
|
|
872
|
-
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
// Disable Node.js HTTP server timeouts — await_server_ready can block
|
|
877
|
-
// up to 600 s on a single SSE response. The defaults (requestTimeout=300 s,
|
|
878
|
-
// headersTimeout=60 s) would destroy the socket mid-wait, silently
|
|
879
|
-
// dropping the tool result that eventually flows through the SSE stream.
|
|
880
|
-
httpSrv.requestTimeout = 0;
|
|
881
|
-
httpSrv.headersTimeout = 0;
|
|
882
|
-
httpSrv.timeout = 0;
|
|
883
|
-
httpSrv.listen(port, "127.0.0.1", () => log("INFO", `Watcher MCP on http://127.0.0.1:${port}/mcp`));
|
|
884
|
-
}
|
|
885
|
-
// Lockfile — prevent two watcher instances --------------------------------------
|
|
886
|
-
let lockFd = null;
|
|
887
|
-
function acquireLock() {
|
|
888
|
-
try {
|
|
889
|
-
ensureDir();
|
|
890
|
-
lockFd = openSync(P.lock, "wx");
|
|
891
|
-
writeFileSync(lockFd, String(process.pid));
|
|
892
|
-
return true;
|
|
893
|
-
}
|
|
894
|
-
catch {
|
|
895
|
-
// Check if the existing lock holder is still alive
|
|
896
|
-
const raw = safeRead(P.lock);
|
|
897
|
-
if (raw) {
|
|
898
|
-
const pid = parseInt(raw, 10);
|
|
899
|
-
if (!Number.isNaN(pid) && alive(pid)) {
|
|
900
|
-
log("ERROR", `Another watcher is already running (PID ${pid}). Exiting.`);
|
|
901
|
-
return false;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
// Stale lock — reclaim
|
|
905
|
-
try {
|
|
906
|
-
unlinkSync(P.lock);
|
|
907
|
-
}
|
|
908
|
-
catch { /**/ }
|
|
909
|
-
try {
|
|
910
|
-
lockFd = openSync(P.lock, "wx");
|
|
911
|
-
writeFileSync(lockFd, String(process.pid));
|
|
912
|
-
log("WARN", "Reclaimed stale watcher lockfile.");
|
|
913
|
-
return true;
|
|
914
|
-
}
|
|
915
|
-
catch {
|
|
916
|
-
log("ERROR", "Failed to acquire watcher lockfile.");
|
|
917
|
-
return false;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
function releaseLock() {
|
|
922
|
-
if (lockFd !== null) {
|
|
923
|
-
try {
|
|
924
|
-
closeSync(lockFd);
|
|
925
|
-
}
|
|
926
|
-
catch { /**/ }
|
|
927
|
-
lockFd = null;
|
|
928
|
-
}
|
|
929
|
-
try {
|
|
930
|
-
if (existsSync(P.lock))
|
|
931
|
-
unlinkSync(P.lock);
|
|
932
|
-
}
|
|
933
|
-
catch { /**/ }
|
|
934
|
-
}
|
|
935
|
-
// Signal handling & entry point -----------------------------------------------
|
|
936
|
-
export async function startWatcherService() {
|
|
937
|
-
if (!acquireLock()) {
|
|
938
|
-
process.exit(1);
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
941
|
-
// Periodic worker cleanup — every 5 minutes, clean expired workers that outlived their TTL
|
|
942
|
-
workerCleanupHandle = setInterval(() => {
|
|
943
|
-
const chatId = process.env.TELEGRAM_CHAT_ID || "";
|
|
944
|
-
if (!chatId)
|
|
945
|
-
return;
|
|
946
|
-
void (async () => {
|
|
947
|
-
try {
|
|
948
|
-
const db = initMemoryDb();
|
|
949
|
-
const token = process.env.TELEGRAM_TOKEN || "";
|
|
950
|
-
const { resolveTelegramTopicId } = await import("./data/memory/thread-registry.js");
|
|
951
|
-
const telegram = {
|
|
952
|
-
async deleteForumTopic(cId, threadId) {
|
|
953
|
-
if (!token)
|
|
954
|
-
return;
|
|
955
|
-
const topicId = resolveTelegramTopicId(db, threadId);
|
|
956
|
-
await fetch(`https://api.telegram.org/bot${token}/deleteForumTopic`, {
|
|
957
|
-
method: "POST",
|
|
958
|
-
headers: { "Content-Type": "application/json" },
|
|
959
|
-
body: JSON.stringify({ chat_id: cId, message_thread_id: topicId }),
|
|
960
|
-
signal: AbortSignal.timeout(10_000),
|
|
961
|
-
});
|
|
962
|
-
},
|
|
963
|
-
};
|
|
964
|
-
const result = await cleanupExpiredWorkers(db, telegram, chatId);
|
|
965
|
-
if (result.cleaned > 0)
|
|
966
|
-
log("INFO", `[worker-cleanup] Cleaned ${result.cleaned} expired worker threads`);
|
|
967
|
-
}
|
|
968
|
-
catch (err) {
|
|
969
|
-
log("WARN", `[worker-cleanup] ${errorMessage(err)}`);
|
|
970
|
-
}
|
|
971
|
-
})();
|
|
972
|
-
}, 5 * 60 * 1000);
|
|
973
|
-
const shutdown = async () => {
|
|
974
|
-
if (keeperPollerHandle)
|
|
975
|
-
clearInterval(keeperPollerHandle);
|
|
976
|
-
if (sessionSweeperHandle)
|
|
977
|
-
clearInterval(sessionSweeperHandle);
|
|
978
|
-
if (workerCleanupHandle)
|
|
979
|
-
clearInterval(workerCleanupHandle);
|
|
980
|
-
log("INFO", "Shutting down watcher...");
|
|
981
|
-
// Timeout guard: don't hang on stuck keepers or server
|
|
982
|
-
await Promise.race([
|
|
983
|
-
Promise.allSettled([...keepers].map(([, e]) => e.handle.stop())),
|
|
984
|
-
sleep(10_000),
|
|
985
|
-
]);
|
|
986
|
-
keepers.clear();
|
|
987
|
-
await Promise.race([stopMcpServer(), sleep(10_000)]);
|
|
988
|
-
httpSrv?.close();
|
|
989
|
-
releaseLock();
|
|
990
|
-
process.exit(process.exitCode ?? 0);
|
|
991
|
-
};
|
|
992
|
-
process.on("SIGINT", () => void shutdown());
|
|
993
|
-
process.on("SIGTERM", () => void shutdown());
|
|
994
|
-
startHttpMcp(CONFIG.httpPort);
|
|
995
|
-
await runLoop();
|
|
996
|
-
}
|
|
997
|
-
//# sourceMappingURL=watcher-service.js.map
|