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 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
 
@@ -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 (!this.child)
71
- return '';
72
- const mins = Math.round((Date.now() - this.startedAt) / 60000);
73
- return `「${this.taskBrief}」(已运行 ${mins} 分钟)`;
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.child)
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
- const stopped = this.stopRequested;
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
- /** 用户 /stop 主动终止会话。返回是否有会话被终止。 */
377
+ /**
378
+ * 用户 /stop 主动终止会话。返回是否有会话被终止。
379
+ * 跨实例:会话可能由另一个窗口实例托管(飞书把 /stop 推给了不同实例),
380
+ * 此时按锁里的 pid 直接终止会话进程,托管实例的 close 回调会自然收尾并回执。
381
+ */
267
382
  stop() {
268
- if (!this.child)
269
- return false;
270
- this.stopRequested = true;
271
- CliLauncher.killTree(this.child, 'SIGTERM');
272
- // SIGTERM 5s 内没退出则补 SIGKILL
273
- const child = this.child;
274
- setTimeout(() => {
275
- if (this.child === child) {
276
- CliLauncher.killTree(child, 'SIGKILL');
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
- }, 5000);
279
- return true;
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.0",
5
+ "version": "2.4.1",
6
6
  "icon": "icon.png",
7
7
  "author": "jianger666",
8
8
  "license": "MIT",