cursor-feedback 2.4.0 → 2.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/.cursor/rules/learned-conventions.mdc +9 -0
- package/CHANGELOG.md +20 -0
- package/README.md +5 -3
- package/README_CN.md +4 -2
- package/dist/cli-launcher.js +227 -27
- package/dist/mcp-server.js +81 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.5.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.4.1...v2.5.0) (2026-07-06)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* /help 完整用法说明;全角斜杠/归一化;持久化「发版需用户确认」规则 ([d345b55](https://github.com/jianger666/cursor-feedback-extension/commit/d345b55da22cad9e1a838999f0d58dd36ffa10b2))
|
|
11
|
+
* slash commands case-insensitive + /help command, malformed /model //cwd args no longer fall through to feedback routing ([a4bceeb](https://github.com/jianger666/cursor-feedback-extension/commit/a4bceebb588898c82acb797e8f5abefb7a457550))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Code Refactoring
|
|
15
|
+
|
|
16
|
+
* **cli:** /new 工作目录简化为「显式指定>主目录」与 IDE 窗口解耦;移除 /cwd;新增 /projects 查项目路径、/new 支持项目名匹配 ([0752a32](https://github.com/jianger666/cursor-feedback-extension/commit/0752a3232de0e712b977f9f01ba988613cd7597c))
|
|
17
|
+
|
|
18
|
+
### [2.4.1](https://github.com/jianger666/cursor-feedback-extension/compare/v2.4.0...v2.4.1) (2026-07-06)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* **cli:** CLI 会话全局锁——多窗口/守护多实例并存时 /new /stop 跨实例一致 ([679c3ef](https://github.com/jianger666/cursor-feedback-extension/commit/679c3ef3ce43af768be939d4059a452cd3d20178))
|
|
24
|
+
|
|
5
25
|
## [2.4.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.3.1...v2.4.0) (2026-07-06)
|
|
6
26
|
|
|
7
27
|
|
package/README.md
CHANGED
|
@@ -270,11 +270,13 @@ Send these directly in the bot chat:
|
|
|
270
270
|
|
|
271
271
|
| Command | What it does |
|
|
272
272
|
|---------|--------------|
|
|
273
|
-
| `/new task description` | Launch a headless CLI session for the task (in
|
|
274
|
-
| `/new /abs/path task description` | Launch
|
|
273
|
+
| `/new task description` | Launch a headless CLI session for the task (in your home directory by default) |
|
|
274
|
+
| `/new /abs/path task description` | Launch in an explicit working directory |
|
|
275
|
+
| `/new project-name task description` | Launch in a project Cursor has opened before (unique folder-name match) |
|
|
276
|
+
| `/projects` | List project paths Cursor has opened (to look up / copy into `/new`) |
|
|
275
277
|
| `/stop` | Terminate the running CLI session |
|
|
276
278
|
| `/model [modelId]` | Show / set the session model (persisted) |
|
|
277
|
-
| `/
|
|
279
|
+
| `/help` | Show command usage |
|
|
278
280
|
|
|
279
281
|
- Sessions run in **non-interactive mode** with `maxMode=false` force-written before spawn — on request-based plans each session costs exactly 1 request, never Max billing.
|
|
280
282
|
- The launched agent talks to you through this extension's feedback cards: confirmations and progress reports are pushed to Feishu; just reply to the cards.
|
package/README_CN.md
CHANGED
|
@@ -274,11 +274,13 @@ npm install -g cursor-feedback
|
|
|
274
274
|
|
|
275
275
|
| 命令 | 作用 |
|
|
276
276
|
|------|------|
|
|
277
|
-
| `/new 任务描述` | 拉起一个 headless CLI
|
|
277
|
+
| `/new 任务描述` | 拉起一个 headless CLI 会话跑这个任务(默认在主目录) |
|
|
278
278
|
| `/new /绝对路径 任务描述` | 指定工作目录拉起 |
|
|
279
|
+
| `/new 项目名 任务描述` | 在 Cursor 打开过的项目里拉起(目录名唯一匹配) |
|
|
280
|
+
| `/projects` | 列出 Cursor 打开过的项目路径(供查路径 / 复制给 `/new`) |
|
|
279
281
|
| `/stop` | 终止运行中的 CLI 会话 |
|
|
280
282
|
| `/model [模型id]` | 查看 / 设置会话模型(持久化) |
|
|
281
|
-
| `/
|
|
283
|
+
| `/help` | 查看命令用法 |
|
|
282
284
|
|
|
283
285
|
- 会话以**非交互模式**运行,拉起前强制写入 `maxMode=false`——按次计费套餐固定只扣 1 次请求,永远不会走 Max 计费。
|
|
284
286
|
- 拉起的 Agent 也通过本插件的反馈卡片与你沟通:需要确认、汇报进展都会推到飞书,直接回复卡片即可。
|
package/dist/cli-launcher.js
CHANGED
|
@@ -62,15 +62,113 @@ class CliLauncher {
|
|
|
62
62
|
this.taskBrief = '';
|
|
63
63
|
this.stopRequested = false;
|
|
64
64
|
}
|
|
65
|
+
/* ---------- 磁盘全局会话锁 ----------
|
|
66
|
+
* 「同时只跑一个 CLI 会话」必须全局生效:多窗口 + 守护进程是多实例并存,
|
|
67
|
+
* 飞书把 /new、/stop 推给哪个实例是不确定的(长连接负载均衡)。只看本实例的
|
|
68
|
+
* child 会被击穿:A 拉了会话,下一条 /new 推给 B,B 以为空闲又拉一个。
|
|
69
|
+
* 锁文件记录会话进程 pid,任何实例都能据此判忙 / 跨实例终止;
|
|
70
|
+
* 持锁进程崩溃后锁自动失效(pid 探活),不会死锁。 */
|
|
71
|
+
lockPath() {
|
|
72
|
+
return path.join(os.homedir(), '.cursor-feedback', 'cli-session.lock');
|
|
73
|
+
}
|
|
74
|
+
readLock() {
|
|
75
|
+
try {
|
|
76
|
+
const j = JSON.parse(fs.readFileSync(this.lockPath(), 'utf-8'));
|
|
77
|
+
return typeof j.pid === 'number' ? j : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
static pidAlive(pid) {
|
|
84
|
+
if (!pid || pid <= 0)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* pid 是否确实是 cursor-agent 会话进程。防 pid 复用:锁残留(极端崩溃)后系统把同号
|
|
96
|
+
* pid 分给了无关进程,跨实例 /stop 若不校验会误杀无辜。校验失败按「不是会话」处理。
|
|
97
|
+
*/
|
|
98
|
+
static pidLooksLikeAgent(pid) {
|
|
99
|
+
try {
|
|
100
|
+
if (process.platform === 'win32') {
|
|
101
|
+
const out = (0, child_process_1.execFileSync)('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
});
|
|
104
|
+
return /cursor-agent|node|cmd\.exe/i.test(out);
|
|
105
|
+
}
|
|
106
|
+
const out = (0, child_process_1.execFileSync)('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' });
|
|
107
|
+
return /cursor-agent/i.test(out);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** 当前全局是否有活跃会话(锁存在且会话进程活着;死锁视为无会话) */
|
|
114
|
+
aliveLock() {
|
|
115
|
+
const lock = this.readLock();
|
|
116
|
+
if (!lock)
|
|
117
|
+
return null;
|
|
118
|
+
// pid=0 = 另一个实例刚抢到锁、spawn 还没完成:以它的实例进程判活,避免这瞬间被当死锁误清
|
|
119
|
+
const alive = lock.pid > 0
|
|
120
|
+
? CliLauncher.pidAlive(lock.pid)
|
|
121
|
+
: CliLauncher.pidAlive(lock.ownerPid);
|
|
122
|
+
if (!alive) {
|
|
123
|
+
// 持锁会话已死(实例崩溃 / 强杀),清掉残锁
|
|
124
|
+
fs.rmSync(this.lockPath(), { force: true });
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return lock;
|
|
128
|
+
}
|
|
129
|
+
/** 原子抢锁:wx 独占创建。返回是否抢到(false = 别的实例刚抢先) */
|
|
130
|
+
tryAcquireLock(task) {
|
|
131
|
+
// 先清死锁,再独占创建(两个实例同时 /new 时只有一个 wx 成功)
|
|
132
|
+
this.aliveLock();
|
|
133
|
+
const lock = { pid: 0, task, startedAt: Date.now(), ownerPid: process.pid };
|
|
134
|
+
try {
|
|
135
|
+
fs.mkdirSync(path.dirname(this.lockPath()), { recursive: true });
|
|
136
|
+
fs.writeFileSync(this.lockPath(), JSON.stringify(lock), { flag: 'wx' });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
updateLockPid(pid) {
|
|
144
|
+
const lock = this.readLock();
|
|
145
|
+
if (lock && lock.ownerPid === process.pid) {
|
|
146
|
+
fs.writeFileSync(this.lockPath(), JSON.stringify({ ...lock, pid }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
releaseLock() {
|
|
150
|
+
const lock = this.readLock();
|
|
151
|
+
if (lock && lock.ownerPid === process.pid) {
|
|
152
|
+
fs.rmSync(this.lockPath(), { force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** 全局视角:是否有会话在跑(本实例或其他实例拉起的都算) */
|
|
65
156
|
isRunning() {
|
|
66
|
-
return this.child !== null;
|
|
157
|
+
return this.child !== null || this.aliveLock() !== null;
|
|
67
158
|
}
|
|
68
|
-
/**
|
|
159
|
+
/** 正在运行的会话描述(用于「已有会话在跑」的回执;含其他实例拉起的会话) */
|
|
69
160
|
describe() {
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
161
|
+
if (this.child) {
|
|
162
|
+
const mins = Math.round((Date.now() - this.startedAt) / 60000);
|
|
163
|
+
return `「${this.taskBrief}」(已运行 ${mins} 分钟)`;
|
|
164
|
+
}
|
|
165
|
+
const lock = this.aliveLock();
|
|
166
|
+
if (lock) {
|
|
167
|
+
const brief = lock.task.length > 40 ? lock.task.slice(0, 40) + '…' : lock.task;
|
|
168
|
+
const mins = Math.round((Date.now() - lock.startedAt) / 60000);
|
|
169
|
+
return `「${brief}」(已运行 ${mins} 分钟,由另一个窗口实例托管)`;
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
74
172
|
}
|
|
75
173
|
settingsPath() {
|
|
76
174
|
return path.join(os.homedir(), '.cursor-feedback', 'cli.json');
|
|
@@ -96,12 +194,61 @@ class CliLauncher {
|
|
|
96
194
|
this.readSettings().model ||
|
|
97
195
|
CliLauncher.DEFAULT_MODEL);
|
|
98
196
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
197
|
+
/** Cursor IDE 的 storage.json 路径(平台相关) */
|
|
198
|
+
static storageJsonPath() {
|
|
199
|
+
if (process.platform === 'darwin') {
|
|
200
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'storage.json');
|
|
201
|
+
}
|
|
202
|
+
if (process.platform === 'win32') {
|
|
203
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage', 'storage.json');
|
|
204
|
+
}
|
|
205
|
+
return path.join(os.homedir(), '.config', 'Cursor', 'User', 'globalStorage', 'storage.json');
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 用户在 Cursor IDE 里打开过的项目目录(供手机上查路径 / 按项目名拉起会话)。
|
|
209
|
+
* 数据源是 IDE 的 storage.json:backupWorkspaces.folders 是当前/最近打开的窗口(排前面),
|
|
210
|
+
* profileAssociations.workspaces 是历史打开过的全部工作区。只返回仍然存在的目录。
|
|
211
|
+
*/
|
|
212
|
+
listProjects() {
|
|
213
|
+
let raw;
|
|
214
|
+
try {
|
|
215
|
+
raw = JSON.parse(fs.readFileSync(CliLauncher.storageJsonPath(), 'utf-8'));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const uris = [
|
|
221
|
+
...(raw.backupWorkspaces?.folders || []).map((f) => f.folderUri || ''),
|
|
222
|
+
...Object.keys(raw.profileAssociations?.workspaces || {}),
|
|
223
|
+
];
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const result = [];
|
|
226
|
+
for (const uri of uris) {
|
|
227
|
+
if (!uri.startsWith('file://'))
|
|
228
|
+
continue;
|
|
229
|
+
let dir;
|
|
230
|
+
try {
|
|
231
|
+
dir = decodeURIComponent(uri.replace(/^file:\/\//, ''));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
// Windows 的 URI 形如 file:///c%3A/... → 去掉开头多余的 /
|
|
237
|
+
if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(dir))
|
|
238
|
+
dir = dir.slice(1);
|
|
239
|
+
const key = dir.toLowerCase();
|
|
240
|
+
if (seen.has(key))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(key);
|
|
243
|
+
try {
|
|
244
|
+
if (fs.statSync(dir).isDirectory())
|
|
245
|
+
result.push(dir);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// 已删除的目录不展示
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
105
252
|
}
|
|
106
253
|
/**
|
|
107
254
|
* 定位 cursor-agent 可执行文件。安装脚本默认放在 ~/.local/bin(mac/linux 是
|
|
@@ -182,17 +329,24 @@ class CliLauncher {
|
|
|
182
329
|
* @returns 启动失败时返回错误说明字符串;成功返回 null,结果经 onDone 回调
|
|
183
330
|
*/
|
|
184
331
|
start(task, cwd, onDone) {
|
|
185
|
-
if (this.
|
|
332
|
+
if (this.isRunning())
|
|
186
333
|
return '已有一个 CLI 会话在运行:' + this.describe();
|
|
334
|
+
// 原子抢全局锁:两个实例同时收到 /new 时只有一个能抢到
|
|
335
|
+
if (!this.tryAcquireLock(task)) {
|
|
336
|
+
return '已有一个 CLI 会话在运行:' + (this.describe() || '(另一个窗口实例刚刚抢先拉起)');
|
|
337
|
+
}
|
|
187
338
|
try {
|
|
188
339
|
this.ensureMaxModeOff();
|
|
189
340
|
}
|
|
190
341
|
catch (e) {
|
|
342
|
+
this.releaseLock();
|
|
191
343
|
return '写入 cli-config.json 失败:' + e;
|
|
192
344
|
}
|
|
193
345
|
const bin = this.findBinary();
|
|
194
|
-
if (!bin)
|
|
346
|
+
if (!bin) {
|
|
347
|
+
this.releaseLock();
|
|
195
348
|
return '找不到 cursor-agent,可通过环境变量 CURSOR_AGENT_PATH 指定路径。';
|
|
349
|
+
}
|
|
196
350
|
const workDir = fs.existsSync(cwd) ? cwd : os.homedir();
|
|
197
351
|
const prompt = this.buildPrompt(task);
|
|
198
352
|
let stdout = '';
|
|
@@ -225,12 +379,14 @@ class CliLauncher {
|
|
|
225
379
|
});
|
|
226
380
|
}
|
|
227
381
|
catch (e) {
|
|
382
|
+
this.releaseLock();
|
|
228
383
|
return '拉起 cursor-agent 失败:' + e;
|
|
229
384
|
}
|
|
230
385
|
this.child = child;
|
|
231
386
|
this.startedAt = Date.now();
|
|
232
387
|
this.taskBrief = task.length > 40 ? task.slice(0, 40) + '…' : task;
|
|
233
388
|
this.stopRequested = false;
|
|
389
|
+
this.updateLockPid(child.pid || 0);
|
|
234
390
|
clog(`CLI 会话已拉起: pid=${child.pid} cwd=${workDir} model=${this.model()}`);
|
|
235
391
|
let timedOut = false;
|
|
236
392
|
const killTimer = setTimeout(() => {
|
|
@@ -243,8 +399,12 @@ class CliLauncher {
|
|
|
243
399
|
const finish = (code) => {
|
|
244
400
|
clearTimeout(killTimer);
|
|
245
401
|
const elapsedMs = Date.now() - this.startedAt;
|
|
246
|
-
|
|
402
|
+
// 跨实例 /stop 会在锁上打 stopRequested 标记再杀进程,这里一并算「主动终止」
|
|
403
|
+
const lockAtFinish = this.readLock();
|
|
404
|
+
const stopped = this.stopRequested ||
|
|
405
|
+
!!(lockAtFinish && lockAtFinish.ownerPid === process.pid && lockAtFinish.stopRequested);
|
|
247
406
|
this.child = null;
|
|
407
|
+
this.releaseLock();
|
|
248
408
|
clog(`CLI 会话结束: code=${code} elapsed=${Math.round(elapsedMs / 1000)}s timedOut=${timedOut} stopped=${stopped}`);
|
|
249
409
|
onDone({
|
|
250
410
|
code,
|
|
@@ -263,20 +423,60 @@ class CliLauncher {
|
|
|
263
423
|
child.on('close', (code) => finish(code));
|
|
264
424
|
return null;
|
|
265
425
|
}
|
|
266
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* 用户 /stop 主动终止会话。返回是否有会话被终止。
|
|
428
|
+
* 跨实例:会话可能由另一个窗口实例托管(飞书把 /stop 推给了不同实例),
|
|
429
|
+
* 此时按锁里的 pid 直接终止会话进程,托管实例的 close 回调会自然收尾并回执。
|
|
430
|
+
*/
|
|
267
431
|
stop() {
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
432
|
+
if (this.child) {
|
|
433
|
+
this.stopRequested = true;
|
|
434
|
+
CliLauncher.killTree(this.child, 'SIGTERM');
|
|
435
|
+
// SIGTERM 5s 内没退出则补 SIGKILL
|
|
436
|
+
const child = this.child;
|
|
437
|
+
setTimeout(() => {
|
|
438
|
+
if (this.child === child) {
|
|
439
|
+
CliLauncher.killTree(child, 'SIGKILL');
|
|
440
|
+
}
|
|
441
|
+
}, 5000);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
const lock = this.aliveLock();
|
|
445
|
+
if (lock && lock.pid > 0) {
|
|
446
|
+
if (!CliLauncher.pidLooksLikeAgent(lock.pid)) {
|
|
447
|
+
// pid 已被无关进程复用(残锁):清锁但绝不误杀
|
|
448
|
+
clog(`锁 pid=${lock.pid} 不是 cursor-agent(已被复用),清除残锁`);
|
|
449
|
+
fs.rmSync(this.lockPath(), { force: true });
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
clog(`跨实例终止 CLI 会话: pid=${lock.pid}(由 ${lock.ownerPid} 托管)`);
|
|
453
|
+
try {
|
|
454
|
+
// 先在锁上打「主动终止」标记,托管实例收尾时据此报「已终止」而非「异常退出」
|
|
455
|
+
fs.writeFileSync(this.lockPath(), JSON.stringify({ ...lock, stopRequested: true }));
|
|
277
456
|
}
|
|
278
|
-
|
|
279
|
-
|
|
457
|
+
catch {
|
|
458
|
+
// 标记失败只影响收尾文案
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
// 会话进程是托管实例 detached 出来的进程组组长,杀整组
|
|
462
|
+
if (process.platform === 'win32') {
|
|
463
|
+
(0, child_process_1.spawn)('taskkill', ['/pid', String(lock.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
try {
|
|
467
|
+
process.kill(-lock.pid, 'SIGTERM');
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// 进程刚好自己结束了
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
280
480
|
}
|
|
281
481
|
/**
|
|
282
482
|
* 终止整个进程树:POSIX 杀进程组(detached spawn 后 child.pid 即组长),
|
package/dist/mcp-server.js
CHANGED
|
@@ -396,7 +396,8 @@ class McpFeedbackServer {
|
|
|
396
396
|
if (!text && images.length === 0 && files.length === 0)
|
|
397
397
|
return;
|
|
398
398
|
// 斜杠命令优先于反馈路由:用户显式输入命令时不应被当成对某张卡片的回复或排队消息。
|
|
399
|
-
|
|
399
|
+
// 大小写不敏感:手机输入法常自动把句首字母大写(/New),必须容错。
|
|
400
|
+
if (/^\s*[//](new|stop|model|cwd|help|projects)\b/i.test(text) && this.handleCliCommand(text, chatId))
|
|
400
401
|
return;
|
|
401
402
|
if (parentId) {
|
|
402
403
|
// 用户「回复」了某条卡片 → 用 parent_id 精确路由
|
|
@@ -494,13 +495,51 @@ class McpFeedbackServer {
|
|
|
494
495
|
}
|
|
495
496
|
/**
|
|
496
497
|
* 处理飞书斜杠命令。返回是否已消费该消息。
|
|
497
|
-
* - /new [
|
|
498
|
+
* - /new [目录或项目名] 任务描述:拉起 headless CLI 会话(非交互 + maxMode=false,扣 1 次请求)。
|
|
499
|
+
* 工作目录刻意保持简单:显式指定 > 主目录,与当前开着哪些 IDE 窗口无关。
|
|
500
|
+
* - /projects:列出 Cursor 打开过的项目路径(供手机上查路径 / 复制给 /new)
|
|
498
501
|
* - /stop:终止运行中的 CLI 会话
|
|
499
502
|
* - /model [模型id]:查看 / 设置 CLI 会话模型(持久化;无论选什么模型都强制 maxMode=false)
|
|
500
|
-
* - /cwd [目录]:查看 / 设置默认工作目录(持久化;IDE 不开时 /new 用它)
|
|
501
503
|
*/
|
|
502
|
-
handleCliCommand(
|
|
503
|
-
|
|
504
|
+
handleCliCommand(rawText, chatId) {
|
|
505
|
+
// 输入容错:首尾空格(飞书入站已 trim,这里兜底;trim 同样覆盖全角空格 U+3000);
|
|
506
|
+
// 全角斜杠「/」归一化(中文输入法常见);命令词大小写不敏感(手机输入法自动把句首
|
|
507
|
+
// 大写成 /New)。任务正文原样保留。
|
|
508
|
+
const text = rawText.trim().replace(/^//, '/');
|
|
509
|
+
if (/^\/help\b/i.test(text)) {
|
|
510
|
+
this.feishu.replyText(chatId, '📖 命令一览(大小写、首尾空格都不敏感)\n\n' +
|
|
511
|
+
'🚀 /new 任务描述\n' +
|
|
512
|
+
'拉起一个全新的 AI 会话跑任务,电脑不用开着 Cursor(需已开启常驻服务)。\n' +
|
|
513
|
+
'· 默认工作目录是主目录;可在任务前指定项目:\n' +
|
|
514
|
+
' /new /Users/me/proj 帮我修下测试\n' +
|
|
515
|
+
' /new crm-web 帮我修下测试(项目名唯一时自动匹配到完整路径)\n' +
|
|
516
|
+
'· 非交互模式 + 强制 maxMode=false,每次会话固定只扣 1 次请求\n' +
|
|
517
|
+
'· AI 需要沟通时会发反馈卡片,直接回复即可;同时只能跑一个会话\n\n' +
|
|
518
|
+
'📁 /projects\n' +
|
|
519
|
+
'列出 Cursor 打开过的项目路径(查路径 / 复制给 /new,也可直接用项目名)\n\n' +
|
|
520
|
+
'🛑 /stop\n' +
|
|
521
|
+
'终止运行中的 CLI 会话(任何窗口拉起的都能停)\n\n' +
|
|
522
|
+
'🧠 /model [模型id]\n' +
|
|
523
|
+
'查看 / 设置会话模型(持久化;无论选什么模型都不会走 Max 计费)\n\n' +
|
|
524
|
+
'❓ /help\n' +
|
|
525
|
+
'看这份帮助\n\n' +
|
|
526
|
+
'💬 不带斜杠的消息照常作为反馈:回复某张卡片就送达那个请求,直接发送则给当前等待中的 AI。');
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
if (/^\/projects\b/i.test(text)) {
|
|
530
|
+
const projects = this.cliLauncher.listProjects();
|
|
531
|
+
if (!projects.length) {
|
|
532
|
+
this.feishu.replyText(chatId, '没有找到 Cursor 打开过的项目记录。可以直接用绝对路径:/new /绝对路径 任务描述');
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const lines = projects.slice(0, 20).map((p) => `· ${path.basename(p)} — ${p}`);
|
|
536
|
+
this.feishu.replyText(chatId, `Cursor 打开过的项目(共 ${projects.length} 个${projects.length > 20 ? ',只列最近 20 个' : ''}):\n` +
|
|
537
|
+
lines.join('\n') +
|
|
538
|
+
'\n\n用法:/new 项目名 任务描述(项目名唯一时直接匹配),或 /new 完整路径 任务描述');
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
if (/^\/stop\b/i.test(text)) {
|
|
504
543
|
if (this.cliLauncher.stop()) {
|
|
505
544
|
this.feishu.replyText(chatId, '🛑 正在终止 CLI 会话…结束后我会再发一条收尾消息。');
|
|
506
545
|
}
|
|
@@ -509,7 +548,7 @@ class McpFeedbackServer {
|
|
|
509
548
|
}
|
|
510
549
|
return true;
|
|
511
550
|
}
|
|
512
|
-
const mModel = text.match(/^\/model(?:\s+(\S+))?\s*$/);
|
|
551
|
+
const mModel = text.match(/^\/model(?:\s+(\S+))?\s*$/i);
|
|
513
552
|
if (mModel) {
|
|
514
553
|
if (mModel[1]) {
|
|
515
554
|
this.cliLauncher.writeSettings({ model: mModel[1] });
|
|
@@ -520,52 +559,59 @@ class McpFeedbackServer {
|
|
|
520
559
|
}
|
|
521
560
|
return true;
|
|
522
561
|
}
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
this.cliLauncher.writeSettings({ defaultCwd: dir });
|
|
532
|
-
this.feishu.replyText(chatId, `✅ 默认工作目录已设为 ${dir}\n之后 /new 不带路径时都用它。`);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
const cur = this.cliLauncher.defaultCwd();
|
|
537
|
-
this.feishu.replyText(chatId, `当前默认工作目录:${cur || '(未设置,回退到活跃窗口工作区或主目录)'}\n设置:/cwd 绝对路径`);
|
|
538
|
-
}
|
|
562
|
+
// /cwd 已移除(工作目录简化为「/new 里显式指定,否则主目录」),给老用户指个路
|
|
563
|
+
if (/^\/cwd\b/i.test(text)) {
|
|
564
|
+
this.feishu.replyText(chatId, '/cwd 已移除。现在直接在 /new 里指定:/new 项目名或/绝对路径 任务描述;不指定就在主目录跑。\n用 /projects 可以查项目路径。');
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
// /model 带了多余参数(如 /model a b):不落入反馈路由(用户明显在敲命令),回用法提示
|
|
568
|
+
if (/^\/model\b/i.test(text)) {
|
|
569
|
+
this.feishu.replyText(chatId, '参数不对。用法:/model 模型id(只接受一个参数,留空为查看当前模型)');
|
|
539
570
|
return true;
|
|
540
571
|
}
|
|
541
|
-
const m = text.match(/^\/new\s*([\s\S]*)$/);
|
|
572
|
+
const m = text.match(/^\/new\b\s*([\s\S]*)$/i);
|
|
542
573
|
if (!m)
|
|
543
574
|
return false;
|
|
544
575
|
let task = (m[1] || '').trim();
|
|
545
576
|
if (!task) {
|
|
546
|
-
this.feishu.replyText(chatId, '用法:/new
|
|
547
|
-
'
|
|
577
|
+
this.feishu.replyText(chatId, '用法:/new 任务描述(默认在主目录跑)\n' +
|
|
578
|
+
'可选在任务前带工作目录(绝对路径或项目名):\n' +
|
|
548
579
|
'/new /Users/me/proj 帮我看下测试为什么挂了\n' +
|
|
549
|
-
'
|
|
580
|
+
'/new my-blog 帮我看下测试为什么挂了\n' +
|
|
581
|
+
'相关命令:/projects 查项目路径、/model 设模型、/stop 终止会话。');
|
|
550
582
|
return true;
|
|
551
583
|
}
|
|
552
584
|
if (this.cliLauncher.isRunning()) {
|
|
553
585
|
this.feishu.replyText(chatId, '已有一个 CLI 会话在运行:' + this.cliLauncher.describe() + '\n发 /stop 可先终止它。');
|
|
554
586
|
return true;
|
|
555
587
|
}
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
? this.ownerWorkspace
|
|
561
|
-
: os.homedir());
|
|
562
|
-
const explicitDir = this.expandDirToken(task.split(/\s+/)[0]);
|
|
588
|
+
// 工作目录刻意保持简单、与 IDE 窗口无关:命令里显式指定(路径或项目名)> 主目录。
|
|
589
|
+
let cwd = os.homedir();
|
|
590
|
+
const firstToken = task.split(/\s+/)[0];
|
|
591
|
+
const explicitDir = this.expandDirToken(firstToken);
|
|
563
592
|
if (explicitDir) {
|
|
564
593
|
cwd = explicitDir;
|
|
565
|
-
task = task.slice(
|
|
594
|
+
task = task.slice(firstToken.length).trim();
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
// 首词不是路径 → 试着当项目名匹配(Cursor 打开过的项目,按目录名精确匹配、忽略大小写)。
|
|
598
|
+
// 唯一命中才采用;多个重名让用户用完整路径消歧;没命中就当作任务正文的第一个词。
|
|
599
|
+
const matches = this.cliLauncher
|
|
600
|
+
.listProjects()
|
|
601
|
+
.filter((p) => path.basename(p).toLowerCase() === firstToken.toLowerCase());
|
|
602
|
+
if (matches.length === 1) {
|
|
603
|
+
cwd = matches[0];
|
|
604
|
+
task = task.slice(firstToken.length).trim();
|
|
605
|
+
}
|
|
606
|
+
else if (matches.length > 1) {
|
|
607
|
+
this.feishu.replyText(chatId, `有 ${matches.length} 个同名项目「${firstToken}」:\n` +
|
|
608
|
+
matches.map((p) => `· ${p}`).join('\n') +
|
|
609
|
+
'\n请用完整路径:/new 完整路径 任务描述');
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
566
612
|
}
|
|
567
613
|
if (!task) {
|
|
568
|
-
this.feishu.replyText(chatId, '只给了目录没给任务。用法:/new [
|
|
614
|
+
this.feishu.replyText(chatId, '只给了目录没给任务。用法:/new [目录或项目名] 任务描述');
|
|
569
615
|
return true;
|
|
570
616
|
}
|
|
571
617
|
const err = this.cliLauncher.start(task, cwd, (result) => this.onCliSessionDone(result, chatId));
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "cursor-feedback",
|
|
3
3
|
"displayName": "Cursor Feedback",
|
|
4
4
|
"description": "One Cursor conversation, unlimited AI interactions - Save your monthly request quota! Interactive feedback loop for AI chat via MCP",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.5.0",
|
|
6
6
|
"icon": "icon.png",
|
|
7
7
|
"author": "jianger666",
|
|
8
8
|
"license": "MIT",
|