cursor-telegram-mcp 0.5.0 → 0.6.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 +59 -14
- package/dist/agentRunner.js +119 -9
- package/dist/cli.js +8 -0
- package/dist/config.js +19 -1
- package/dist/formatTelegram.js +8 -0
- package/dist/index.js +35 -6
- package/dist/install.js +142 -0
- package/dist/parseInbound.js +35 -3
- package/dist/store.js +97 -12
- package/dist/worker.js +202 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,8 @@ Local and bring-your-own-bot: each person installs the package, creates their
|
|
|
15
15
|
own Telegram bot, and everything runs on their own machine. There is no shared
|
|
16
16
|
server and nothing shared between users. It uses the official
|
|
17
17
|
[Telegram Bot API](https://core.telegram.org/bots/api) over HTTPS - no QR, no
|
|
18
|
-
eSIM, no SIM. Pending questions are
|
|
18
|
+
eSIM, no SIM. Pending questions are persisted to disk, so a worker restart
|
|
19
|
+
restores any still-open questions instead of dropping them.
|
|
19
20
|
|
|
20
21
|
## Add to Cursor (one click)
|
|
21
22
|
|
|
@@ -71,7 +72,7 @@ flowchart TD
|
|
|
71
72
|
check -->|yes| reuse["reuse running worker"]
|
|
72
73
|
spawn --> worker["worker dist/worker.js"]
|
|
73
74
|
reuse --> worker
|
|
74
|
-
worker --> store["
|
|
75
|
+
worker --> store["persisted question store (questions.json)"]
|
|
75
76
|
worker -->|"command mode (optional)"| agent["headless Cursor agent (cursor-agent CLI)"]
|
|
76
77
|
worker -->|"Bot API: sendMessage / getUpdates"| tg["Telegram"]
|
|
77
78
|
tg -->|"your reply / texted task"| worker
|
|
@@ -120,15 +121,37 @@ worker treats any message that is not answering an open question as a task:
|
|
|
120
121
|
carries out the plan and texts back a summary.
|
|
121
122
|
|
|
122
123
|
You can also use `/ask <question>` (read-only Q&A) and `/plan <task>` explicitly,
|
|
123
|
-
|
|
124
|
-
(`cursor-agent`) installed:
|
|
124
|
+
send `status` to see what's running, and `/reset` (or `/new`) to start a fresh
|
|
125
|
+
conversation. Command mode needs the Cursor CLI (`cursor-agent`) installed:
|
|
125
126
|
|
|
126
127
|
- macOS / Linux: `curl https://cursor.com/install -fsS | bash`
|
|
127
128
|
- Windows (PowerShell): `irm 'https://cursor.com/install?win32=true' | iex`
|
|
128
129
|
|
|
129
130
|
The headless agent runs locally against `TG_AGENT_CWD` (default: the directory
|
|
130
131
|
the worker started in). Every task is gated: nothing changes code until you
|
|
131
|
-
approve the plan.
|
|
132
|
+
approve the plan.
|
|
133
|
+
|
|
134
|
+
## Rolling chat (knowledge carries across messages)
|
|
135
|
+
|
|
136
|
+
By default (`TG_ROLLING=true`) command mode keeps ONE long-lived Cursor agent
|
|
137
|
+
and sends every turn — plan, execute, and `/ask` — to it. That means:
|
|
138
|
+
|
|
139
|
+
- The executor remembers the plan it just made, and the next prompt remembers
|
|
140
|
+
the whole conversation, so you can keep building from your phone without
|
|
141
|
+
re-explaining context.
|
|
142
|
+
- The agent id is persisted to `TG_SESSION_PATH` (default
|
|
143
|
+
`<configDir>/rolling-session.json`), so the thread survives worker restarts
|
|
144
|
+
(including the self-update restart) and resumes where you left off.
|
|
145
|
+
- Send `/reset` (or `/new`) to drop the memory and start a fresh thread.
|
|
146
|
+
|
|
147
|
+
Set `TG_ROLLING=false` to go back to a stateless agent per message.
|
|
148
|
+
|
|
149
|
+
### See the same chat on your computer
|
|
150
|
+
|
|
151
|
+
Every turn is also appended to a Markdown transcript at `TG_TRANSCRIPT_PATH`
|
|
152
|
+
(default `<TG_AGENT_CWD>/remote-chat.md`). Open it in Cursor (or any editor) to
|
|
153
|
+
read the same conversation you are driving from your phone. Because it lives in
|
|
154
|
+
the repo, git tracks its history — commit it whenever you want a snapshot.
|
|
132
155
|
|
|
133
156
|
## Keeping the worker alive
|
|
134
157
|
|
|
@@ -136,11 +159,25 @@ The worker runs only while your laptop is awake. It auto-starts with the MCP
|
|
|
136
159
|
server and stays up across Cursor reloads, so for "laptop on the charger, manage
|
|
137
160
|
from my phone" you usually need nothing else.
|
|
138
161
|
|
|
139
|
-
If you want it to survive reboots without opening Cursor,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
If you want it to survive reboots without opening Cursor, install it as an
|
|
163
|
+
always-on background service. On macOS this is one command:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
cursor-telegram-mcp install # start now + at every login (launchd)
|
|
167
|
+
cursor-telegram-mcp uninstall # stop and remove it
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`install` writes a per-user launch agent
|
|
171
|
+
(`~/Library/LaunchAgents/com.cursor-telegram.worker.plist`) that points at your
|
|
172
|
+
current Node and the installed CLI — no hardcoded paths — runs the worker under
|
|
173
|
+
`caffeinate` so idle sleep never blocks delivery, and restarts it if it crashes
|
|
174
|
+
(`KeepAlive`). Logs go to `~/Library/Logs/cursor-telegram-worker.log`. Keep the
|
|
175
|
+
Mac on AC power so a closed lid does not fully sleep.
|
|
176
|
+
|
|
177
|
+
On Linux/Windows, run `cursor-telegram-mcp worker` under your own service
|
|
178
|
+
manager (systemd / Task Scheduler). (Note: on macOS, launchd agents cannot read
|
|
179
|
+
files under `~/Desktop`/`~/Documents`/`~/Downloads`; install the package or
|
|
180
|
+
project outside those folders.)
|
|
144
181
|
|
|
145
182
|
## Configuration
|
|
146
183
|
|
|
@@ -162,6 +199,9 @@ defaults.
|
|
|
162
199
|
| `TG_AGENT_CWD` | worker cwd | Directory the headless command-mode agent works in. |
|
|
163
200
|
| `TG_AGENT_MODEL` | `composer-2.5` | Model id for the headless agent. |
|
|
164
201
|
| `TG_AGENT_LOAD_SETTINGS` | `false` | `true` to load `TG_AGENT_CWD`'s `.cursor` rules/MCP during runs. |
|
|
202
|
+
| `TG_ROLLING` | `true` | Keep one rolling agent thread so knowledge carries across messages. |
|
|
203
|
+
| `TG_TRANSCRIPT_PATH` | `<cwd>/remote-chat.md` | Markdown transcript of the rolling chat. |
|
|
204
|
+
| `TG_SESSION_PATH` | `<configDir>/rolling-session.json` | Where the rolling agent id is persisted. |
|
|
165
205
|
|
|
166
206
|
## CLI
|
|
167
207
|
|
|
@@ -170,6 +210,8 @@ cursor-telegram-mcp [mcp] Start the MCP server (default; Cursor runs this)
|
|
|
170
210
|
cursor-telegram-mcp setup First-time setup: create/link your bot
|
|
171
211
|
cursor-telegram-mcp login Print and save your Telegram chat id
|
|
172
212
|
cursor-telegram-mcp worker Run the background worker in the foreground
|
|
213
|
+
cursor-telegram-mcp install Install the always-on worker (macOS launchd)
|
|
214
|
+
cursor-telegram-mcp uninstall Remove the always-on worker
|
|
173
215
|
cursor-telegram-mcp doctor Diagnose configuration and connectivity
|
|
174
216
|
```
|
|
175
217
|
|
|
@@ -243,8 +285,8 @@ npm run build # emit dist/ for publishing
|
|
|
243
285
|
## Notes & limitations
|
|
244
286
|
|
|
245
287
|
- Local-only: if the laptop sleeps or the worker stops, messaging pauses until
|
|
246
|
-
it is back.
|
|
247
|
-
|
|
288
|
+
it is back. Open questions are persisted to disk and restored on the next
|
|
289
|
+
worker start, so a restart no longer drops unanswered questions.
|
|
248
290
|
- Per user: each person creates their own bot and runs their own worker (one bot
|
|
249
291
|
token can only be polled by one process).
|
|
250
292
|
- Command mode requires the `cursor-agent` CLI and a Cursor API key; without
|
|
@@ -262,8 +304,11 @@ src/
|
|
|
262
304
|
login.ts # chat-id discovery helper
|
|
263
305
|
doctor.ts # diagnostics
|
|
264
306
|
telegram.ts # Bot API client: long-poll getUpdates, sendText, media
|
|
265
|
-
agentRunner.ts# command mode: headless Cursor agent (lazy @cursor/sdk)
|
|
266
|
-
|
|
307
|
+
agentRunner.ts# command mode: rolling/resumable headless Cursor agent (lazy @cursor/sdk)
|
|
308
|
+
session.ts # persist the rolling agent id across worker restarts
|
|
309
|
+
transcript.ts # append-only remote-chat.md transcript of the rolling chat
|
|
310
|
+
store.ts # pending-question store (persisted to disk) + reply matching
|
|
311
|
+
install.ts # `install`/`uninstall`: generate a per-user launchd agent (macOS)
|
|
267
312
|
parseInbound.ts / splitMessage.ts / taskQueue.ts / formatTelegram.ts
|
|
268
313
|
answerWaiters.ts # wake-on-answer for long-polling GET /response/:id
|
|
269
314
|
.cursor/
|
package/dist/agentRunner.js
CHANGED
|
@@ -55,15 +55,23 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
55
55
|
*
|
|
56
56
|
* Flow (plan-then-approve, always gated):
|
|
57
57
|
* 1. You text the bot a task.
|
|
58
|
-
* 2. `plan()`
|
|
59
|
-
*
|
|
60
|
-
* 3. You reply YES -> `approve()`
|
|
61
|
-
*
|
|
58
|
+
* 2. `plan()` produces a step-by-step PLAN only (no file changes) and returns
|
|
59
|
+
* it for you to review on your phone.
|
|
60
|
+
* 3. You reply YES -> `approve()` executes the approved plan and reports the
|
|
61
|
+
* result. Reply NO -> `reject()` drops it.
|
|
62
|
+
*
|
|
63
|
+
* Rolling thread: by default the runner keeps ONE long-lived Cursor agent and
|
|
64
|
+
* sends every turn (plan, execute, ask) to it, so knowledge carries across
|
|
65
|
+
* messages — the executor remembers the plan, and the next prompt remembers the
|
|
66
|
+
* whole conversation. The agent id is persisted (see session.ts) so the thread
|
|
67
|
+
* survives worker restarts. Set `rolling: false` to fall back to a fresh agent
|
|
68
|
+
* per call (the old stateless behavior).
|
|
62
69
|
*
|
|
63
70
|
* Everything runs locally on this machine (the laptop on the charger) against
|
|
64
|
-
* `cwd`, using your CURSOR_API_KEY.
|
|
71
|
+
* `cwd`, using your CURSOR_API_KEY.
|
|
65
72
|
*/
|
|
66
73
|
import { readFileSync } from "node:fs";
|
|
74
|
+
import { clearRollingSession, loadRollingSession, saveRollingSession, } from "./session.js";
|
|
67
75
|
/**
|
|
68
76
|
* Lazily load the optional `@cursor/sdk`. Command mode needs it; if the package
|
|
69
77
|
* is not installed (it is an optionalDependency), surface a clear, actionable
|
|
@@ -100,7 +108,8 @@ function buildUserMessage(text, attachments) {
|
|
|
100
108
|
return text;
|
|
101
109
|
return { text, images };
|
|
102
110
|
}
|
|
103
|
-
|
|
111
|
+
/** Run a single prompt on a fresh, stateless agent (rolling disabled). */
|
|
112
|
+
async function runStatelessPrompt(promptText, opts, attachments) {
|
|
104
113
|
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
105
114
|
try {
|
|
106
115
|
const message = buildUserMessage(promptText, attachments);
|
|
@@ -163,13 +172,114 @@ export class CommandRunner {
|
|
|
163
172
|
nextAskId = 1;
|
|
164
173
|
sessions = new Map();
|
|
165
174
|
askSessions = new Map();
|
|
175
|
+
/** Live rolling agent instance (kept across turns; created lazily). */
|
|
176
|
+
agent;
|
|
177
|
+
/** Persisted rolling agent id (may exist before `agent` is reattached). */
|
|
178
|
+
rollingAgentId;
|
|
179
|
+
/** Epoch ms the current rolling thread started. */
|
|
180
|
+
threadStartedAt;
|
|
181
|
+
/** Serializes turns so two phone messages never share one agent send. */
|
|
182
|
+
turnChain = Promise.resolve();
|
|
166
183
|
constructor(opts) {
|
|
167
184
|
this.opts = opts;
|
|
185
|
+
if (opts.rolling) {
|
|
186
|
+
const saved = loadRollingSession(opts.sessionPath);
|
|
187
|
+
// Only reuse a saved thread if it matches the current cwd (a different
|
|
188
|
+
// project should get its own conversation).
|
|
189
|
+
if (saved && saved.cwd === opts.cwd) {
|
|
190
|
+
this.rollingAgentId = saved.agentId;
|
|
191
|
+
this.threadStartedAt = saved.startedAt;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
168
194
|
}
|
|
169
195
|
/** True when a Cursor API key is configured. */
|
|
170
196
|
get enabled() {
|
|
171
197
|
return this.opts.apiKey.trim() !== "";
|
|
172
198
|
}
|
|
199
|
+
/** Info about the active rolling thread, if rolling is on and started. */
|
|
200
|
+
rollingInfo() {
|
|
201
|
+
if (!this.opts.rolling)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (!this.rollingAgentId)
|
|
204
|
+
return undefined;
|
|
205
|
+
return { agentId: this.rollingAgentId, startedAt: this.threadStartedAt ?? 0 };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get the rolling agent, creating it (first ever turn) or resuming a saved
|
|
209
|
+
* thread (after a worker restart). Reuses the live instance otherwise.
|
|
210
|
+
*/
|
|
211
|
+
async getRollingAgent() {
|
|
212
|
+
if (this.agent)
|
|
213
|
+
return this.agent;
|
|
214
|
+
const { Agent } = await loadSdk();
|
|
215
|
+
const local = { cwd: this.opts.cwd, settingSources: this.opts.settingSources };
|
|
216
|
+
if (this.rollingAgentId) {
|
|
217
|
+
try {
|
|
218
|
+
this.agent = await Agent.resume(this.rollingAgentId, {
|
|
219
|
+
apiKey: this.opts.apiKey,
|
|
220
|
+
model: { id: this.opts.model },
|
|
221
|
+
local,
|
|
222
|
+
});
|
|
223
|
+
return this.agent;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// The saved thread could not be resumed (e.g. pruned); start fresh.
|
|
227
|
+
this.rollingAgentId = undefined;
|
|
228
|
+
this.threadStartedAt = undefined;
|
|
229
|
+
clearRollingSession(this.opts.sessionPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
this.agent = await Agent.create({
|
|
233
|
+
apiKey: this.opts.apiKey,
|
|
234
|
+
model: { id: this.opts.model },
|
|
235
|
+
local,
|
|
236
|
+
});
|
|
237
|
+
this.rollingAgentId = this.agent.agentId;
|
|
238
|
+
this.threadStartedAt = Date.now();
|
|
239
|
+
saveRollingSession(this.opts.sessionPath, {
|
|
240
|
+
agentId: this.rollingAgentId,
|
|
241
|
+
model: this.opts.model,
|
|
242
|
+
cwd: this.opts.cwd,
|
|
243
|
+
startedAt: this.threadStartedAt,
|
|
244
|
+
});
|
|
245
|
+
return this.agent;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Run one prompt. In rolling mode this reuses the persistent agent (so the
|
|
249
|
+
* turn remembers all prior turns); otherwise it uses a fresh stateless agent.
|
|
250
|
+
* Turns are serialized to keep the single agent consistent.
|
|
251
|
+
*/
|
|
252
|
+
async runTurn(promptText, attachments) {
|
|
253
|
+
if (!this.opts.rolling) {
|
|
254
|
+
return runStatelessPrompt(promptText, this.opts, attachments);
|
|
255
|
+
}
|
|
256
|
+
const run = this.turnChain.then(async () => {
|
|
257
|
+
const message = buildUserMessage(promptText, attachments);
|
|
258
|
+
const agent = await this.getRollingAgent();
|
|
259
|
+
const r = await agent.send(message);
|
|
260
|
+
await r.wait();
|
|
261
|
+
return { status: r.status, result: r.result };
|
|
262
|
+
});
|
|
263
|
+
// Keep the chain alive even if this turn rejects, without swallowing the
|
|
264
|
+
// error for the caller.
|
|
265
|
+
this.turnChain = run.catch(() => undefined);
|
|
266
|
+
return run;
|
|
267
|
+
}
|
|
268
|
+
/** Start a fresh rolling thread (drops memory of the current conversation). */
|
|
269
|
+
reset() {
|
|
270
|
+
if (this.agent) {
|
|
271
|
+
try {
|
|
272
|
+
this.agent.close();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// best-effort
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
this.agent = undefined;
|
|
279
|
+
this.rollingAgentId = undefined;
|
|
280
|
+
this.threadStartedAt = undefined;
|
|
281
|
+
clearRollingSession(this.opts.sessionPath);
|
|
282
|
+
}
|
|
173
283
|
/** Most recent session waiting for a YES/NO approval, if any. */
|
|
174
284
|
latestAwaitingApproval() {
|
|
175
285
|
let latest;
|
|
@@ -223,7 +333,7 @@ export class CommandRunner {
|
|
|
223
333
|
this.sessions.set(session.id, session);
|
|
224
334
|
onSession?.(session);
|
|
225
335
|
try {
|
|
226
|
-
const result = await
|
|
336
|
+
const result = await this.runTurn(PLAN_PROMPT(task), attachments);
|
|
227
337
|
if (result.status === "error") {
|
|
228
338
|
session.status = "error";
|
|
229
339
|
session.error = coerceText(result.result) || "plan run failed";
|
|
@@ -254,7 +364,7 @@ export class CommandRunner {
|
|
|
254
364
|
this.askSessions.set(session.id, session);
|
|
255
365
|
onSession?.(session);
|
|
256
366
|
try {
|
|
257
|
-
const result = await
|
|
367
|
+
const result = await this.runTurn(ASK_PROMPT(question), attachments);
|
|
258
368
|
if (result.status === "error") {
|
|
259
369
|
session.status = "error";
|
|
260
370
|
session.error = coerceText(result.result) || "ask run failed";
|
|
@@ -284,7 +394,7 @@ export class CommandRunner {
|
|
|
284
394
|
session.updatedAt = Date.now();
|
|
285
395
|
onExecuting?.(session);
|
|
286
396
|
try {
|
|
287
|
-
const result = await
|
|
397
|
+
const result = await this.runTurn(EXECUTE_PROMPT(session.task, session.plan ?? ""));
|
|
288
398
|
if (result.status === "error") {
|
|
289
399
|
session.status = "error";
|
|
290
400
|
session.error = coerceText(result.result) || "execution run failed";
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* cursor-telegram-mcp setup Interactive first-time setup (bot + chat id).
|
|
8
8
|
* cursor-telegram-mcp login Find your chat id (token must be set).
|
|
9
9
|
* cursor-telegram-mcp worker Run the background worker in the foreground.
|
|
10
|
+
* cursor-telegram-mcp install Install the always-on worker (macOS launchd).
|
|
11
|
+
* cursor-telegram-mcp uninstall Remove the always-on worker.
|
|
10
12
|
* cursor-telegram-mcp doctor Diagnose configuration / connectivity.
|
|
11
13
|
*
|
|
12
14
|
* Each subcommand is a module whose side-effecting `main()` runs on import.
|
|
@@ -20,6 +22,8 @@ function printHelp() {
|
|
|
20
22
|
" cursor-telegram-mcp setup First-time setup: create/link your bot",
|
|
21
23
|
" cursor-telegram-mcp login Print your Telegram chat id",
|
|
22
24
|
" cursor-telegram-mcp worker Run the background worker in the foreground",
|
|
25
|
+
" cursor-telegram-mcp install Install the always-on worker (macOS launchd)",
|
|
26
|
+
" cursor-telegram-mcp uninstall Remove the always-on worker",
|
|
23
27
|
" cursor-telegram-mcp doctor Diagnose configuration and connectivity",
|
|
24
28
|
" cursor-telegram-mcp help Show this help",
|
|
25
29
|
"",
|
|
@@ -39,6 +43,10 @@ async function run() {
|
|
|
39
43
|
case "worker":
|
|
40
44
|
await import("./worker.js");
|
|
41
45
|
break;
|
|
46
|
+
case "install":
|
|
47
|
+
case "uninstall":
|
|
48
|
+
await import("./install.js");
|
|
49
|
+
break;
|
|
42
50
|
case "setup":
|
|
43
51
|
await import("./setup.js");
|
|
44
52
|
break;
|
package/dist/config.js
CHANGED
|
@@ -15,11 +15,21 @@
|
|
|
15
15
|
* MCP client needs:
|
|
16
16
|
* TG_WORKER_URL - where the worker listens (default http://127.0.0.1:8787).
|
|
17
17
|
* TG_PROJECT - label for this project's messages (default "default").
|
|
18
|
+
* TG_AGENT - optional default per-agent/chat label, shown as
|
|
19
|
+
* [project · agent]. Overridden by the `agent` tool param.
|
|
18
20
|
* TG_DEFAULT_POLL_WAIT_MS - long-poll chunk size check_human_response uses
|
|
19
21
|
* when no explicit waitMs is passed (default 120000).
|
|
22
|
+
* TG_STARTUP_PING - set to 0/false to silence the "worker online" message the
|
|
23
|
+
* worker sends after it connects (default on).
|
|
20
24
|
*
|
|
21
25
|
* Command mode (optional, worker-side) needs:
|
|
22
26
|
* CURSOR_API_KEY - enables texting tasks to the bot to run headless agents.
|
|
27
|
+
* TG_COMMAND_MODE - set to 0/false to hard-disable command mode even when a
|
|
28
|
+
* CURSOR_API_KEY is present (inbound text won't spawn tasks).
|
|
29
|
+
* TG_SELF_UPDATE - set to 0/false so the worker never auto-restarts itself when
|
|
30
|
+
* its own source changes (prevents wiping in-flight state).
|
|
31
|
+
* TG_AGENT_CWD - point command-mode agents at a SEPARATE repo so their edits
|
|
32
|
+
* never touch the worker's own source (avoids self-restarts).
|
|
23
33
|
* TG_AGENT_CWD - directory the headless agent works in (default cwd).
|
|
24
34
|
* TG_AGENT_MODEL - model id for the headless agent (default composer-2.5).
|
|
25
35
|
* TG_AGENT_LOAD_SETTINGS - "1"/"true" to load this repo's .cursor settings.
|
|
@@ -141,6 +151,7 @@ export function getConfig(requireToken = true) {
|
|
|
141
151
|
}
|
|
142
152
|
const workerHost = strOr("TG_WORKER_HOST", "127.0.0.1");
|
|
143
153
|
const workerPort = intOr("TG_WORKER_PORT", 8787);
|
|
154
|
+
const agentCwd = strOr("TG_AGENT_CWD", process.cwd());
|
|
144
155
|
cached = {
|
|
145
156
|
botToken,
|
|
146
157
|
chatId: strOr("TELEGRAM_CHAT_ID", ""),
|
|
@@ -150,11 +161,18 @@ export function getConfig(requireToken = true) {
|
|
|
150
161
|
workerPort,
|
|
151
162
|
workerUrl: strOr("TG_WORKER_URL", `http://${workerHost}:${workerPort}`).replace(/\/+$/, ""),
|
|
152
163
|
project: strOr("TG_PROJECT", "default"),
|
|
164
|
+
agentLabel: strOr("TG_AGENT", ""),
|
|
153
165
|
defaultPollWaitMs: intOr("TG_DEFAULT_POLL_WAIT_MS", 120_000),
|
|
166
|
+
startupPing: boolOr("TG_STARTUP_PING", true),
|
|
154
167
|
cursorApiKey: strOr("CURSOR_API_KEY", ""),
|
|
155
|
-
|
|
168
|
+
commandModeEnabled: boolOr("TG_COMMAND_MODE", true),
|
|
169
|
+
selfUpdateEnabled: boolOr("TG_SELF_UPDATE", true),
|
|
170
|
+
agentCwd,
|
|
156
171
|
agentModel: strOr("TG_AGENT_MODEL", "composer-2.5"),
|
|
157
172
|
agentLoadSettings: boolOr("TG_AGENT_LOAD_SETTINGS", false),
|
|
173
|
+
rolling: boolOr("TG_ROLLING", true),
|
|
174
|
+
transcriptPath: strOr("TG_TRANSCRIPT_PATH", join(agentCwd, "remote-chat.md")),
|
|
175
|
+
sessionPath: strOr("TG_SESSION_PATH", join(configDir(), "rolling-session.json")),
|
|
158
176
|
};
|
|
159
177
|
return cached;
|
|
160
178
|
}
|
package/dist/formatTelegram.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bracketed prefix for outgoing messages: "[project]" or "[project · agent]"
|
|
3
|
+
* when a per-agent/chat label is present. Lets the human tell which agent a
|
|
4
|
+
* question/notification came from when several chats are active.
|
|
5
|
+
*/
|
|
6
|
+
export function labelPrefix(project, agent) {
|
|
7
|
+
return agent && agent !== "" ? `[${project} · ${agent}]` : `[${project}]`;
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Convert agent Markdown to plain text suitable for Telegram (no parse_mode).
|
|
3
11
|
*/
|
package/dist/index.js
CHANGED
|
@@ -173,9 +173,18 @@ async function main() {
|
|
|
173
173
|
.string()
|
|
174
174
|
.min(1)
|
|
175
175
|
.describe("Short summary of the completed work (what changed / result)."),
|
|
176
|
+
agent: z
|
|
177
|
+
.string()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe("Optional short label for THIS chat/agent (e.g. \"auth-refactor\"), shown " +
|
|
180
|
+
"as [project · agent] so multiple agents are distinguishable."),
|
|
176
181
|
},
|
|
177
|
-
}, async ({ summary }) => {
|
|
178
|
-
const r = await callWorker("POST", "/notify", {
|
|
182
|
+
}, async ({ summary, agent }) => {
|
|
183
|
+
const r = await callWorker("POST", "/notify", {
|
|
184
|
+
summary,
|
|
185
|
+
project: config.project,
|
|
186
|
+
agent: (agent ?? "").trim() || config.agentLabel,
|
|
187
|
+
});
|
|
179
188
|
if (!r)
|
|
180
189
|
return textResult(WORKER_DOWN, true);
|
|
181
190
|
if (r.status === 200)
|
|
@@ -203,9 +212,19 @@ async function main() {
|
|
|
203
212
|
.min(1)
|
|
204
213
|
.describe("The exact question to ask the human. Be specific and include the " +
|
|
205
214
|
"options/context needed to answer from a phone."),
|
|
215
|
+
agent: z
|
|
216
|
+
.string()
|
|
217
|
+
.optional()
|
|
218
|
+
.describe("Optional short label for THIS chat/agent (e.g. \"auth-refactor\"), shown " +
|
|
219
|
+
"as [project · agent] Q-n so the human can tell which agent is asking " +
|
|
220
|
+
"and route their reply. Keep it stable across the chat."),
|
|
206
221
|
},
|
|
207
|
-
}, async ({ question }) => {
|
|
208
|
-
const r = await callWorker("POST", "/ask", {
|
|
222
|
+
}, async ({ question, agent }) => {
|
|
223
|
+
const r = await callWorker("POST", "/ask", {
|
|
224
|
+
question,
|
|
225
|
+
project: config.project,
|
|
226
|
+
agent: (agent ?? "").trim() || config.agentLabel,
|
|
227
|
+
});
|
|
209
228
|
if (!r)
|
|
210
229
|
return textResult(WORKER_DOWN, true);
|
|
211
230
|
if (r.status === 200) {
|
|
@@ -237,6 +256,12 @@ async function main() {
|
|
|
237
256
|
.min(1)
|
|
238
257
|
.describe("The exact question to ask the human. Be specific and include the " +
|
|
239
258
|
"options/context needed to answer from a phone."),
|
|
259
|
+
agent: z
|
|
260
|
+
.string()
|
|
261
|
+
.optional()
|
|
262
|
+
.describe("Optional short label for THIS chat/agent (e.g. \"auth-refactor\"), shown " +
|
|
263
|
+
"as [project · agent] Q-n so the human can tell which agent is asking " +
|
|
264
|
+
"and route their reply. Keep it stable across the chat."),
|
|
240
265
|
timeoutMin: z
|
|
241
266
|
.number()
|
|
242
267
|
.int()
|
|
@@ -245,9 +270,13 @@ async function main() {
|
|
|
245
270
|
.describe("Maximum minutes to wait for a reply (default: TG_RESPONSE_TIMEOUT_MIN, " +
|
|
246
271
|
"usually 30). Returns timed_out if no reply within this window."),
|
|
247
272
|
},
|
|
248
|
-
}, async ({ question, timeoutMin }) => {
|
|
273
|
+
}, async ({ question, agent, timeoutMin }) => {
|
|
249
274
|
const maxWaitMin = timeoutMin ?? config.responseTimeoutMin;
|
|
250
|
-
const askRes = await callWorker("POST", "/ask", {
|
|
275
|
+
const askRes = await callWorker("POST", "/ask", {
|
|
276
|
+
question,
|
|
277
|
+
project: config.project,
|
|
278
|
+
agent: (agent ?? "").trim() || config.agentLabel,
|
|
279
|
+
});
|
|
251
280
|
if (!askRes)
|
|
252
281
|
return textResult(WORKER_DOWN, true);
|
|
253
282
|
if (askRes.status === 503) {
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursor-telegram-mcp install` / `uninstall` — always-on worker on macOS.
|
|
3
|
+
*
|
|
4
|
+
* Generates a per-user launchd agent (no hardcoded paths) so the background
|
|
5
|
+
* worker starts at login, restarts if it crashes, and keeps the Mac awake
|
|
6
|
+
* enough to deliver Telegram messages. Designed for the npm-distributed
|
|
7
|
+
* package: the plist points at the Node that is running this command and the
|
|
8
|
+
* installed CLI, and config is read from `<configDir>/config.json` (written by
|
|
9
|
+
* `setup`), so nothing is tied to a checkout location.
|
|
10
|
+
*
|
|
11
|
+
* macOS only for now; on other platforms it prints guidance and exits.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir, platform } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
const LABEL = "com.cursor-telegram.worker";
|
|
19
|
+
/** Older label used by the in-repo dev scripts; booted out on install to avoid a double-run. */
|
|
20
|
+
const LEGACY_LABEL = "com.cursor-remote-chat.worker";
|
|
21
|
+
function plistPath() {
|
|
22
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
23
|
+
}
|
|
24
|
+
function logPaths() {
|
|
25
|
+
const dir = join(homedir(), "Library", "Logs");
|
|
26
|
+
return {
|
|
27
|
+
out: join(dir, "cursor-telegram-worker.log"),
|
|
28
|
+
err: join(dir, "cursor-telegram-worker.err.log"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function xmlEscape(s) {
|
|
32
|
+
return s
|
|
33
|
+
.replace(/&/g, "&")
|
|
34
|
+
.replace(/</g, "<")
|
|
35
|
+
.replace(/>/g, ">");
|
|
36
|
+
}
|
|
37
|
+
/** Absolute path to the installed CLI entry (dist/cli.js) running this code. */
|
|
38
|
+
function cliEntry() {
|
|
39
|
+
return fileURLToPath(import.meta.url).replace(/install\.js$/, "cli.js");
|
|
40
|
+
}
|
|
41
|
+
function buildProgramArguments() {
|
|
42
|
+
const node = process.execPath;
|
|
43
|
+
const cli = cliEntry();
|
|
44
|
+
const caffeinate = "/usr/bin/caffeinate";
|
|
45
|
+
// caffeinate -ims keeps the system + display awake while the worker runs so
|
|
46
|
+
// Telegram delivery is never blocked by idle sleep (AC power still required
|
|
47
|
+
// for a fully closed-lid Mac, which we document in the README).
|
|
48
|
+
if (existsSync(caffeinate)) {
|
|
49
|
+
return [caffeinate, "-ims", node, cli, "worker"];
|
|
50
|
+
}
|
|
51
|
+
return [node, cli, "worker"];
|
|
52
|
+
}
|
|
53
|
+
function buildPlist() {
|
|
54
|
+
const args = buildProgramArguments();
|
|
55
|
+
const { out, err } = logPaths();
|
|
56
|
+
const nodeDir = dirname(process.execPath);
|
|
57
|
+
const pathEnv = [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":");
|
|
58
|
+
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
59
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
60
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
61
|
+
<plist version="1.0">
|
|
62
|
+
<dict>
|
|
63
|
+
<key>Label</key>
|
|
64
|
+
<string>${LABEL}</string>
|
|
65
|
+
<key>ProgramArguments</key>
|
|
66
|
+
<array>
|
|
67
|
+
${argXml}
|
|
68
|
+
</array>
|
|
69
|
+
<key>WorkingDirectory</key>
|
|
70
|
+
<string>${xmlEscape(homedir())}</string>
|
|
71
|
+
<key>EnvironmentVariables</key>
|
|
72
|
+
<dict>
|
|
73
|
+
<key>PATH</key>
|
|
74
|
+
<string>${xmlEscape(pathEnv)}</string>
|
|
75
|
+
</dict>
|
|
76
|
+
<key>RunAtLoad</key>
|
|
77
|
+
<true/>
|
|
78
|
+
<key>KeepAlive</key>
|
|
79
|
+
<true/>
|
|
80
|
+
<key>ThrottleInterval</key>
|
|
81
|
+
<integer>10</integer>
|
|
82
|
+
<key>ProcessType</key>
|
|
83
|
+
<string>Background</string>
|
|
84
|
+
<key>StandardOutPath</key>
|
|
85
|
+
<string>${xmlEscape(out)}</string>
|
|
86
|
+
<key>StandardErrorPath</key>
|
|
87
|
+
<string>${xmlEscape(err)}</string>
|
|
88
|
+
</dict>
|
|
89
|
+
</plist>
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
function domain() {
|
|
93
|
+
return `gui/${process.getuid?.() ?? 501}`;
|
|
94
|
+
}
|
|
95
|
+
function launchctl(args) {
|
|
96
|
+
try {
|
|
97
|
+
execFileSync("launchctl", args, { stdio: "ignore" });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// bootout of a non-loaded agent is expected to fail; callers tolerate it
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function requireMac() {
|
|
104
|
+
if (platform() !== "darwin") {
|
|
105
|
+
process.stderr.write("Always-on install is macOS only for now.\n" +
|
|
106
|
+
"On other systems, run `cursor-telegram-mcp worker` under your own\n" +
|
|
107
|
+
"process manager (systemd, pm2, a login item, etc.).\n");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function install() {
|
|
112
|
+
requireMac();
|
|
113
|
+
const path = plistPath();
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
mkdirSync(join(homedir(), "Library", "Logs"), { recursive: true });
|
|
116
|
+
writeFileSync(path, buildPlist(), "utf8");
|
|
117
|
+
// Avoid two workers fighting over the port: stop any previous/legacy agent.
|
|
118
|
+
launchctl(["bootout", domain(), path]);
|
|
119
|
+
launchctl(["bootout", domain(), join(homedir(), "Library", "LaunchAgents", `${LEGACY_LABEL}.plist`)]);
|
|
120
|
+
execFileSync("launchctl", ["bootstrap", domain(), path], { stdio: "inherit" });
|
|
121
|
+
const { out } = logPaths();
|
|
122
|
+
process.stdout.write([
|
|
123
|
+
`Installed always-on worker: ${path}`,
|
|
124
|
+
`Logs: ${out}`,
|
|
125
|
+
"",
|
|
126
|
+
"It will start now and at every login. To stop it:",
|
|
127
|
+
" cursor-telegram-mcp uninstall",
|
|
128
|
+
"",
|
|
129
|
+
"Tip: keep the Mac on AC power so it never fully sleeps.",
|
|
130
|
+
"",
|
|
131
|
+
].join("\n"));
|
|
132
|
+
}
|
|
133
|
+
function uninstall() {
|
|
134
|
+
requireMac();
|
|
135
|
+
const path = plistPath();
|
|
136
|
+
launchctl(["bootout", domain(), path]);
|
|
137
|
+
if (existsSync(path))
|
|
138
|
+
rmSync(path);
|
|
139
|
+
process.stdout.write("Uninstalled always-on worker.\n");
|
|
140
|
+
}
|
|
141
|
+
const action = process.argv[2] === "uninstall" ? uninstall : install;
|
|
142
|
+
action();
|
package/dist/parseInbound.js
CHANGED
|
@@ -5,6 +5,18 @@
|
|
|
5
5
|
* /ask or /plan on any line. Reply-to HITL answers should bypass splitting
|
|
6
6
|
* (caller passes the full text as a single answer).
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Parse an explicit question target prefix like "Q-3 ...", "Q3 ...", "#3 ..."
|
|
10
|
+
* or "@Q-3 ...". Returns the normalized question id ("Q-3") and the remaining
|
|
11
|
+
* answer text, or null when the text doesn't start with such a prefix. Used to
|
|
12
|
+
* let a Telegram reply target a specific pending question when several are open.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTargetId(text) {
|
|
15
|
+
const m = text.match(/^\s*@?\s*(?:q-?|#)(\d+)\b[\s:.\-]*([\s\S]*)$/i);
|
|
16
|
+
if (!m)
|
|
17
|
+
return null;
|
|
18
|
+
return { id: `Q-${m[1]}`, rest: (m[2] ?? "").trim() };
|
|
19
|
+
}
|
|
8
20
|
/** Standard plan-approval footer sent after Plan (C-n). */
|
|
9
21
|
const APPROVAL_FOOTER_RE = /^\s*reply\s+yes\s+to\s+run\b[\s\S]*\bno\s+to\s+cancel\.?\s*$/i;
|
|
10
22
|
/** True when text is (or ends with) the plan YES/NO footer — not a new task. */
|
|
@@ -18,7 +30,9 @@ export function isPlanApprovalFooter(text) {
|
|
|
18
30
|
const last = lines[lines.length - 1] ?? "";
|
|
19
31
|
return APPROVAL_FOOTER_RE.test(last);
|
|
20
32
|
}
|
|
21
|
-
const STATUS_RE = /^\s
|
|
33
|
+
const STATUS_RE = /^\s*\/?status\s*$/i;
|
|
34
|
+
const PENDING_RE = /^\s*\/?(pending|questions)\s*$/i;
|
|
35
|
+
const RESET_RE = /^\s*\/?(reset|new)\s*$/i;
|
|
22
36
|
const YES_WORDS = "yes|yea|yeah|y|approve|approved|ok|okay|go|do it|כן|אישור|בצע";
|
|
23
37
|
const NO_WORDS = "no|n|cancel|stop|reject|nope|לא|ביטול|עצור";
|
|
24
38
|
const YES_ONLY_RE = new RegExp(`^\\s*(${YES_WORDS})\\s*[!.]?\\s*$`, "i");
|
|
@@ -52,6 +66,10 @@ function parseCommandLine(part) {
|
|
|
52
66
|
function classifyPart(part) {
|
|
53
67
|
if (STATUS_RE.test(part))
|
|
54
68
|
return [{ kind: "status" }];
|
|
69
|
+
if (PENDING_RE.test(part))
|
|
70
|
+
return [{ kind: "pending" }];
|
|
71
|
+
if (RESET_RE.test(part))
|
|
72
|
+
return [{ kind: "reset" }];
|
|
55
73
|
if (YES_ONLY_RE.test(part))
|
|
56
74
|
return [{ kind: "approve" }];
|
|
57
75
|
if (NO_ONLY_RE.test(part))
|
|
@@ -85,9 +103,23 @@ export function splitInboundMessage(text) {
|
|
|
85
103
|
if (isPlanApprovalFooter(trimmed)) {
|
|
86
104
|
return [{ kind: "approval_footer" }];
|
|
87
105
|
}
|
|
88
|
-
const
|
|
106
|
+
const raw = [];
|
|
89
107
|
for (const part of splitParts(trimmed)) {
|
|
90
|
-
|
|
108
|
+
raw.push(...classifyPart(part));
|
|
109
|
+
}
|
|
110
|
+
// Coalesce consecutive plain segments into ONE task. Without this, a single
|
|
111
|
+
// multi-line free-text paste is split per line into many separate plan tasks
|
|
112
|
+
// (the cause of a runaway where one paste spawned C-1..C-n). Explicit /ask
|
|
113
|
+
// and /plan commands and leading approve/reject stay as their own segments.
|
|
114
|
+
const segments = [];
|
|
115
|
+
for (const seg of raw) {
|
|
116
|
+
const prev = segments[segments.length - 1];
|
|
117
|
+
if (seg.kind === "plain" && prev && prev.kind === "plain") {
|
|
118
|
+
prev.text = `${prev.text}\n${seg.text}`;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
segments.push(seg);
|
|
122
|
+
}
|
|
91
123
|
}
|
|
92
124
|
return segments;
|
|
93
125
|
}
|
package/dist/store.js
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending-question store with optional disk persistence.
|
|
3
|
+
*
|
|
4
|
+
* When a `persistPath` is given, the store is loaded on construction and saved
|
|
5
|
+
* (debounced) on every change, so pending questions survive a worker restart and
|
|
6
|
+
* remain answerable. Without a path it behaves as a pure in-memory store.
|
|
7
|
+
* Answered questions are kept briefly so a poll that arrives after the reply can
|
|
8
|
+
* still read it, then pruned to bound memory (and to bound the persisted file).
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
1
12
|
/** Keep answered questions this long after they are answered, then prune. */
|
|
2
13
|
const ANSWERED_TTL_MS = 60 * 60_000;
|
|
14
|
+
/** Debounce window for writing the persisted store to disk. */
|
|
15
|
+
const SAVE_DEBOUNCE_MS = 200;
|
|
3
16
|
export class QuestionStore {
|
|
4
17
|
nextId = 1;
|
|
5
18
|
questions = new Map();
|
|
19
|
+
persistPath;
|
|
20
|
+
saveTimer;
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
this.persistPath = opts.persistPath;
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
6
25
|
/** Create a new pending question. */
|
|
7
|
-
addQuestion(projectLabel, question) {
|
|
26
|
+
addQuestion(projectLabel, question, agentLabel) {
|
|
8
27
|
this.prune();
|
|
9
28
|
const record = {
|
|
10
29
|
id: `Q-${this.nextId++}`,
|
|
11
30
|
projectLabel,
|
|
31
|
+
agentLabel: agentLabel && agentLabel !== "" ? agentLabel : undefined,
|
|
12
32
|
question,
|
|
13
33
|
status: "pending",
|
|
14
34
|
createdAt: Date.now(),
|
|
15
35
|
};
|
|
16
36
|
this.questions.set(record.id, record);
|
|
37
|
+
this.scheduleSave();
|
|
17
38
|
return record;
|
|
18
39
|
}
|
|
19
40
|
/** Attach the outgoing Telegram message id to a question. */
|
|
20
41
|
setSentMessageId(id, messageId) {
|
|
21
42
|
const record = this.questions.get(id);
|
|
22
|
-
if (record)
|
|
43
|
+
if (record) {
|
|
23
44
|
record.sentMessageId = messageId;
|
|
45
|
+
this.scheduleSave();
|
|
46
|
+
}
|
|
24
47
|
}
|
|
25
48
|
/** Look up a single question by id. */
|
|
26
49
|
get(id) {
|
|
@@ -42,40 +65,102 @@ export class QuestionStore {
|
|
|
42
65
|
}
|
|
43
66
|
/**
|
|
44
67
|
* Match an inbound message to a pending question and record the answer.
|
|
45
|
-
*
|
|
46
|
-
* question
|
|
68
|
+
* Resolution order: an explicit `targetId` > an exact reply-to (swipe) match >
|
|
69
|
+
* the sole pending question. When more than one question is pending and there
|
|
70
|
+
* is no target or reply-to, returns null so the caller can disambiguate (we no
|
|
71
|
+
* longer silently answer the oldest). A `targetId` that is not pending also
|
|
72
|
+
* returns null. `answerText` overrides the recorded answer (used when the id
|
|
73
|
+
* prefix has been stripped from the inbound text).
|
|
47
74
|
*/
|
|
48
|
-
matchAndAnswer(incoming) {
|
|
75
|
+
matchAndAnswer(incoming, opts = {}) {
|
|
49
76
|
const pendings = [...this.questions.values()]
|
|
50
77
|
.filter((q) => q.status === "pending")
|
|
51
78
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
52
79
|
if (pendings.length === 0)
|
|
53
80
|
return null;
|
|
54
81
|
let target;
|
|
55
|
-
if (
|
|
82
|
+
if (opts.targetId) {
|
|
83
|
+
target = pendings.find((q) => q.id === opts.targetId);
|
|
84
|
+
if (!target)
|
|
85
|
+
return null; // unknown / already-answered target
|
|
86
|
+
}
|
|
87
|
+
else if (incoming.quotedMessageId) {
|
|
56
88
|
target = pendings.find((q) => q.sentMessageId && q.sentMessageId === incoming.quotedMessageId);
|
|
57
89
|
}
|
|
58
|
-
if (!target)
|
|
59
|
-
|
|
90
|
+
if (!target) {
|
|
91
|
+
if (pendings.length === 1)
|
|
92
|
+
target = pendings[0];
|
|
93
|
+
else
|
|
94
|
+
return null; // ambiguous: caller disambiguates
|
|
95
|
+
}
|
|
60
96
|
target.status = "answered";
|
|
61
|
-
target.answer = incoming.text;
|
|
97
|
+
target.answer = opts.answerText ?? incoming.text;
|
|
62
98
|
if (incoming.attachments.length > 0) {
|
|
63
99
|
target.answerAttachments = incoming.attachments;
|
|
64
100
|
}
|
|
65
101
|
target.answeredAt = incoming.timestamp || Date.now();
|
|
102
|
+
this.scheduleSave();
|
|
66
103
|
return target;
|
|
67
104
|
}
|
|
68
105
|
/** Drop answered questions older than the TTL. */
|
|
69
106
|
prune() {
|
|
70
107
|
const cutoff = Date.now() - ANSWERED_TTL_MS;
|
|
108
|
+
let removed = false;
|
|
71
109
|
for (const [id, q] of this.questions) {
|
|
72
110
|
if (q.status === "answered" && (q.answeredAt ?? q.createdAt) < cutoff) {
|
|
73
111
|
this.questions.delete(id);
|
|
112
|
+
removed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (removed)
|
|
116
|
+
this.scheduleSave();
|
|
117
|
+
}
|
|
118
|
+
/** Load persisted questions (best-effort) on startup. */
|
|
119
|
+
load() {
|
|
120
|
+
if (!this.persistPath)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const data = JSON.parse(readFileSync(this.persistPath, "utf8"));
|
|
124
|
+
if (Array.isArray(data.questions)) {
|
|
125
|
+
for (const q of data.questions) {
|
|
126
|
+
if (q && typeof q.id === "string")
|
|
127
|
+
this.questions.set(q.id, q);
|
|
128
|
+
}
|
|
74
129
|
}
|
|
130
|
+
if (typeof data.nextId === "number" && data.nextId > this.nextId) {
|
|
131
|
+
this.nextId = data.nextId;
|
|
132
|
+
}
|
|
133
|
+
this.prune();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// No (or unreadable) persisted state: start fresh.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Debounced write so bursts of changes coalesce into one disk write. */
|
|
140
|
+
scheduleSave() {
|
|
141
|
+
if (!this.persistPath || this.saveTimer)
|
|
142
|
+
return;
|
|
143
|
+
this.saveTimer = setTimeout(() => {
|
|
144
|
+
this.saveTimer = undefined;
|
|
145
|
+
this.saveNow();
|
|
146
|
+
}, SAVE_DEBOUNCE_MS);
|
|
147
|
+
this.saveTimer.unref?.();
|
|
148
|
+
}
|
|
149
|
+
/** Write the current state to disk now (best-effort). */
|
|
150
|
+
saveNow() {
|
|
151
|
+
if (!this.persistPath)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(this.persistPath), { recursive: true });
|
|
155
|
+
const data = { nextId: this.nextId, questions: [...this.questions.values()] };
|
|
156
|
+
writeFileSync(this.persistPath, JSON.stringify(data), "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Best-effort persistence: a failed write must not break the worker.
|
|
75
160
|
}
|
|
76
161
|
}
|
|
77
162
|
}
|
|
78
|
-
/** Build a
|
|
79
|
-
export function createStore() {
|
|
80
|
-
return new QuestionStore();
|
|
163
|
+
/** Build a store, optionally backed by a JSON file at `opts.persistPath`. */
|
|
164
|
+
export function createStore(opts = {}) {
|
|
165
|
+
return new QuestionStore(opts);
|
|
81
166
|
}
|
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,9 @@ 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
|
+
lastError: lastError ?? null,
|
|
524
656
|
});
|
|
525
657
|
}
|
|
526
658
|
if (method === "POST" && path === "/notify") {
|
|
@@ -534,9 +666,10 @@ async function main() {
|
|
|
534
666
|
const body = await readJson(req);
|
|
535
667
|
const summary = String(body.summary ?? "").trim();
|
|
536
668
|
const project = cleanProject(body.project);
|
|
669
|
+
const agent = cleanAgent(body.agent);
|
|
537
670
|
if (!summary)
|
|
538
671
|
return sendJson(res, 400, { error: "summary is required" });
|
|
539
|
-
await send(
|
|
672
|
+
await send(`${labelPrefix(project, agent)} Task complete\n\n${summary}`);
|
|
540
673
|
return sendJson(res, 200, { ok: true });
|
|
541
674
|
}
|
|
542
675
|
if (method === "POST" && (path === "/ask" || path === "/mirror")) {
|
|
@@ -550,9 +683,10 @@ async function main() {
|
|
|
550
683
|
const body = await readJson(req);
|
|
551
684
|
const question = String(body.question ?? "").trim();
|
|
552
685
|
const project = cleanProject(body.project);
|
|
686
|
+
const agent = cleanAgent(body.agent);
|
|
553
687
|
if (!question)
|
|
554
688
|
return sendJson(res, 400, { error: "question is required" });
|
|
555
|
-
const { id, mirrored } = await mirrorQuestion(project, question);
|
|
689
|
+
const { id, mirrored } = await mirrorQuestion(project, question, agent);
|
|
556
690
|
return sendJson(res, 200, { id, mirrored: path === "/mirror" ? mirrored : true });
|
|
557
691
|
}
|
|
558
692
|
if (method === "GET" && path.startsWith("/response/")) {
|
|
@@ -616,6 +750,7 @@ async function main() {
|
|
|
616
750
|
return sendJson(res, 404, { error: "not found" });
|
|
617
751
|
}
|
|
618
752
|
catch (err) {
|
|
753
|
+
lastError = `http: ${String(err)}`;
|
|
619
754
|
return sendJson(res, 500, { error: String(err) });
|
|
620
755
|
}
|
|
621
756
|
});
|
|
@@ -633,6 +768,13 @@ async function main() {
|
|
|
633
768
|
const sweep = setInterval(() => runner.sweepStale(COMMAND_TTL_MS), 10 * 60_000);
|
|
634
769
|
sweep.unref?.();
|
|
635
770
|
await client.connect().catch((err) => log(`Telegram connection error: ${String(err)}`));
|
|
771
|
+
// Startup ping: let the human know the worker is back online (opt-out via
|
|
772
|
+
// TG_STARTUP_PING=0). Includes restored pending-question count so a restart
|
|
773
|
+
// that recovered state is visible.
|
|
774
|
+
if (config.startupPing && client.isOpen() && config.chatId !== "") {
|
|
775
|
+
const restored = restoredPending > 0 ? `, restored ${restoredPending} pending` : "";
|
|
776
|
+
await send(`Worker online on ${hostname()} (v${WORKER_VERSION}). Command mode ${commandMode ? "ON" : "OFF"}${restored}.`);
|
|
777
|
+
}
|
|
636
778
|
const shutdown = () => {
|
|
637
779
|
log("Shutting down...");
|
|
638
780
|
server.close();
|
|
@@ -643,23 +785,29 @@ async function main() {
|
|
|
643
785
|
// Self-update: if a task edits the worker's own source, relaunch on the new
|
|
644
786
|
// code once nothing is in flight. Only effective under a KeepAlive supervisor
|
|
645
787
|
// (launchd `npm run worker:install`), which restarts the process after exit.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
788
|
+
// Disabled when TG_SELF_UPDATE is off: the worker then never restarts itself on
|
|
789
|
+
// source changes, so in-flight command-mode state can't be wiped mid-task (you
|
|
790
|
+
// restart manually to pick up new code).
|
|
791
|
+
if (config.selfUpdateEnabled) {
|
|
792
|
+
const startFingerprint = sourceFingerprint();
|
|
793
|
+
const isIdleForRestart = () => !isWorkerBusy() &&
|
|
649
794
|
!draining &&
|
|
650
795
|
taskQueue.length() === 0 &&
|
|
651
796
|
runner.listAwaitingApproval().length === 0 &&
|
|
652
797
|
runner.listActiveAsks().length === 0 &&
|
|
653
|
-
store.pendingCount() === 0
|
|
798
|
+
store.pendingCount() === 0;
|
|
799
|
+
const updateCheck = setInterval(() => {
|
|
800
|
+
if (sourceFingerprint() > startFingerprint && isIdleForRestart()) {
|
|
801
|
+
log("Source changed and worker idle; restarting to load the latest version...");
|
|
802
|
+
clearInterval(updateCheck);
|
|
803
|
+
shutdown();
|
|
804
|
+
}
|
|
805
|
+
}, UPDATE_CHECK_MS);
|
|
806
|
+
updateCheck.unref?.();
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
log("Self-update OFF (TG_SELF_UPDATE). Worker will not auto-restart on source changes.");
|
|
654
810
|
}
|
|
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
811
|
}
|
|
664
812
|
main().catch((err) => {
|
|
665
813
|
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.6.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",
|