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 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 kept in memory only.
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["in-memory question 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
- and send `status` to see what's running. Command mode needs the Cursor CLI
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. Sessions are in memory only.
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, run it under your OS
140
- service manager (launchd / systemd / Task Scheduler) calling
141
- `cursor-telegram-mcp worker`. (Note: on macOS, launchd agents cannot read files
142
- under `~/Desktop`/`~/Documents`/`~/Downloads`; install the package or project
143
- outside those folders.)
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. Questions are in memory, so a worker restart forgets any
247
- unanswered question (by design).
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
- store.ts # in-memory pending-question store + reply matching
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/
@@ -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()` runs a read-only Cursor agent that produces a step-by-step PLAN
59
- * only (no file changes) and returns it for you to review on your phone.
60
- * 3. You reply YES -> `approve()` runs a second agent that executes the
61
- * approved plan and reports the result. Reply NO -> `reject()` drops it.
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. Sessions live in memory only.
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
- async function runAgentPrompt(promptText, opts, attachments) {
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 runAgentPrompt(PLAN_PROMPT(task), this.opts, attachments);
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 runAgentPrompt(ASK_PROMPT(question), this.opts, attachments);
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 runAgentPrompt(EXECUTE_PROMPT(session.task, session.plan ?? ""), this.opts);
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
- agentCwd: strOr("TG_AGENT_CWD", process.cwd()),
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
  }
@@ -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", { summary, project: config.project });
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", { question, project: config.project });
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", { question, project: config.project });
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) {
@@ -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, "&amp;")
34
+ .replace(/</g, "&lt;")
35
+ .replace(/>/g, "&gt;");
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();
@@ -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*status\s*$/i;
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 segments = [];
106
+ const raw = [];
89
107
  for (const part of splitParts(trimmed)) {
90
- segments.push(...classifyPart(part));
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
- * Prefers an exact reply-to match; otherwise answers the oldest pending
46
- * question (FIFO). Returns the answered record, or null if nothing matched.
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 (incoming.quotedMessageId) {
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
- target = pendings[0];
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 fresh in-memory store. */
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 { toPlainTelegram } from "./formatTelegram.js";
37
- import { splitInboundMessage } from "./parseInbound.js";
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
- : "Command mode OFF (set CURSOR_API_KEY to text tasks to the bot).");
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 = store.listPending();
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} (${pending.map((q) => q.id).join(", ")})`);
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
- log(`Recorded answer for ${matched.id} [${matched.projectLabel}].`);
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
- if (store.pendingCount() > 0 && !isCommandLike(splitInboundMessage(text))) {
459
- const matched = store.matchAndAnswer(msg);
460
- if (matched) {
461
- log(`Recorded answer for ${matched.id} [${matched.projectLabel}].`);
462
- notifyAnswered(matched.id);
463
- await send(`Answer recorded for ${matched.id}.`);
464
- return;
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) => log(`handleIncoming error: ${String(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 = `[${project}] ${record.id}\n${question}\n\nReply to this message to answer.`;
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(`[${project}] Task complete\n\n${summary}`);
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
- const startFingerprint = sourceFingerprint();
647
- function isIdleForRestart() {
648
- return (!isWorkerBusy() &&
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.5.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",