cursor-feedback 2.4.0 → 2.4.1
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/CHANGELOG.md +7 -0
- package/dist/cli-launcher.js +172 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
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.4.1](https://github.com/jianger666/cursor-feedback-extension/compare/v2.4.0...v2.4.1) (2026-07-06)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **cli:** CLI 会话全局锁——多窗口/守护多实例并存时 /new /stop 跨实例一致 ([679c3ef](https://github.com/jianger666/cursor-feedback-extension/commit/679c3ef3ce43af768be939d4059a452cd3d20178))
|
|
11
|
+
|
|
5
12
|
## [2.4.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.3.1...v2.4.0) (2026-07-06)
|
|
6
13
|
|
|
7
14
|
|
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');
|
|
@@ -182,17 +280,24 @@ class CliLauncher {
|
|
|
182
280
|
* @returns 启动失败时返回错误说明字符串;成功返回 null,结果经 onDone 回调
|
|
183
281
|
*/
|
|
184
282
|
start(task, cwd, onDone) {
|
|
185
|
-
if (this.
|
|
283
|
+
if (this.isRunning())
|
|
186
284
|
return '已有一个 CLI 会话在运行:' + this.describe();
|
|
285
|
+
// 原子抢全局锁:两个实例同时收到 /new 时只有一个能抢到
|
|
286
|
+
if (!this.tryAcquireLock(task)) {
|
|
287
|
+
return '已有一个 CLI 会话在运行:' + (this.describe() || '(另一个窗口实例刚刚抢先拉起)');
|
|
288
|
+
}
|
|
187
289
|
try {
|
|
188
290
|
this.ensureMaxModeOff();
|
|
189
291
|
}
|
|
190
292
|
catch (e) {
|
|
293
|
+
this.releaseLock();
|
|
191
294
|
return '写入 cli-config.json 失败:' + e;
|
|
192
295
|
}
|
|
193
296
|
const bin = this.findBinary();
|
|
194
|
-
if (!bin)
|
|
297
|
+
if (!bin) {
|
|
298
|
+
this.releaseLock();
|
|
195
299
|
return '找不到 cursor-agent,可通过环境变量 CURSOR_AGENT_PATH 指定路径。';
|
|
300
|
+
}
|
|
196
301
|
const workDir = fs.existsSync(cwd) ? cwd : os.homedir();
|
|
197
302
|
const prompt = this.buildPrompt(task);
|
|
198
303
|
let stdout = '';
|
|
@@ -225,12 +330,14 @@ class CliLauncher {
|
|
|
225
330
|
});
|
|
226
331
|
}
|
|
227
332
|
catch (e) {
|
|
333
|
+
this.releaseLock();
|
|
228
334
|
return '拉起 cursor-agent 失败:' + e;
|
|
229
335
|
}
|
|
230
336
|
this.child = child;
|
|
231
337
|
this.startedAt = Date.now();
|
|
232
338
|
this.taskBrief = task.length > 40 ? task.slice(0, 40) + '…' : task;
|
|
233
339
|
this.stopRequested = false;
|
|
340
|
+
this.updateLockPid(child.pid || 0);
|
|
234
341
|
clog(`CLI 会话已拉起: pid=${child.pid} cwd=${workDir} model=${this.model()}`);
|
|
235
342
|
let timedOut = false;
|
|
236
343
|
const killTimer = setTimeout(() => {
|
|
@@ -243,8 +350,12 @@ class CliLauncher {
|
|
|
243
350
|
const finish = (code) => {
|
|
244
351
|
clearTimeout(killTimer);
|
|
245
352
|
const elapsedMs = Date.now() - this.startedAt;
|
|
246
|
-
|
|
353
|
+
// 跨实例 /stop 会在锁上打 stopRequested 标记再杀进程,这里一并算「主动终止」
|
|
354
|
+
const lockAtFinish = this.readLock();
|
|
355
|
+
const stopped = this.stopRequested ||
|
|
356
|
+
!!(lockAtFinish && lockAtFinish.ownerPid === process.pid && lockAtFinish.stopRequested);
|
|
247
357
|
this.child = null;
|
|
358
|
+
this.releaseLock();
|
|
248
359
|
clog(`CLI 会话结束: code=${code} elapsed=${Math.round(elapsedMs / 1000)}s timedOut=${timedOut} stopped=${stopped}`);
|
|
249
360
|
onDone({
|
|
250
361
|
code,
|
|
@@ -263,20 +374,60 @@ class CliLauncher {
|
|
|
263
374
|
child.on('close', (code) => finish(code));
|
|
264
375
|
return null;
|
|
265
376
|
}
|
|
266
|
-
/**
|
|
377
|
+
/**
|
|
378
|
+
* 用户 /stop 主动终止会话。返回是否有会话被终止。
|
|
379
|
+
* 跨实例:会话可能由另一个窗口实例托管(飞书把 /stop 推给了不同实例),
|
|
380
|
+
* 此时按锁里的 pid 直接终止会话进程,托管实例的 close 回调会自然收尾并回执。
|
|
381
|
+
*/
|
|
267
382
|
stop() {
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
383
|
+
if (this.child) {
|
|
384
|
+
this.stopRequested = true;
|
|
385
|
+
CliLauncher.killTree(this.child, 'SIGTERM');
|
|
386
|
+
// SIGTERM 5s 内没退出则补 SIGKILL
|
|
387
|
+
const child = this.child;
|
|
388
|
+
setTimeout(() => {
|
|
389
|
+
if (this.child === child) {
|
|
390
|
+
CliLauncher.killTree(child, 'SIGKILL');
|
|
391
|
+
}
|
|
392
|
+
}, 5000);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const lock = this.aliveLock();
|
|
396
|
+
if (lock && lock.pid > 0) {
|
|
397
|
+
if (!CliLauncher.pidLooksLikeAgent(lock.pid)) {
|
|
398
|
+
// pid 已被无关进程复用(残锁):清锁但绝不误杀
|
|
399
|
+
clog(`锁 pid=${lock.pid} 不是 cursor-agent(已被复用),清除残锁`);
|
|
400
|
+
fs.rmSync(this.lockPath(), { force: true });
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
clog(`跨实例终止 CLI 会话: pid=${lock.pid}(由 ${lock.ownerPid} 托管)`);
|
|
404
|
+
try {
|
|
405
|
+
// 先在锁上打「主动终止」标记,托管实例收尾时据此报「已终止」而非「异常退出」
|
|
406
|
+
fs.writeFileSync(this.lockPath(), JSON.stringify({ ...lock, stopRequested: true }));
|
|
277
407
|
}
|
|
278
|
-
|
|
279
|
-
|
|
408
|
+
catch {
|
|
409
|
+
// 标记失败只影响收尾文案
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
// 会话进程是托管实例 detached 出来的进程组组长,杀整组
|
|
413
|
+
if (process.platform === 'win32') {
|
|
414
|
+
(0, child_process_1.spawn)('taskkill', ['/pid', String(lock.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
try {
|
|
418
|
+
process.kill(-lock.pid, 'SIGTERM');
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// 进程刚好自己结束了
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
280
431
|
}
|
|
281
432
|
/**
|
|
282
433
|
* 终止整个进程树:POSIX 杀进程组(detached spawn 后 child.pid 即组长),
|
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.4.
|
|
5
|
+
"version": "2.4.1",
|
|
6
6
|
"icon": "icon.png",
|
|
7
7
|
"author": "jianger666",
|
|
8
8
|
"license": "MIT",
|