cursor-telegram-mcp 0.5.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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/agentRunner.js +332 -0
- package/dist/answerWaiters.js +64 -0
- package/dist/cli.js +66 -0
- package/dist/config.js +160 -0
- package/dist/doctor.js +116 -0
- package/dist/formatTelegram.js +28 -0
- package/dist/index.js +334 -0
- package/dist/login.js +59 -0
- package/dist/parseInbound.js +93 -0
- package/dist/session.js +49 -0
- package/dist/setup.js +127 -0
- package/dist/splitMessage.js +61 -0
- package/dist/store.js +81 -0
- package/dist/taskQueue.js +33 -0
- package/dist/telegram.js +241 -0
- package/dist/transcript.js +56 -0
- package/dist/worker.js +667 -0
- package/mcp.client.template.json +12 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuval Ikobson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# cursor-telegram-mcp
|
|
2
|
+
|
|
3
|
+
Manage Cursor from your phone over Telegram. A local [MCP](https://modelcontextprotocol.io)
|
|
4
|
+
server plus an auto-started background worker that lets a Cursor agent talk to
|
|
5
|
+
you on Telegram, and lets you drive work from your phone:
|
|
6
|
+
|
|
7
|
+
- When the agent finishes a task, it messages you (finish notification).
|
|
8
|
+
- When the agent is blocked on a decision, it mirrors the question to Telegram
|
|
9
|
+
and keeps working / waiting until your reply comes back.
|
|
10
|
+
- Command mode: text a task to the bot and a headless Cursor agent plans it,
|
|
11
|
+
sends you the plan, and runs it once you reply `YES` - so you can leave the
|
|
12
|
+
laptop on the charger and drive work entirely from your phone.
|
|
13
|
+
|
|
14
|
+
Local and bring-your-own-bot: each person installs the package, creates their
|
|
15
|
+
own Telegram bot, and everything runs on their own machine. There is no shared
|
|
16
|
+
server and nothing shared between users. It uses the official
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Add to Cursor (one click)
|
|
21
|
+
|
|
22
|
+
[](https://cursor.com/install-mcp?name=cursor-telegram-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImN1cnNvci10ZWxlZ3JhbS1tY3AiXX0%3D)
|
|
23
|
+
|
|
24
|
+
This adds the server to your global `~/.cursor/mcp.json` (the "Installed MCP
|
|
25
|
+
Servers" list in Cursor Settings → Tools & MCP). It runs `npx -y
|
|
26
|
+
cursor-telegram-mcp`, so it requires the package to be published to npm. After
|
|
27
|
+
adding, run `npx cursor-telegram-mcp setup` once to connect your bot.
|
|
28
|
+
|
|
29
|
+
Prefer it under "Plugin MCP Servers" / the Cursor marketplace? See
|
|
30
|
+
[As a Cursor plugin](#as-a-cursor-plugin) below.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
### 1. Configure your bot (one time)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx cursor-telegram-mcp setup
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The wizard walks you through creating a bot with [@BotFather](https://t.me/BotFather),
|
|
41
|
+
validates the token, captures your chat id (you message the bot once), and
|
|
42
|
+
optionally enables command mode with a Cursor API key. It saves everything to a
|
|
43
|
+
local config file (`~/.config/cursor-telegram/config.json`, or
|
|
44
|
+
`%APPDATA%\cursor-telegram\config.json` on Windows).
|
|
45
|
+
|
|
46
|
+
### 2. Add it to Cursor
|
|
47
|
+
|
|
48
|
+
Add this to your project's `.cursor/mcp.json` (or Cursor Settings -> Tools & MCP):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"telegram": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "cursor-telegram-mcp"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reload MCP in Cursor. That's it - the MCP server auto-starts the background
|
|
62
|
+
worker. Run `npx cursor-telegram-mcp doctor` anytime to check status.
|
|
63
|
+
|
|
64
|
+
## Architecture
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
flowchart TD
|
|
68
|
+
cursor["Cursor agent"] -->|"MCP stdio (npx)"| mcp["MCP server (thin) dist/index.js"]
|
|
69
|
+
mcp -->|"GET /health"| check{"worker up?"}
|
|
70
|
+
check -->|no| spawn["spawn detached worker (singleton)"]
|
|
71
|
+
check -->|yes| reuse["reuse running worker"]
|
|
72
|
+
spawn --> worker["worker dist/worker.js"]
|
|
73
|
+
reuse --> worker
|
|
74
|
+
worker --> store["in-memory question store"]
|
|
75
|
+
worker -->|"command mode (optional)"| agent["headless Cursor agent (cursor-agent CLI)"]
|
|
76
|
+
worker -->|"Bot API: sendMessage / getUpdates"| tg["Telegram"]
|
|
77
|
+
tg -->|"your reply / texted task"| worker
|
|
78
|
+
phone["Your phone (Telegram app)"] --> tg
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The worker owns the Telegram connection and survives Cursor/MCP reloads. The MCP
|
|
82
|
+
server is a thin client Cursor launches per project; it auto-spawns the worker if
|
|
83
|
+
one is not already running (the worker's port guard keeps it a singleton, so
|
|
84
|
+
multiple projects safely share one worker). For non-blocking questions,
|
|
85
|
+
`ask_human_for_guidance` returns a `questionId` immediately and the agent polls
|
|
86
|
+
`check_human_response` (or long-polls with `waitMs`). For blocking decisions,
|
|
87
|
+
`ask_human_and_wait` long-polls the worker and returns as soon as you reply on
|
|
88
|
+
Telegram (no fixed 30-second sleep after your answer).
|
|
89
|
+
|
|
90
|
+
## Timeouts (three different values)
|
|
91
|
+
|
|
92
|
+
- getUpdates 30s: worker receive path only (Bot API long poll for inbound messages).
|
|
93
|
+
- waitMs / ask_human_and_wait: agent wait path — long-poll the worker and wake
|
|
94
|
+
immediately when you reply (do NOT sleep on a fixed timer between checks).
|
|
95
|
+
- TG_RESPONSE_TIMEOUT_MIN (default 30): minutes before a pending question reports
|
|
96
|
+
timed_out.
|
|
97
|
+
|
|
98
|
+
## Tools exposed to the agent
|
|
99
|
+
|
|
100
|
+
| Tool | Purpose |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `notify_human_task_complete(summary)` | Fire-and-forget "task done" message. |
|
|
103
|
+
| `ask_human_for_guidance(question)` | Send a question, return a `questionId` right away. |
|
|
104
|
+
| `ask_human_and_wait(question, timeoutMin?)` | Send a question and block until you answer or it times out. |
|
|
105
|
+
| `check_human_response(questionId, waitMs?)` | Poll for your reply: `answered` / `pending` / `timed_out`. Long-polls by default when `waitMs` is omitted; pass `waitMs=0` for an instant check. |
|
|
106
|
+
|
|
107
|
+
The rule [.cursor/rules/telegram-hitl.mdc](.cursor/rules/telegram-hitl.mdc) tells
|
|
108
|
+
the agent to send a completion message when it finishes and to mirror any
|
|
109
|
+
question it would ask in chat to Telegram.
|
|
110
|
+
|
|
111
|
+
## Command mode (drive work from your phone)
|
|
112
|
+
|
|
113
|
+
When a Cursor API key is configured (`setup` step 3, or `CURSOR_API_KEY`), the
|
|
114
|
+
worker treats any message that is not answering an open question as a task:
|
|
115
|
+
|
|
116
|
+
1. You text the bot, e.g. `add a healthcheck endpoint and a test`.
|
|
117
|
+
2. A read-only headless Cursor agent investigates and replies with a numbered
|
|
118
|
+
plan (`Plan (C-1) ...`). No files are changed yet.
|
|
119
|
+
3. You reply `YES` to execute (or `NO` to cancel). On `YES`, a second agent
|
|
120
|
+
carries out the plan and texts back a summary.
|
|
121
|
+
|
|
122
|
+
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:
|
|
125
|
+
|
|
126
|
+
- macOS / Linux: `curl https://cursor.com/install -fsS | bash`
|
|
127
|
+
- Windows (PowerShell): `irm 'https://cursor.com/install?win32=true' | iex`
|
|
128
|
+
|
|
129
|
+
The headless agent runs locally against `TG_AGENT_CWD` (default: the directory
|
|
130
|
+
the worker started in). Every task is gated: nothing changes code until you
|
|
131
|
+
approve the plan. Sessions are in memory only.
|
|
132
|
+
|
|
133
|
+
## Keeping the worker alive
|
|
134
|
+
|
|
135
|
+
The worker runs only while your laptop is awake. It auto-starts with the MCP
|
|
136
|
+
server and stays up across Cursor reloads, so for "laptop on the charger, manage
|
|
137
|
+
from my phone" you usually need nothing else.
|
|
138
|
+
|
|
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.)
|
|
144
|
+
|
|
145
|
+
## Configuration
|
|
146
|
+
|
|
147
|
+
Values resolve in this order: real environment variables (e.g. set in
|
|
148
|
+
`mcp.json`) > the config file written by `setup` > a local `.env` (dev) >
|
|
149
|
+
defaults.
|
|
150
|
+
|
|
151
|
+
| Variable | Default | Description |
|
|
152
|
+
| --- | --- | --- |
|
|
153
|
+
| `TELEGRAM_BOT_TOKEN` | (required) | Bot token from @BotFather. |
|
|
154
|
+
| `TELEGRAM_CHAT_ID` | (required) | Chat to message; replies are matched to it. |
|
|
155
|
+
| `TG_PROJECT` | `default` | Project label prefixed to this client's messages. |
|
|
156
|
+
| `TG_WORKER_URL` | `http://127.0.0.1:8787` | Worker base URL the MCP calls. |
|
|
157
|
+
| `TG_WORKER_HOST` | `127.0.0.1` | Host the worker binds to. |
|
|
158
|
+
| `TG_WORKER_PORT` | `8787` | Port the worker listens on. |
|
|
159
|
+
| `TG_MIN_SEND_GAP_MS` | `3000` | Minimum gap between outgoing messages. |
|
|
160
|
+
| `TG_RESPONSE_TIMEOUT_MIN` | `30` | Minutes before a pending question reports `timed_out`. |
|
|
161
|
+
| `CURSOR_API_KEY` | (unset) | Enables command mode. [Cursor -> Integrations](https://cursor.com/dashboard/integrations). |
|
|
162
|
+
| `TG_AGENT_CWD` | worker cwd | Directory the headless command-mode agent works in. |
|
|
163
|
+
| `TG_AGENT_MODEL` | `composer-2.5` | Model id for the headless agent. |
|
|
164
|
+
| `TG_AGENT_LOAD_SETTINGS` | `false` | `true` to load `TG_AGENT_CWD`'s `.cursor` rules/MCP during runs. |
|
|
165
|
+
|
|
166
|
+
## CLI
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
cursor-telegram-mcp [mcp] Start the MCP server (default; Cursor runs this)
|
|
170
|
+
cursor-telegram-mcp setup First-time setup: create/link your bot
|
|
171
|
+
cursor-telegram-mcp login Print and save your Telegram chat id
|
|
172
|
+
cursor-telegram-mcp worker Run the background worker in the foreground
|
|
173
|
+
cursor-telegram-mcp doctor Diagnose configuration and connectivity
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## As a Cursor plugin
|
|
177
|
+
|
|
178
|
+
This repo is also packaged as a Cursor plugin (manifest in
|
|
179
|
+
[.cursor-plugin/plugin.json](.cursor-plugin/plugin.json), server config in
|
|
180
|
+
[mcp.json](mcp.json), behavior rule in
|
|
181
|
+
[.cursor/rules/telegram-hitl.mdc](.cursor/rules/telegram-hitl.mdc)). Installing
|
|
182
|
+
it as a plugin makes the server show up under "Plugin MCP Servers" and bundles
|
|
183
|
+
the human-in-the-loop rule automatically.
|
|
184
|
+
|
|
185
|
+
Try it locally (no publishing required):
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# make the `npx -y cursor-telegram-mcp` command resolve locally
|
|
189
|
+
npm run build && npm pack && npm i -g ./cursor-telegram-mcp-*.tgz
|
|
190
|
+
|
|
191
|
+
# load the plugin from this checkout, then reload Cursor
|
|
192
|
+
ln -s "$(pwd)" ~/.cursor/plugins/local/cursor-telegram-mcp
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Then "Developer: Reload Window" in Cursor — the `telegram` server appears under
|
|
196
|
+
Settings → Tools & MCP → "Plugin MCP Servers". To list it in the in-app
|
|
197
|
+
marketplace for one-click discovery, publish the repo (public, open source) at
|
|
198
|
+
[cursor.com/marketplace/publish](https://cursor.com/marketplace/publish).
|
|
199
|
+
|
|
200
|
+
## Multiple projects
|
|
201
|
+
|
|
202
|
+
All your projects can share one worker (one bot, one chat). Each project's
|
|
203
|
+
`mcp.json` sets a different `TG_PROJECT`; messages are prefixed and numbered:
|
|
204
|
+
`[billing-api] Q-7`. Answer by using Telegram Reply on the specific question
|
|
205
|
+
message so the worker matches it to the right `Q-<n>`; otherwise the oldest open
|
|
206
|
+
question is answered first.
|
|
207
|
+
|
|
208
|
+
## Replying
|
|
209
|
+
|
|
210
|
+
Reply with a normal Telegram message. With several questions open, reply to the
|
|
211
|
+
specific question message to answer it precisely; otherwise the oldest open
|
|
212
|
+
question is answered first.
|
|
213
|
+
|
|
214
|
+
## Answering from your phone
|
|
215
|
+
|
|
216
|
+
When the agent needs a decision while you are away from the laptop:
|
|
217
|
+
|
|
218
|
+
1. Reply on Telegram. Prefer Telegram Reply on the specific Q-n message so the
|
|
219
|
+
worker matches your answer to the right question.
|
|
220
|
+
2. Ignore the IDE Questions panel (A/B/C, Skip, Continue) when the agent says
|
|
221
|
+
the question was also sent to Telegram as Q-n. That panel is separate; Skip
|
|
222
|
+
there does not read your Telegram reply.
|
|
223
|
+
3. The agent should resume as soon as it sees your answer (`ask_human_and_wait`
|
|
224
|
+
wakes immediately on reply; `check_human_response` long-polls by default when
|
|
225
|
+
you omit `waitMs`, or pass `waitMs=0` for an instant status check). If it
|
|
226
|
+
picks a default instead, send a follow-up message in the IDE chat with your
|
|
227
|
+
choice.
|
|
228
|
+
|
|
229
|
+
Use Telegram for remote decisions; use the IDE panel only when you are at the
|
|
230
|
+
desk and not relying on Telegram for that question.
|
|
231
|
+
|
|
232
|
+
## Develop from source
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
npm install
|
|
236
|
+
npm run setup # or: npm run login (after a token is set)
|
|
237
|
+
npm run worker # foreground worker (tsx)
|
|
238
|
+
npm run start # MCP server (tsx)
|
|
239
|
+
npm run typecheck
|
|
240
|
+
npm run build # emit dist/ for publishing
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## Notes & limitations
|
|
244
|
+
|
|
245
|
+
- 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).
|
|
248
|
+
- Per user: each person creates their own bot and runs their own worker (one bot
|
|
249
|
+
token can only be polled by one process).
|
|
250
|
+
- Command mode requires the `cursor-agent` CLI and a Cursor API key; without
|
|
251
|
+
them, notifications and questions still work.
|
|
252
|
+
|
|
253
|
+
## Project layout
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
src/
|
|
257
|
+
cli.ts # CLI dispatcher (bin) -> mcp / setup / login / worker / doctor
|
|
258
|
+
index.ts # thin MCP stdio server; auto-spawns the worker
|
|
259
|
+
worker.ts # long-running Telegram worker + localhost HTTP API
|
|
260
|
+
config.ts # config resolution (env > config file > .env > defaults)
|
|
261
|
+
setup.ts # interactive first-time setup wizard
|
|
262
|
+
login.ts # chat-id discovery helper
|
|
263
|
+
doctor.ts # diagnostics
|
|
264
|
+
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
|
|
267
|
+
parseInbound.ts / splitMessage.ts / taskQueue.ts / formatTelegram.ts
|
|
268
|
+
answerWaiters.ts # wake-on-answer for long-polling GET /response/:id
|
|
269
|
+
.cursor/
|
|
270
|
+
rules/telegram-hitl.mdc # agent behavior rule
|
|
271
|
+
mcp.client.template.json # per-project MCP client block to copy
|
|
272
|
+
```
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Command mode: run headless Cursor agents from a phone message.
|
|
55
|
+
*
|
|
56
|
+
* Flow (plan-then-approve, always gated):
|
|
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.
|
|
62
|
+
*
|
|
63
|
+
* Everything runs locally on this machine (the laptop on the charger) against
|
|
64
|
+
* `cwd`, using your CURSOR_API_KEY. Sessions live in memory only.
|
|
65
|
+
*/
|
|
66
|
+
import { readFileSync } from "node:fs";
|
|
67
|
+
/**
|
|
68
|
+
* Lazily load the optional `@cursor/sdk`. Command mode needs it; if the package
|
|
69
|
+
* is not installed (it is an optionalDependency), surface a clear, actionable
|
|
70
|
+
* error instead of crashing the worker at import time.
|
|
71
|
+
*/
|
|
72
|
+
async function loadSdk() {
|
|
73
|
+
try {
|
|
74
|
+
return await import("@cursor/sdk");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("command mode requires the optional '@cursor/sdk' package, which is not installed. " +
|
|
78
|
+
"Install it in the worker's environment (npm i @cursor/sdk) and restart.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** True if the error is a Cursor SDK startup failure (auth/config/network). */
|
|
82
|
+
function isStartupError(err) {
|
|
83
|
+
return (typeof err === "object" &&
|
|
84
|
+
err !== null &&
|
|
85
|
+
err.name === "CursorAgentError");
|
|
86
|
+
}
|
|
87
|
+
function attachmentsToSdkImages(attachments) {
|
|
88
|
+
if (!attachments || attachments.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const images = [];
|
|
91
|
+
for (const att of attachments) {
|
|
92
|
+
const data = readFileSync(att.localPath).toString("base64");
|
|
93
|
+
images.push({ data, mimeType: att.mimeType });
|
|
94
|
+
}
|
|
95
|
+
return images.length > 0 ? images : undefined;
|
|
96
|
+
}
|
|
97
|
+
function buildUserMessage(text, attachments) {
|
|
98
|
+
const images = attachmentsToSdkImages(attachments);
|
|
99
|
+
if (!images)
|
|
100
|
+
return text;
|
|
101
|
+
return { text, images };
|
|
102
|
+
}
|
|
103
|
+
async function runAgentPrompt(promptText, opts, attachments) {
|
|
104
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
105
|
+
try {
|
|
106
|
+
const message = buildUserMessage(promptText, attachments);
|
|
107
|
+
const { Agent } = await loadSdk();
|
|
108
|
+
const agent = __addDisposableResource(env_1, await Agent.create({
|
|
109
|
+
apiKey: opts.apiKey,
|
|
110
|
+
model: { id: opts.model },
|
|
111
|
+
local: { cwd: opts.cwd, settingSources: opts.settingSources },
|
|
112
|
+
}), true);
|
|
113
|
+
const run = await agent.send(message);
|
|
114
|
+
await run.wait();
|
|
115
|
+
return { status: run.status, result: run.result };
|
|
116
|
+
}
|
|
117
|
+
catch (e_1) {
|
|
118
|
+
env_1.error = e_1;
|
|
119
|
+
env_1.hasError = true;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
const result_1 = __disposeResources(env_1);
|
|
123
|
+
if (result_1)
|
|
124
|
+
await result_1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function coerceText(value) {
|
|
128
|
+
if (value == null)
|
|
129
|
+
return "";
|
|
130
|
+
if (typeof value === "string")
|
|
131
|
+
return value;
|
|
132
|
+
try {
|
|
133
|
+
return JSON.stringify(value, null, 2);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return String(value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const PLAIN_TEXT_RULE = `Write in plain text only — no Markdown (no **bold**, \`code\`, # headers, or ` +
|
|
140
|
+
`links in [label](url) form). Use numbered lists for steps and CAPS for ` +
|
|
141
|
+
`emphasis. Keep it short and easy to skim on a phone screen.`;
|
|
142
|
+
const PLAN_PROMPT = (task) => `You are operating in PLAN-ONLY mode for a user reviewing on their phone.\n\n` +
|
|
143
|
+
`Task:\n${task}\n\n` +
|
|
144
|
+
`Investigate the repository as needed, then reply with a concise, numbered ` +
|
|
145
|
+
`step-by-step plan of exactly what you would change. Do NOT edit, create, or ` +
|
|
146
|
+
`delete any files, and do not run mutating commands.\n\n` +
|
|
147
|
+
PLAIN_TEXT_RULE;
|
|
148
|
+
const EXECUTE_PROMPT = (task, plan) => `Execute the following approved plan for this task. Make the actual changes.\n\n` +
|
|
149
|
+
`Task:\n${task}\n\n` +
|
|
150
|
+
`Approved plan:\n${plan}\n\n` +
|
|
151
|
+
`Carry out the plan now. When finished, reply with a short summary of what you ` +
|
|
152
|
+
`changed and anything the user should review.\n\n` +
|
|
153
|
+
PLAIN_TEXT_RULE;
|
|
154
|
+
const ASK_PROMPT = (question) => `You are answering a quick question from the user on their phone.\n\n` +
|
|
155
|
+
`Question:\n${question}\n\n` +
|
|
156
|
+
`Investigate the repository as needed (read-only), then answer directly. Do NOT ` +
|
|
157
|
+
`edit, create, or delete any files, and do not run mutating commands.\n\n` +
|
|
158
|
+
PLAIN_TEXT_RULE;
|
|
159
|
+
/** Runs and tracks plan-then-approve command sessions. */
|
|
160
|
+
export class CommandRunner {
|
|
161
|
+
opts;
|
|
162
|
+
nextId = 1;
|
|
163
|
+
nextAskId = 1;
|
|
164
|
+
sessions = new Map();
|
|
165
|
+
askSessions = new Map();
|
|
166
|
+
constructor(opts) {
|
|
167
|
+
this.opts = opts;
|
|
168
|
+
}
|
|
169
|
+
/** True when a Cursor API key is configured. */
|
|
170
|
+
get enabled() {
|
|
171
|
+
return this.opts.apiKey.trim() !== "";
|
|
172
|
+
}
|
|
173
|
+
/** Most recent session waiting for a YES/NO approval, if any. */
|
|
174
|
+
latestAwaitingApproval() {
|
|
175
|
+
let latest;
|
|
176
|
+
for (const s of this.sessions.values()) {
|
|
177
|
+
if (s.status === "awaiting_approval" && (!latest || s.updatedAt > latest.updatedAt)) {
|
|
178
|
+
latest = s;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return latest;
|
|
182
|
+
}
|
|
183
|
+
/** Most recent session that is actively planning, executing, or asking, if any. */
|
|
184
|
+
latestActiveSession() {
|
|
185
|
+
let latest;
|
|
186
|
+
let latestAt = 0;
|
|
187
|
+
for (const s of this.sessions.values()) {
|
|
188
|
+
if ((s.status === "planning" || s.status === "executing") &&
|
|
189
|
+
s.updatedAt > latestAt) {
|
|
190
|
+
latest = { id: s.id, status: s.status };
|
|
191
|
+
latestAt = s.updatedAt;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const s of this.askSessions.values()) {
|
|
195
|
+
if (s.status === "asking" && s.updatedAt > latestAt) {
|
|
196
|
+
latest = { id: s.id, status: s.status };
|
|
197
|
+
latestAt = s.updatedAt;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return latest;
|
|
201
|
+
}
|
|
202
|
+
/** Ask sessions that are still running (newest first). */
|
|
203
|
+
listActiveAsks() {
|
|
204
|
+
return [...this.askSessions.values()]
|
|
205
|
+
.filter((s) => s.status === "asking")
|
|
206
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
207
|
+
}
|
|
208
|
+
/** All sessions waiting for YES/NO approval (newest first). */
|
|
209
|
+
listAwaitingApproval() {
|
|
210
|
+
return [...this.sessions.values()]
|
|
211
|
+
.filter((s) => s.status === "awaiting_approval")
|
|
212
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
213
|
+
}
|
|
214
|
+
/** Plan a new task (read-only). Returns the session with plan or error set. */
|
|
215
|
+
async plan(task, onSession, attachments) {
|
|
216
|
+
const session = {
|
|
217
|
+
id: `C-${this.nextId++}`,
|
|
218
|
+
task,
|
|
219
|
+
status: "planning",
|
|
220
|
+
createdAt: Date.now(),
|
|
221
|
+
updatedAt: Date.now(),
|
|
222
|
+
};
|
|
223
|
+
this.sessions.set(session.id, session);
|
|
224
|
+
onSession?.(session);
|
|
225
|
+
try {
|
|
226
|
+
const result = await runAgentPrompt(PLAN_PROMPT(task), this.opts, attachments);
|
|
227
|
+
if (result.status === "error") {
|
|
228
|
+
session.status = "error";
|
|
229
|
+
session.error = coerceText(result.result) || "plan run failed";
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
session.status = "awaiting_approval";
|
|
233
|
+
session.plan = coerceText(result.result).trim() || "(no plan text returned)";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
session.status = "error";
|
|
238
|
+
session.error = isStartupError(err)
|
|
239
|
+
? `agent did not start: ${err.message}`
|
|
240
|
+
: String(err);
|
|
241
|
+
}
|
|
242
|
+
session.updatedAt = Date.now();
|
|
243
|
+
return session;
|
|
244
|
+
}
|
|
245
|
+
/** Answer a question directly (read-only, no approval step). */
|
|
246
|
+
async ask(question, onSession, attachments) {
|
|
247
|
+
const session = {
|
|
248
|
+
id: `A-${this.nextAskId++}`,
|
|
249
|
+
question,
|
|
250
|
+
status: "asking",
|
|
251
|
+
createdAt: Date.now(),
|
|
252
|
+
updatedAt: Date.now(),
|
|
253
|
+
};
|
|
254
|
+
this.askSessions.set(session.id, session);
|
|
255
|
+
onSession?.(session);
|
|
256
|
+
try {
|
|
257
|
+
const result = await runAgentPrompt(ASK_PROMPT(question), this.opts, attachments);
|
|
258
|
+
if (result.status === "error") {
|
|
259
|
+
session.status = "error";
|
|
260
|
+
session.error = coerceText(result.result) || "ask run failed";
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
session.status = "done";
|
|
264
|
+
session.answer = coerceText(result.result).trim() || "(no answer text returned)";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
session.status = "error";
|
|
269
|
+
session.error = isStartupError(err)
|
|
270
|
+
? `agent did not start: ${err.message}`
|
|
271
|
+
: String(err);
|
|
272
|
+
}
|
|
273
|
+
session.updatedAt = Date.now();
|
|
274
|
+
return session;
|
|
275
|
+
}
|
|
276
|
+
/** Execute a previously planned session. Returns it with result or error. */
|
|
277
|
+
async approve(id, onExecuting) {
|
|
278
|
+
const session = this.sessions.get(id);
|
|
279
|
+
if (!session)
|
|
280
|
+
throw new Error(`unknown command session ${id}`);
|
|
281
|
+
if (session.status !== "awaiting_approval")
|
|
282
|
+
return session;
|
|
283
|
+
session.status = "executing";
|
|
284
|
+
session.updatedAt = Date.now();
|
|
285
|
+
onExecuting?.(session);
|
|
286
|
+
try {
|
|
287
|
+
const result = await runAgentPrompt(EXECUTE_PROMPT(session.task, session.plan ?? ""), this.opts);
|
|
288
|
+
if (result.status === "error") {
|
|
289
|
+
session.status = "error";
|
|
290
|
+
session.error = coerceText(result.result) || "execution run failed";
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
session.status = "done";
|
|
294
|
+
session.result = coerceText(result.result).trim() || "(no result text returned)";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
session.status = "error";
|
|
299
|
+
session.error = isStartupError(err)
|
|
300
|
+
? `agent did not start: ${err.message}`
|
|
301
|
+
: String(err);
|
|
302
|
+
}
|
|
303
|
+
session.updatedAt = Date.now();
|
|
304
|
+
return session;
|
|
305
|
+
}
|
|
306
|
+
/** Cancel a session awaiting approval. */
|
|
307
|
+
reject(id) {
|
|
308
|
+
const session = this.sessions.get(id);
|
|
309
|
+
if (session && session.status === "awaiting_approval") {
|
|
310
|
+
session.status = "cancelled";
|
|
311
|
+
session.updatedAt = Date.now();
|
|
312
|
+
}
|
|
313
|
+
return session;
|
|
314
|
+
}
|
|
315
|
+
/** Drop sessions older than maxAgeMs that aren't actively running. */
|
|
316
|
+
sweepStale(maxAgeMs) {
|
|
317
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
318
|
+
for (const [id, s] of this.sessions) {
|
|
319
|
+
const idle = s.status !== "planning" && s.status !== "executing";
|
|
320
|
+
if (idle && s.updatedAt < cutoff)
|
|
321
|
+
this.sessions.delete(id);
|
|
322
|
+
}
|
|
323
|
+
for (const [id, s] of this.askSessions) {
|
|
324
|
+
if (s.status !== "asking" && s.updatedAt < cutoff)
|
|
325
|
+
this.askSessions.delete(id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Build a CommandRunner (always returns one; check `.enabled`). */
|
|
330
|
+
export function createCommandRunner(opts) {
|
|
331
|
+
return new CommandRunner(opts);
|
|
332
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake-on-answer registry for long-polling GET /response/:id.
|
|
3
|
+
*
|
|
4
|
+
* When a client blocks on a pending question, register a waiter keyed by
|
|
5
|
+
* question id. notifyAnswered() resolves all waiters immediately when the
|
|
6
|
+
* human's Telegram reply is recorded.
|
|
7
|
+
*/
|
|
8
|
+
const waiters = new Map();
|
|
9
|
+
/** Resolve every blocked waiter for this question id (called after matchAndAnswer). */
|
|
10
|
+
export function notifyAnswered(id) {
|
|
11
|
+
const set = waiters.get(id);
|
|
12
|
+
if (!set)
|
|
13
|
+
return;
|
|
14
|
+
for (const w of set) {
|
|
15
|
+
clearTimeout(w.timer);
|
|
16
|
+
w.resolve();
|
|
17
|
+
}
|
|
18
|
+
waiters.delete(id);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Block until notifyAnswered(id) or maxMs elapses.
|
|
22
|
+
* Returns { promise, cancel } — call cancel on client disconnect to avoid leaks.
|
|
23
|
+
*/
|
|
24
|
+
export function waitForAnswer(id, maxMs) {
|
|
25
|
+
let settled = false;
|
|
26
|
+
let waiter;
|
|
27
|
+
let resolvePromise;
|
|
28
|
+
const remove = () => {
|
|
29
|
+
if (!waiter)
|
|
30
|
+
return;
|
|
31
|
+
const set = waiters.get(id);
|
|
32
|
+
if (set) {
|
|
33
|
+
set.delete(waiter);
|
|
34
|
+
if (set.size === 0)
|
|
35
|
+
waiters.delete(id);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const finish = () => {
|
|
39
|
+
if (settled)
|
|
40
|
+
return;
|
|
41
|
+
settled = true;
|
|
42
|
+
if (waiter)
|
|
43
|
+
clearTimeout(waiter.timer);
|
|
44
|
+
remove();
|
|
45
|
+
resolvePromise?.();
|
|
46
|
+
};
|
|
47
|
+
const promise = new Promise((resolve) => {
|
|
48
|
+
resolvePromise = resolve;
|
|
49
|
+
const timer = setTimeout(() => finish(), maxMs);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
waiter = {
|
|
52
|
+
timer,
|
|
53
|
+
resolve: finish,
|
|
54
|
+
};
|
|
55
|
+
let set = waiters.get(id);
|
|
56
|
+
if (!set) {
|
|
57
|
+
set = new Set();
|
|
58
|
+
waiters.set(id, set);
|
|
59
|
+
}
|
|
60
|
+
set.add(waiter);
|
|
61
|
+
});
|
|
62
|
+
const cancel = () => finish();
|
|
63
|
+
return { promise, cancel };
|
|
64
|
+
}
|