cursor-telegram-mcp 0.5.0 → 0.7.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 +104 -24
- package/dist/agentRunner.js +119 -9
- package/dist/cli.js +11 -0
- package/dist/config.js +19 -1
- package/dist/formatTelegram.js +8 -0
- package/dist/index.js +35 -6
- package/dist/install.js +231 -0
- package/dist/parseInbound.js +35 -3
- package/dist/setup.js +31 -11
- package/dist/store.js +97 -12
- package/dist/telegram.js +57 -17
- package/dist/watchdog.js +62 -0
- package/dist/worker.js +203 -54
- package/package.json +1 -1
package/dist/watchdog.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursor-telegram-mcp watchdog` — one-shot liveness check.
|
|
3
|
+
*
|
|
4
|
+
* Meant to be run on a short interval by launchd (or cron). It hits the
|
|
5
|
+
* worker's /health and, if the worker is unreachable OR its poll loop looks
|
|
6
|
+
* wedged (pollAgeSec too high — e.g. a stale socket the abort timeout somehow
|
|
7
|
+
* missed), it kicks the worker's launch agent so launchd restarts it. The
|
|
8
|
+
* worker's own retry/backoff handles the common cases; this is the belt-and-
|
|
9
|
+
* suspenders layer so the bot is never silently offline while the Mac is on.
|
|
10
|
+
*
|
|
11
|
+
* Exits 0 always (a transient blip should not spam launchd error logs).
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { getConfig } from "./config.js";
|
|
15
|
+
/** launchd label to restart; overridable so it works for legacy installs too. */
|
|
16
|
+
const WORKER_LABEL = process.env.TG_WORKER_LABEL?.trim() || "com.cursor-telegram.worker";
|
|
17
|
+
/** Restart if getUpdates has not returned successfully for this many seconds. */
|
|
18
|
+
const MAX_POLL_AGE_SEC = Number.parseInt(process.env.TG_WATCHDOG_MAX_POLL_AGE_SEC ?? "120", 10);
|
|
19
|
+
function log(msg) {
|
|
20
|
+
process.stderr.write(`[watchdog] ${new Date().toISOString()} ${msg}\n`);
|
|
21
|
+
}
|
|
22
|
+
function kick() {
|
|
23
|
+
const uid = process.getuid?.() ?? 501;
|
|
24
|
+
try {
|
|
25
|
+
execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${WORKER_LABEL}`], {
|
|
26
|
+
stdio: "ignore",
|
|
27
|
+
});
|
|
28
|
+
log(`restarted ${WORKER_LABEL}`);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log(`failed to restart ${WORKER_LABEL}: ${String(err)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
const url = `${getConfig(false).workerUrl}/health`;
|
|
36
|
+
const ac = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => ac.abort(), 5000);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, { signal: ac.signal });
|
|
40
|
+
const body = (await res.json());
|
|
41
|
+
if (!body.ok || body.connected === false) {
|
|
42
|
+
log(`unhealthy (ok=${body.ok}, connected=${body.connected}); restarting`);
|
|
43
|
+
kick();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const age = body.pollAgeSec ?? 0;
|
|
47
|
+
if (age > MAX_POLL_AGE_SEC) {
|
|
48
|
+
log(`poll loop wedged (pollAgeSec=${age} > ${MAX_POLL_AGE_SEC}); restarting`);
|
|
49
|
+
kick();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
log(`ok (pollAgeSec=${age})`);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
log(`worker unreachable at ${url}: ${String(err)}; restarting`);
|
|
56
|
+
kick();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await main();
|
package/dist/worker.js
CHANGED
|
@@ -18,23 +18,24 @@
|
|
|
18
18
|
*
|
|
19
19
|
* API (JSON, localhost only):
|
|
20
20
|
* GET /health -> { ok, connected, target, pending, commandMode, queue }
|
|
21
|
-
* POST /notify { summary, project } -> { ok } | 429 { error, waitMs } | 503
|
|
22
|
-
* POST /ask { question, project }-> { id } | 429 | 503
|
|
23
|
-
* POST /mirror { question, project } -> { id, mirrored } | 429 | 503
|
|
21
|
+
* POST /notify { summary, project, agent? } -> { ok } | 429 { error, waitMs } | 503
|
|
22
|
+
* POST /ask { question, project, agent? }-> { id } | 429 | 503
|
|
23
|
+
* POST /mirror { question, project, agent? } -> { id, mirrored } | 429 | 503
|
|
24
24
|
* GET /response/:id -> { id, status, answer?, attachments?, elapsedMin } | 404
|
|
25
25
|
* optional ?waitMs=N long-polls until answered or N ms
|
|
26
26
|
*/
|
|
27
27
|
import { createServer } from "node:http";
|
|
28
28
|
import { notifyAnswered, waitForAnswer } from "./answerWaiters.js";
|
|
29
|
-
import { readdirSync, statSync } from "node:fs";
|
|
30
|
-
import { homedir } from "node:os";
|
|
29
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
30
|
+
import { homedir, hostname } from "node:os";
|
|
31
31
|
import { delimiter, dirname, join } from "node:path";
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
|
-
import { getConfig } from "./config.js";
|
|
33
|
+
import { configDir, getConfig } from "./config.js";
|
|
34
34
|
import { createStore } from "./store.js";
|
|
35
35
|
import { createCommandRunner } from "./agentRunner.js";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
36
|
+
import { Transcript } from "./transcript.js";
|
|
37
|
+
import { labelPrefix, toPlainTelegram } from "./formatTelegram.js";
|
|
38
|
+
import { parseTargetId, splitInboundMessage } from "./parseInbound.js";
|
|
38
39
|
import { splitMessage } from "./splitMessage.js";
|
|
39
40
|
import { createTaskQueue } from "./taskQueue.js";
|
|
40
41
|
import { TelegramClient, createStderrLogger, } from "./telegram.js";
|
|
@@ -44,6 +45,17 @@ const COMMAND_TTL_MS = 60 * 60_000;
|
|
|
44
45
|
const UPDATE_CHECK_MS = 15_000;
|
|
45
46
|
/** Directory holding this worker's source (for self-update detection). */
|
|
46
47
|
const SRC_DIR = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
/** Worker version from package.json (one level up from src/ or dist/). */
|
|
49
|
+
function readVersion() {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(join(SRC_DIR, "..", "package.json"), "utf8"));
|
|
52
|
+
return pkg.version ?? "unknown";
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "unknown";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const WORKER_VERSION = readVersion();
|
|
47
59
|
/**
|
|
48
60
|
* Newest mtime among the worker's TypeScript source files. Used to notice when
|
|
49
61
|
* a command-mode task has edited the worker's own code, so it can relaunch on
|
|
@@ -77,6 +89,11 @@ function cleanProject(raw) {
|
|
|
77
89
|
const s = String(raw ?? "").trim().replace(/\s+/g, " ");
|
|
78
90
|
return s === "" ? "default" : s.slice(0, 48);
|
|
79
91
|
}
|
|
92
|
+
/** Sanitize an optional per-agent label; empty string means "no agent label". */
|
|
93
|
+
function cleanAgent(raw) {
|
|
94
|
+
const s = String(raw ?? "").trim().replace(/\s+/g, " ");
|
|
95
|
+
return s.slice(0, 48);
|
|
96
|
+
}
|
|
80
97
|
function sendJson(res, status, body) {
|
|
81
98
|
res.writeHead(status, { "content-type": "application/json" });
|
|
82
99
|
res.end(JSON.stringify(body));
|
|
@@ -108,16 +125,6 @@ function isReplyToQuestion(msg, store) {
|
|
|
108
125
|
const pendings = store.listPending();
|
|
109
126
|
return pendings.some((q) => q.sentMessageId && q.sentMessageId === msg.quotedMessageId);
|
|
110
127
|
}
|
|
111
|
-
function isCommandLike(segments) {
|
|
112
|
-
return segments.some((s) => s.kind === "ask" ||
|
|
113
|
-
s.kind === "plan" ||
|
|
114
|
-
s.kind === "ask_empty" ||
|
|
115
|
-
s.kind === "plan_empty" ||
|
|
116
|
-
s.kind === "status" ||
|
|
117
|
-
s.kind === "approve" ||
|
|
118
|
-
s.kind === "reject" ||
|
|
119
|
-
s.kind === "approval_footer");
|
|
120
|
-
}
|
|
121
128
|
async function main() {
|
|
122
129
|
// Make sure the Cursor CLI (`cursor-agent`, used by command mode) is findable
|
|
123
130
|
// even when launched by a GUI/launchd context with a minimal PATH.
|
|
@@ -130,18 +137,32 @@ async function main() {
|
|
|
130
137
|
if (config.chatId === "") {
|
|
131
138
|
log("TELEGRAM_CHAT_ID is not set. Run `npm run login` to find it, then set it in .env.");
|
|
132
139
|
}
|
|
133
|
-
const store = createStore();
|
|
140
|
+
const store = createStore({ persistPath: join(configDir(), "questions.json") });
|
|
134
141
|
const taskQueue = createTaskQueue();
|
|
142
|
+
const startedAt = Date.now();
|
|
143
|
+
let lastError;
|
|
144
|
+
const restoredPending = store.pendingCount();
|
|
135
145
|
const runner = createCommandRunner({
|
|
136
146
|
apiKey: config.cursorApiKey,
|
|
137
147
|
cwd: config.agentCwd,
|
|
138
148
|
model: config.agentModel,
|
|
139
149
|
settingSources: config.agentLoadSettings ? ["all"] : [],
|
|
150
|
+
rolling: config.rolling,
|
|
151
|
+
sessionPath: config.sessionPath,
|
|
140
152
|
});
|
|
141
|
-
const commandMode = runner.enabled;
|
|
153
|
+
const commandMode = runner.enabled && config.commandModeEnabled;
|
|
154
|
+
const transcript = new Transcript(config.transcriptPath);
|
|
142
155
|
log(commandMode
|
|
143
156
|
? `Command mode ON (model ${config.agentModel}, cwd ${config.agentCwd}). /ask, /plan, or plain text to plan.`
|
|
144
|
-
:
|
|
157
|
+
: runner.enabled
|
|
158
|
+
? "Command mode OFF (disabled via TG_COMMAND_MODE). Inbound text will not spawn tasks."
|
|
159
|
+
: "Command mode OFF (set CURSOR_API_KEY to text tasks to the bot).");
|
|
160
|
+
if (commandMode && config.rolling) {
|
|
161
|
+
const info = runner.rollingInfo();
|
|
162
|
+
log(info
|
|
163
|
+
? `Rolling thread ON (resuming ${info.agentId}). Transcript: ${config.transcriptPath}`
|
|
164
|
+
: `Rolling thread ON (new thread on first message). Transcript: ${config.transcriptPath}`);
|
|
165
|
+
}
|
|
145
166
|
const client = new TelegramClient({
|
|
146
167
|
botToken: config.botToken,
|
|
147
168
|
logger: createStderrLogger("warn"),
|
|
@@ -208,9 +229,11 @@ async function main() {
|
|
|
208
229
|
});
|
|
209
230
|
log(`Execution ${id} -> ${session.status}`);
|
|
210
231
|
if (session.status === "done") {
|
|
232
|
+
transcript.append({ role: "done", id, text: session.result ?? "" });
|
|
211
233
|
await send(`Done (${id})\n\n${session.result ?? ""}`);
|
|
212
234
|
}
|
|
213
235
|
else if (session.status === "error") {
|
|
236
|
+
transcript.append({ role: "error", id, text: session.error ?? "unknown error" });
|
|
214
237
|
await send(`Command ${id} failed:\n${session.error ?? "unknown error"}`);
|
|
215
238
|
}
|
|
216
239
|
}
|
|
@@ -218,15 +241,35 @@ async function main() {
|
|
|
218
241
|
clearHeartbeat();
|
|
219
242
|
}
|
|
220
243
|
}
|
|
244
|
+
/** One line per pending question: "Q-3 [project · agent]: question…". */
|
|
245
|
+
function pendingLines() {
|
|
246
|
+
return store.listPending().map((q) => {
|
|
247
|
+
const head = `${q.id} ${labelPrefix(q.projectLabel, q.agentLabel)}`;
|
|
248
|
+
const preview = q.question.replace(/\s+/g, " ").slice(0, 80);
|
|
249
|
+
return `${head}: ${preview}`;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/** Full reply to /pending: the list plus how to answer a specific one. */
|
|
253
|
+
function formatPendingList() {
|
|
254
|
+
const lines = pendingLines();
|
|
255
|
+
if (lines.length === 0)
|
|
256
|
+
return "No pending questions.";
|
|
257
|
+
const example = store.listPending()[0]?.id ?? "Q-1";
|
|
258
|
+
return (`Pending questions (${lines.length}):\n${lines.join("\n")}\n\n` +
|
|
259
|
+
`To answer a specific one, swipe-reply its message, or prefix your answer ` +
|
|
260
|
+
`with its id, e.g. "${example} your answer".`);
|
|
261
|
+
}
|
|
221
262
|
function formatStatus() {
|
|
222
263
|
const lines = [];
|
|
223
264
|
lines.push(`Command mode: ${commandMode ? "ON" : "OFF"}`);
|
|
224
|
-
const pending =
|
|
265
|
+
const pending = pendingLines();
|
|
225
266
|
if (pending.length === 0) {
|
|
226
267
|
lines.push("Pending questions: none");
|
|
227
268
|
}
|
|
228
269
|
else {
|
|
229
|
-
lines.push(`Pending questions: ${pending.length}
|
|
270
|
+
lines.push(`Pending questions: ${pending.length}`);
|
|
271
|
+
for (const line of pending)
|
|
272
|
+
lines.push(` ${line}`);
|
|
230
273
|
}
|
|
231
274
|
const awaiting = runner.listAwaitingApproval();
|
|
232
275
|
if (awaiting.length === 0) {
|
|
@@ -244,13 +287,19 @@ async function main() {
|
|
|
244
287
|
lines.push(`Active asks: ${activeAsks.map((s) => s.id).join(", ")}`);
|
|
245
288
|
}
|
|
246
289
|
lines.push(`Queued tasks: ${taskQueue.preview()}`);
|
|
290
|
+
if (commandMode && config.rolling) {
|
|
291
|
+
const info = runner.rollingInfo();
|
|
292
|
+
lines.push(`Rolling thread: ${info ? info.agentId : "not started yet"}`);
|
|
293
|
+
lines.push(`Transcript: ${config.transcriptPath}`);
|
|
294
|
+
}
|
|
247
295
|
if (commandMode) {
|
|
248
|
-
lines.push("Commands: /ask question, /plan task, plain text plans too");
|
|
296
|
+
lines.push("Commands: /ask question, /plan task, plain text plans too, /reset for a new thread");
|
|
249
297
|
}
|
|
250
298
|
return lines.join("\n");
|
|
251
299
|
}
|
|
252
300
|
async function runPlanTask(task, attachments) {
|
|
253
301
|
log(`New task received; planning...`);
|
|
302
|
+
transcript.append({ role: "you", text: task });
|
|
254
303
|
await send(`Planning your request...`);
|
|
255
304
|
let heartbeat;
|
|
256
305
|
const clearHeartbeat = () => {
|
|
@@ -278,14 +327,17 @@ async function main() {
|
|
|
278
327
|
clearHeartbeat();
|
|
279
328
|
log(`Plan ${session.id} -> ${session.status}`);
|
|
280
329
|
if (session.status === "awaiting_approval") {
|
|
330
|
+
transcript.append({ role: "plan", id: session.id, text: session.plan ?? "" });
|
|
281
331
|
await send(`Plan (${session.id})\n\n${session.plan ?? ""}\n\nReply YES to run it or NO to cancel.`);
|
|
282
332
|
}
|
|
283
333
|
else {
|
|
334
|
+
transcript.append({ role: "error", id: session.id, text: session.error ?? "unknown error" });
|
|
284
335
|
await send(`Could not plan that (${session.id}):\n${session.error ?? "unknown error"}`);
|
|
285
336
|
}
|
|
286
337
|
}
|
|
287
338
|
async function runAskQuestion(question, attachments) {
|
|
288
339
|
log(`Ask received; answering...`);
|
|
340
|
+
transcript.append({ role: "you", text: `/ask ${question}` });
|
|
289
341
|
await send(`Answering...`);
|
|
290
342
|
let heartbeat;
|
|
291
343
|
const clearHeartbeat = () => {
|
|
@@ -313,9 +365,11 @@ async function main() {
|
|
|
313
365
|
clearHeartbeat();
|
|
314
366
|
log(`Ask ${session.id} -> ${session.status}`);
|
|
315
367
|
if (session.status === "done") {
|
|
368
|
+
transcript.append({ role: "answer", id: session.id, text: session.answer ?? "" });
|
|
316
369
|
await send(`Ask (${session.id})\n\n${session.answer ?? ""}`);
|
|
317
370
|
}
|
|
318
371
|
else {
|
|
372
|
+
transcript.append({ role: "error", id: session.id, text: session.error ?? "unknown error" });
|
|
319
373
|
await send(`Could not answer (${session.id}):\n${session.error ?? "unknown error"}`);
|
|
320
374
|
}
|
|
321
375
|
}
|
|
@@ -359,6 +413,25 @@ async function main() {
|
|
|
359
413
|
case "status":
|
|
360
414
|
await send(formatStatus());
|
|
361
415
|
return;
|
|
416
|
+
case "pending":
|
|
417
|
+
await send(formatPendingList());
|
|
418
|
+
return;
|
|
419
|
+
case "reset": {
|
|
420
|
+
if (!commandMode)
|
|
421
|
+
return;
|
|
422
|
+
if (!config.rolling) {
|
|
423
|
+
await send("Rolling thread is off; nothing to reset.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (isWorkerBusy()) {
|
|
427
|
+
await send("BUSY: finish the current work before starting a new thread.");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
runner.reset();
|
|
431
|
+
transcript.append({ role: "system", text: "New rolling thread started (memory cleared)." });
|
|
432
|
+
await send("Started a fresh thread. The next prompt begins a new conversation.");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
362
435
|
case "approve": {
|
|
363
436
|
if (!commandMode)
|
|
364
437
|
return;
|
|
@@ -446,22 +519,75 @@ async function main() {
|
|
|
446
519
|
const text = msg.text.trim();
|
|
447
520
|
if (text === "" && attachments.length === 0)
|
|
448
521
|
return;
|
|
522
|
+
const recordAnswer = async (matched) => {
|
|
523
|
+
log(`Recorded answer for ${matched.id} ${labelPrefix(matched.projectLabel, matched.agentLabel)}.`);
|
|
524
|
+
notifyAnswered(matched.id);
|
|
525
|
+
await send(`Answer recorded for ${matched.id}.`);
|
|
526
|
+
};
|
|
527
|
+
// 1) Explicit target prefix ("Q-3 ...", "#3 ...", "@Q-3 ...") wins over
|
|
528
|
+
// everything else, so "Q-3 yes" answers Q-3 rather than reading as a
|
|
529
|
+
// command. Only intercept when something is actually pending.
|
|
530
|
+
if (store.pendingCount() > 0) {
|
|
531
|
+
const target = parseTargetId(text);
|
|
532
|
+
if (target) {
|
|
533
|
+
if (!store.listPending().some((q) => q.id === target.id)) {
|
|
534
|
+
await send(`No pending question ${target.id}.\n\n${formatPendingList()}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (target.rest === "" && attachments.length === 0) {
|
|
538
|
+
await send(`What's your answer to ${target.id}? Reply: ${target.id} your answer`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const matched = store.matchAndAnswer(msg, { targetId: target.id, answerText: target.rest });
|
|
542
|
+
if (matched)
|
|
543
|
+
await recordAnswer(matched);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// 2) Swipe-reply to a specific pending question.
|
|
449
548
|
if (isReplyToQuestion(msg, store)) {
|
|
450
549
|
const matched = store.matchAndAnswer(msg);
|
|
451
|
-
if (matched)
|
|
452
|
-
|
|
453
|
-
notifyAnswered(matched.id);
|
|
454
|
-
await send(`Answer recorded for ${matched.id}.`);
|
|
455
|
-
}
|
|
550
|
+
if (matched)
|
|
551
|
+
await recordAnswer(matched);
|
|
456
552
|
return;
|
|
457
553
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
554
|
+
// 3) Bare reply while questions are pending. A yes/no/ok parses as an
|
|
555
|
+
// approve/reject COMMAND, but only when command mode is on AND a plan is
|
|
556
|
+
// awaiting approval. Otherwise route it to the pending question so answers
|
|
557
|
+
// like "Yes" are not silently swallowed.
|
|
558
|
+
if (store.pendingCount() > 0) {
|
|
559
|
+
const segs = splitInboundMessage(text);
|
|
560
|
+
const canApproveNow = commandMode && runner.latestAwaitingApproval() != null;
|
|
561
|
+
const actionableCommand = segs.some((s) => {
|
|
562
|
+
switch (s.kind) {
|
|
563
|
+
case "ask":
|
|
564
|
+
case "ask_empty":
|
|
565
|
+
case "plan":
|
|
566
|
+
case "plan_empty":
|
|
567
|
+
case "status":
|
|
568
|
+
case "pending":
|
|
569
|
+
return true;
|
|
570
|
+
case "reset":
|
|
571
|
+
return commandMode;
|
|
572
|
+
case "approve":
|
|
573
|
+
case "reject":
|
|
574
|
+
case "approval_footer":
|
|
575
|
+
return canApproveNow;
|
|
576
|
+
default:
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
if (!actionableCommand) {
|
|
581
|
+
if (store.pendingCount() > 1) {
|
|
582
|
+
// Ambiguous: don't guess at the oldest. Ask the human to target one.
|
|
583
|
+
await send(`You have ${store.pendingCount()} questions waiting — tell me which to answer.\n\n${formatPendingList()}`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const matched = store.matchAndAnswer(msg);
|
|
587
|
+
if (matched) {
|
|
588
|
+
await recordAnswer(matched);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
465
591
|
}
|
|
466
592
|
}
|
|
467
593
|
const segments = splitInboundMessage(text);
|
|
@@ -481,7 +607,10 @@ async function main() {
|
|
|
481
607
|
return;
|
|
482
608
|
incomingChain = incomingChain
|
|
483
609
|
.then(() => handleIncoming(msg))
|
|
484
|
-
.catch((err) =>
|
|
610
|
+
.catch((err) => {
|
|
611
|
+
lastError = `handleIncoming: ${String(err)}`;
|
|
612
|
+
log(lastError);
|
|
613
|
+
});
|
|
485
614
|
});
|
|
486
615
|
function rateGuard() {
|
|
487
616
|
const elapsed = Date.now() - lastSendAt;
|
|
@@ -492,7 +621,7 @@ async function main() {
|
|
|
492
621
|
function dedupeMirrorKey(project, question) {
|
|
493
622
|
return `${project}::${question.slice(0, 200)}`;
|
|
494
623
|
}
|
|
495
|
-
async function mirrorQuestion(project, question) {
|
|
624
|
+
async function mirrorQuestion(project, question, agent) {
|
|
496
625
|
const key = dedupeMirrorKey(project, question);
|
|
497
626
|
const now = Date.now();
|
|
498
627
|
const last = recentMirrors.get(key);
|
|
@@ -501,8 +630,8 @@ async function main() {
|
|
|
501
630
|
return { id: existing?.id ?? "deduped", mirrored: false };
|
|
502
631
|
}
|
|
503
632
|
recentMirrors.set(key, now);
|
|
504
|
-
const record = store.addQuestion(project, question);
|
|
505
|
-
const text =
|
|
633
|
+
const record = store.addQuestion(project, question, agent);
|
|
634
|
+
const text = `${labelPrefix(project, agent)} ${record.id}\n${question}\n\nReply to this message to answer.`;
|
|
506
635
|
const sentId = await send(text);
|
|
507
636
|
if (sentId)
|
|
508
637
|
store.setSentMessageId(record.id, sentId);
|
|
@@ -521,6 +650,10 @@ async function main() {
|
|
|
521
650
|
pending: store.pendingCount(),
|
|
522
651
|
commandMode,
|
|
523
652
|
queue: taskQueue.length(),
|
|
653
|
+
version: WORKER_VERSION,
|
|
654
|
+
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
|
|
655
|
+
pollAgeSec: Math.floor(client.lastPollAgeMs() / 1000),
|
|
656
|
+
lastError: lastError ?? null,
|
|
524
657
|
});
|
|
525
658
|
}
|
|
526
659
|
if (method === "POST" && path === "/notify") {
|
|
@@ -534,9 +667,10 @@ async function main() {
|
|
|
534
667
|
const body = await readJson(req);
|
|
535
668
|
const summary = String(body.summary ?? "").trim();
|
|
536
669
|
const project = cleanProject(body.project);
|
|
670
|
+
const agent = cleanAgent(body.agent);
|
|
537
671
|
if (!summary)
|
|
538
672
|
return sendJson(res, 400, { error: "summary is required" });
|
|
539
|
-
await send(
|
|
673
|
+
await send(`${labelPrefix(project, agent)} Task complete\n\n${summary}`);
|
|
540
674
|
return sendJson(res, 200, { ok: true });
|
|
541
675
|
}
|
|
542
676
|
if (method === "POST" && (path === "/ask" || path === "/mirror")) {
|
|
@@ -550,9 +684,10 @@ async function main() {
|
|
|
550
684
|
const body = await readJson(req);
|
|
551
685
|
const question = String(body.question ?? "").trim();
|
|
552
686
|
const project = cleanProject(body.project);
|
|
687
|
+
const agent = cleanAgent(body.agent);
|
|
553
688
|
if (!question)
|
|
554
689
|
return sendJson(res, 400, { error: "question is required" });
|
|
555
|
-
const { id, mirrored } = await mirrorQuestion(project, question);
|
|
690
|
+
const { id, mirrored } = await mirrorQuestion(project, question, agent);
|
|
556
691
|
return sendJson(res, 200, { id, mirrored: path === "/mirror" ? mirrored : true });
|
|
557
692
|
}
|
|
558
693
|
if (method === "GET" && path.startsWith("/response/")) {
|
|
@@ -616,6 +751,7 @@ async function main() {
|
|
|
616
751
|
return sendJson(res, 404, { error: "not found" });
|
|
617
752
|
}
|
|
618
753
|
catch (err) {
|
|
754
|
+
lastError = `http: ${String(err)}`;
|
|
619
755
|
return sendJson(res, 500, { error: String(err) });
|
|
620
756
|
}
|
|
621
757
|
});
|
|
@@ -633,6 +769,13 @@ async function main() {
|
|
|
633
769
|
const sweep = setInterval(() => runner.sweepStale(COMMAND_TTL_MS), 10 * 60_000);
|
|
634
770
|
sweep.unref?.();
|
|
635
771
|
await client.connect().catch((err) => log(`Telegram connection error: ${String(err)}`));
|
|
772
|
+
// Startup ping: let the human know the worker is back online (opt-out via
|
|
773
|
+
// TG_STARTUP_PING=0). Includes restored pending-question count so a restart
|
|
774
|
+
// that recovered state is visible.
|
|
775
|
+
if (config.startupPing && client.isOpen() && config.chatId !== "") {
|
|
776
|
+
const restored = restoredPending > 0 ? `, restored ${restoredPending} pending` : "";
|
|
777
|
+
await send(`Worker online on ${hostname()} (v${WORKER_VERSION}). Command mode ${commandMode ? "ON" : "OFF"}${restored}.`);
|
|
778
|
+
}
|
|
636
779
|
const shutdown = () => {
|
|
637
780
|
log("Shutting down...");
|
|
638
781
|
server.close();
|
|
@@ -643,23 +786,29 @@ async function main() {
|
|
|
643
786
|
// Self-update: if a task edits the worker's own source, relaunch on the new
|
|
644
787
|
// code once nothing is in flight. Only effective under a KeepAlive supervisor
|
|
645
788
|
// (launchd `npm run worker:install`), which restarts the process after exit.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
789
|
+
// Disabled when TG_SELF_UPDATE is off: the worker then never restarts itself on
|
|
790
|
+
// source changes, so in-flight command-mode state can't be wiped mid-task (you
|
|
791
|
+
// restart manually to pick up new code).
|
|
792
|
+
if (config.selfUpdateEnabled) {
|
|
793
|
+
const startFingerprint = sourceFingerprint();
|
|
794
|
+
const isIdleForRestart = () => !isWorkerBusy() &&
|
|
649
795
|
!draining &&
|
|
650
796
|
taskQueue.length() === 0 &&
|
|
651
797
|
runner.listAwaitingApproval().length === 0 &&
|
|
652
798
|
runner.listActiveAsks().length === 0 &&
|
|
653
|
-
store.pendingCount() === 0
|
|
799
|
+
store.pendingCount() === 0;
|
|
800
|
+
const updateCheck = setInterval(() => {
|
|
801
|
+
if (sourceFingerprint() > startFingerprint && isIdleForRestart()) {
|
|
802
|
+
log("Source changed and worker idle; restarting to load the latest version...");
|
|
803
|
+
clearInterval(updateCheck);
|
|
804
|
+
shutdown();
|
|
805
|
+
}
|
|
806
|
+
}, UPDATE_CHECK_MS);
|
|
807
|
+
updateCheck.unref?.();
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
log("Self-update OFF (TG_SELF_UPDATE). Worker will not auto-restart on source changes.");
|
|
654
811
|
}
|
|
655
|
-
const updateCheck = setInterval(() => {
|
|
656
|
-
if (sourceFingerprint() > startFingerprint && isIdleForRestart()) {
|
|
657
|
-
log("Source changed and worker idle; restarting to load the latest version...");
|
|
658
|
-
clearInterval(updateCheck);
|
|
659
|
-
shutdown();
|
|
660
|
-
}
|
|
661
|
-
}, UPDATE_CHECK_MS);
|
|
662
|
-
updateCheck.unref?.();
|
|
663
812
|
}
|
|
664
813
|
main().catch((err) => {
|
|
665
814
|
log(`Fatal: ${err?.stack ?? err}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-telegram-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Manage Cursor from your phone over Telegram: an MCP server + auto-spawned local worker that notifies you, asks you questions, and (optionally) runs headless Cursor agents you text it. Local, bring-your-own-bot, runs entirely on your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|