cursor-feedback 2.7.1 → 2.7.3

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,25 @@
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.7.3](https://github.com/jianger666/cursor-feedback-extension/compare/v2.7.2...v2.7.3) (2026-07-07)
6
+
7
+
8
+ ### Features
9
+
10
+ * **feishu:** 新增 /models 命令查可用模型列表——手机上不知道模型 id 怎么拼的痛点 ([e63fb46](https://github.com/jianger666/cursor-feedback-extension/commit/e63fb46d96c8798912825f1e39c3246e949857c1))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * 全项目扫描修复 8 个 bug + 飞书体验优化 ([d463835](https://github.com/jianger666/cursor-feedback-extension/commit/d46383570f514341fe2ef1a1dba5acbabbb9614e))
16
+
17
+ ### [2.7.2](https://github.com/jianger666/cursor-feedback-extension/compare/v2.7.1...v2.7.2) (2026-07-07)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **cli:** 移除所有 Max Mode 限制——Cursor 新定价按 token 计费,不再需要强制 maxMode=false 和配置守护 ([6724230](https://github.com/jianger666/cursor-feedback-extension/commit/67242301436ce0953d6fff6a86bfec93ab928587))
23
+
5
24
  ### [2.7.1](https://github.com/jianger666/cursor-feedback-extension/compare/v2.7.0...v2.7.1) (2026-07-07)
6
25
 
7
26
 
package/README.md CHANGED
@@ -247,11 +247,13 @@ Send these directly in the bot chat:
247
247
  | `/new /abs/path task description` | Launch in an explicit working directory |
248
248
  | `/new project-name task description` | Launch in a project Cursor has opened before (unique folder-name match) |
249
249
  | `/projects` | List project paths Cursor has opened (to look up / copy into `/new`) |
250
+ | `/status` | Show the running CLI session (task, elapsed time, model) |
250
251
  | `/stop` | Terminate the running CLI session |
251
252
  | `/model [modelId]` | Show / set the session model (persisted) |
253
+ | `/models [keyword]` | List available models (curated picks by default; filter with a keyword, e.g. `/models fable`) |
252
254
  | `/help` | Show command usage |
253
255
 
254
- - Sessions run in **non-interactive mode**. Before every spawn the launcher rewrites `~/.cursor/cli-config.json` to a clean state (`maxMode=false`, target model, stale `selectedModel` removed), so sessions are billed at the normal per-request rate — including models with a `-max` suffix.
256
+ - Sessions run in **non-interactive mode** with the model specified via `--model` flag.
255
257
  - 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.
256
258
  - Put custom rules in `~/.cursor-feedback/cli-rules.md` (e.g. "always reply in Chinese"); they are injected into every `/new` session.
257
259
  - When a session ends (done / error / `/stop` / 3-hour cap), a wrap-up message with the final output is sent to Feishu.
package/README_CN.md CHANGED
@@ -251,11 +251,13 @@ npm install -g cursor-feedback
251
251
  | `/new /绝对路径 任务描述` | 指定工作目录拉起 |
252
252
  | `/new 项目名 任务描述` | 在 Cursor 打开过的项目里拉起(目录名唯一匹配) |
253
253
  | `/projects` | 列出 Cursor 打开过的项目路径(供查路径 / 复制给 `/new`) |
254
+ | `/status` | 查看运行中会话状态(任务、已运行时长、模型) |
254
255
  | `/stop` | 终止运行中的 CLI 会话 |
255
256
  | `/model [模型id]` | 查看 / 设置会话模型(持久化) |
257
+ | `/models [关键词]` | 查可用模型列表(默认常用推荐;带关键词搜索,如 `/models fable`) |
256
258
  | `/help` | 查看命令用法 |
257
259
 
258
- - 会话以**非交互模式**运行。每次拉起前都会把 `~/.cursor/cli-config.json` 重写为干净状态(`maxMode=false` + 目标模型 + 清除 `selectedModel` 残留),因此会话按普通单次请求计费——包括 `-max` 后缀的模型。
260
+ - 会话以**非交互模式**运行,通过 `--model` 参数指定模型。
259
261
  - 拉起的 Agent 也通过本插件的反馈卡片与你沟通:需要确认、汇报进展都会推到飞书,直接回复卡片即可。
260
262
  - 可在 `~/.cursor-feedback/cli-rules.md` 写自定义规则(如「永远用中文」),每次 `/new` 自动注入。
261
263
  - 会话结束(完成 / 异常 / `/stop` / 超 3 小时兜底终止)都会发飞书收尾消息附最终输出。
@@ -38,20 +38,6 @@ exports.CliLauncher = void 0;
38
38
  * CLI 会话拉起器:飞书 /new 命令 → 非交互模式 spawn cursor-agent。
39
39
  *
40
40
  * 关键设计(均来自实测结论,勿随意更改):
41
- * - 默认模型用**非 Max 变体**(见 DEFAULT_MODEL 注释)。
42
- * - spawn 前每次都强制重写 ~/.cursor/cli-config.json:maxMode=false + 目标模型,
43
- * 并删除 selectedModel 残留——三件缺一不可,不能信任上次的状态。
44
- * 实测机制(2026-07-07 文件监视 + 前日账单交叉验证):
45
- * 1. CLI 启动约 5~10s 内会把 --model 解析结果持久化回 cli-config.json 的
46
- * selectedModel(含 effort 参数),会话结束时再写一次——任何一次会话
47
- * (含 IDE 终端手动跑的)都会留下与该会话模型匹配的残留;
48
- * 2. 本次会话的实际计费档取决于启动前的文件状态:干净状态(无 selectedModel、
49
- * maxMode=false)时即使 --model 传 -max 模型也按非 Max 计 1 次;
50
- * 但残留 selectedModel(effort=max) 在场时同样命令扣 40+ 并带 MAX 标签。
51
- * 3. 残留与 --model 冲突时 --model 赢(实验:残留 effort=xhigh + --model low
52
- * → 会话按 low 跑)——所以用非 Max 模型时即使清理和别的进程写残留发生竞态
53
- * 也不会翻车;仅「用户显式选 -max 模型 + 恰好有并发进程写入 max 残留」
54
- * 这一极小竞态窗口无法从我们这侧根除。
55
41
  * - 必须用非交互 print 模式(-p):交互式 TUI 会持久改写 cli-config.json 的模型选择。
56
42
  * - CLI 不读 IDE 的全局 User Rules,用户的个人规则要显式注入到 prompt 里
57
43
  * (从 ~/.cursor-feedback/cli-rules.md 读取,没有则只注入 cursor-feedback 沟通协议)。
@@ -61,6 +47,11 @@ exports.CliLauncher = void 0;
61
47
  * mcp.json(IDE 却能容忍),spawn 前做一次归一化。
62
48
  * - 拉起的 agent 会通过全局注册的 cursor-feedback MCP 发飞书卡片,用户在手机上
63
49
  * 直接和这个会话对话——本模块只负责拉起和收尾,过程中的交互走既有反馈链路。
50
+ *
51
+ * 计费说明(2026-07 Cursor 新定价):
52
+ * - Cursor 已从按请求次数计费全面转向按 token 计费(个人/Teams 均是)。
53
+ * - Max Mode 只影响上下文窗口大小(token 消耗更多),不再有"固定 1 次 vs N 次"的区别。
54
+ * - 因此不再干预 cli-config.json 的 maxMode/selectedModel——用户自由选择模型和档位。
64
55
  */
65
56
  const child_process_1 = require("child_process");
66
57
  const fs = __importStar(require("fs"));
@@ -77,6 +68,8 @@ class CliLauncher {
77
68
  this.startedAt = 0;
78
69
  this.taskBrief = '';
79
70
  this.stopRequested = false;
71
+ /** --list-models 结果缓存(拉一次要几秒,10 分钟内复用) */
72
+ this.modelsCache = null;
80
73
  }
81
74
  /* ---------- 磁盘全局会话锁 ----------
82
75
  * 「同时只跑一个 CLI 会话」必须全局生效:多窗口 + 守护进程是多实例并存,
@@ -114,10 +107,12 @@ class CliLauncher {
114
107
  static pidLooksLikeAgent(pid) {
115
108
  try {
116
109
  if (process.platform === 'win32') {
117
- const out = (0, child_process_1.execFileSync)('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
118
- encoding: 'utf-8',
119
- });
120
- return /cursor-agent|node|cmd\.exe/i.test(out);
110
+ // 查完整命令行而不是进程名:会话进程是 cmd.exe 转发拉起的,进程名只会是
111
+ // cmd.exe / node.exe——按名字匹配等于放行所有 node 进程,pid 复用后会误杀
112
+ // 用户无关进程(如 dev server)。命令行含 cursor-agent 才算真命中。
113
+ const out = (0, child_process_1.execFileSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
114
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf-8' });
115
+ return /cursor-agent/i.test(out);
121
116
  }
122
117
  const out = (0, child_process_1.execFileSync)('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' });
123
118
  return /cursor-agent/i.test(out);
@@ -204,12 +199,45 @@ class CliLauncher {
204
199
  fs.mkdirSync(path.dirname(p), { recursive: true });
205
200
  fs.writeFileSync(p, JSON.stringify(merged, null, 2));
206
201
  }
207
- /** 当前生效模型:env 覆盖 > /model 持久化设置 > 默认。无论选什么模型,spawn 前都强制 maxMode=false */
202
+ /** 当前生效模型:env 覆盖 > /model 持久化设置 > 默认 */
208
203
  model() {
209
204
  return (process.env.CURSOR_FEEDBACK_CLI_MODEL ||
210
205
  this.readSettings().model ||
211
206
  CliLauncher.DEFAULT_MODEL);
212
207
  }
208
+ /**
209
+ * 可用模型列表(调 cursor-agent --list-models 并解析)。
210
+ * 供飞书 /models 命令用:用户在手机上不知道模型 id 怎么拼,给他们一份带显示名的清单。
211
+ * 失败(未安装 / 未登录 / 超时)返回空数组,调用方给引导文案。
212
+ */
213
+ listModels() {
214
+ if (this.modelsCache && Date.now() - this.modelsCache.at < 10 * 60 * 1000) {
215
+ return this.modelsCache.list;
216
+ }
217
+ const bin = this.findBinary();
218
+ if (!bin)
219
+ return [];
220
+ try {
221
+ const isWin = process.platform === 'win32';
222
+ const viaCmdShell = isWin && /\.(cmd|bat|ps1)$/i.test(bin);
223
+ const out = viaCmdShell
224
+ ? (0, child_process_1.execFileSync)('cmd.exe', ['/d', '/s', '/c', bin, '--list-models'], { encoding: 'utf-8', timeout: 30000 })
225
+ : (0, child_process_1.execFileSync)(bin, ['--list-models'], { encoding: 'utf-8', timeout: 30000 });
226
+ const list = [];
227
+ for (const line of out.split('\n')) {
228
+ const m = line.match(/^(\S+) - (.+)$/);
229
+ if (m)
230
+ list.push({ id: m[1], name: m[2].trim() });
231
+ }
232
+ if (list.length > 0)
233
+ this.modelsCache = { at: Date.now(), list };
234
+ return list;
235
+ }
236
+ catch (e) {
237
+ clog('拉取模型列表失败: ' + e);
238
+ return [];
239
+ }
240
+ }
213
241
  /** Cursor IDE 的 storage.json 路径(平台相关) */
214
242
  static storageJsonPath() {
215
243
  if (process.platform === 'darwin') {
@@ -288,7 +316,19 @@ class CliLauncher {
288
316
  return p;
289
317
  }
290
318
  }
291
- return 'cursor-agent'; // 最后交给 PATH 解析,spawn error 时报错提示
319
+ // 常见安装位都没有 PATH(which/where)。真找不到时返回 null,
320
+ // 让 start() 给出「怎么装」的友好提示,而不是晦涩的 spawn ENOENT
321
+ try {
322
+ const probe = process.platform === 'win32' ? 'where' : 'which';
323
+ const out = (0, child_process_1.execFileSync)(probe, ['cursor-agent'], { encoding: 'utf-8' }).trim();
324
+ const first = out.split(/\r?\n/)[0];
325
+ if (first)
326
+ return first;
327
+ }
328
+ catch {
329
+ // PATH 里也没有
330
+ }
331
+ return null;
292
332
  }
293
333
  /**
294
334
  * spawn 前把 ~/.cursor/mcp.json 里的非字符串 env 值归一化为字符串。
@@ -318,85 +358,6 @@ class CliLauncher {
318
358
  // mcp.json 不存在或损坏:不是本模块的职责,交给 cursor-agent 自己报错
319
359
  }
320
360
  }
321
- static cliConfigPath() {
322
- return path.join(os.homedir(), '.cursor', 'cli-config.json');
323
- }
324
- /** 配置里是否存在会触发 Max 计费的状态(两个独立触发源,任一命中即危险) */
325
- static hasMaxResidue(cfg) {
326
- if (cfg.maxMode === true)
327
- return true;
328
- const sel = cfg.selectedModel;
329
- if (sel?.parameters?.some((p) => p?.id === 'effort' && p?.value === 'max'))
330
- return true;
331
- return false;
332
- }
333
- /**
334
- * spawn 前强制写 cli-config.json:maxMode=false + 目标模型 + 删除 selectedModel。
335
- * 每次都写——上次会话(含用户在终端手动跑的)会留下残留,状态不可信。
336
- */
337
- ensureMaxModeOff() {
338
- const p = CliLauncher.cliConfigPath();
339
- let cfg = {};
340
- try {
341
- cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
342
- }
343
- catch {
344
- // 文件不存在或损坏时从空对象重建(cursor-agent 会补齐其余字段)
345
- }
346
- cfg.maxMode = false;
347
- cfg.model = { modelId: this.model(), maxMode: false };
348
- cfg.hasChangedDefaultModel = true;
349
- // 清掉 CLI 自己写入的 selectedModel(Max 模型会话会留下 effort=max 的残留,
350
- // 不删的话下次会话可能沿用 Max 档计费)
351
- delete cfg.selectedModel;
352
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
353
- clog(`cli-config.json 已写入 maxMode=false, model=${this.model()},并清除 selectedModel`);
354
- }
355
- /**
356
- * 会话运行期间的配置守护:监听 cli-config.json,一旦出现 Max 残留立即改回干净状态。
357
- * 动机:spawn 前的清理只保证「启动瞬间」干净;会话运行中 CLI 自己会把当前模型写回
358
- * selectedModel(-max 模型就是 effort=max),若 CLI 存在断线重连等重读配置的路径,
359
- * 残留可能被中途拾取。守护用 fs.watch 事件监听做到毫秒级响应,再加 1s 轮询兜底
360
- * (fs.watch 少数场景会漏事件)。只在「危险」时才写(maxMode=true 或 effort=max),
361
- * 非 Max 状态一律不动,避免与 CLI 的正常写回互相打架。
362
- * @returns 停止守护的清理函数(会话结束时调用)
363
- */
364
- startConfigGuard() {
365
- const p = CliLauncher.cliConfigPath();
366
- const checkAndClean = () => {
367
- try {
368
- const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
369
- if (CliLauncher.hasMaxResidue(cfg)) {
370
- this.ensureMaxModeOff();
371
- clog('配置守护:检测到 Max 残留被写回,已立即清除');
372
- }
373
- }
374
- catch {
375
- // 文件正被写入(半包 JSON)或暂不可读:跳过,等下一次事件/轮询
376
- }
377
- };
378
- // 监听目录而不是文件本身:CLI 若用「写临时文件再 rename」的原子写法,
379
- // 直接 watch 文件会在第一次替换后失去目标(inode 变了)
380
- let watcher = null;
381
- try {
382
- watcher = fs.watch(path.dirname(p), (_event, filename) => {
383
- // filename 在个别平台可能为 null,此时也检查一次(checkAndClean 幂等且廉价)
384
- if (!filename || filename === path.basename(p))
385
- checkAndClean();
386
- });
387
- watcher.on('error', () => {
388
- // 监听挂掉不影响安全性,轮询兜底还在
389
- });
390
- }
391
- catch {
392
- // 平台不支持 fs.watch 时只靠轮询
393
- }
394
- const timer = setInterval(checkAndClean, 1000);
395
- return () => {
396
- clearInterval(timer);
397
- watcher?.close();
398
- };
399
- }
400
361
  /** 用户自定义注入规则:~/.cursor-feedback/cli-rules.md(可选) */
401
362
  readUserRules() {
402
363
  try {
@@ -439,18 +400,14 @@ class CliLauncher {
439
400
  if (!this.tryAcquireLock(task)) {
440
401
  return '已有一个 CLI 会话在运行:' + (this.describe() || '(另一个窗口实例刚刚抢先拉起)');
441
402
  }
442
- try {
443
- this.ensureMaxModeOff();
444
- }
445
- catch (e) {
446
- this.releaseLock();
447
- return '写入 cli-config.json 失败:' + e;
448
- }
449
403
  this.normalizeMcpJsonEnv();
450
404
  const bin = this.findBinary();
451
405
  if (!bin) {
452
406
  this.releaseLock();
453
- return '找不到 cursor-agent,可通过环境变量 CURSOR_AGENT_PATH 指定路径。';
407
+ return ('这台电脑还没安装 Cursor CLI(cursor-agent)。\n' +
408
+ '安装方法:在电脑终端执行 curl https://cursor.com/install -fsS | bash\n' +
409
+ '(Windows 用 PowerShell:irm https://cursor.com/install.ps1 | iex)\n' +
410
+ '装好后重新 /new 即可;装在非默认位置时可用环境变量 CURSOR_AGENT_PATH 指定路径。');
454
411
  }
455
412
  const workDir = fs.existsSync(cwd) ? cwd : os.homedir();
456
413
  const prompt = this.buildPrompt(task);
@@ -502,13 +459,16 @@ class CliLauncher {
502
459
  clog('会话超过时长上限,强制终止');
503
460
  CliLauncher.killTree(child, 'SIGKILL');
504
461
  }, CliLauncher.SESSION_MAX_MS);
505
- // 会话全程守护 cli-config.json,Max 残留一出现就清(防 CLI 中途重读配置拾取残留)
506
- const stopConfigGuard = this.startConfigGuard();
507
462
  child.stdout?.on('data', (chunk) => { stdout = append(stdout, chunk); });
508
463
  child.stderr?.on('data', (chunk) => { stderr = append(stderr, chunk); });
464
+ // spawn 失败时 Node 会先后触发 error 和 close 两个事件(实测证实),
465
+ // 不加防重标志 finish 会跑两次 → 用户在飞书收到两条收尾消息
466
+ let finished = false;
509
467
  const finish = (code) => {
468
+ if (finished)
469
+ return;
470
+ finished = true;
510
471
  clearTimeout(killTimer);
511
- stopConfigGuard();
512
472
  const elapsedMs = Date.now() - this.startedAt;
513
473
  // 跨实例 /stop 会在锁上打 stopRequested 标记再杀进程,这里一并算「主动终止」
514
474
  const lockAtFinish = this.readLock();
@@ -620,13 +580,6 @@ class CliLauncher {
620
580
  }
621
581
  }
622
582
  exports.CliLauncher = CliLauncher;
623
- /**
624
- * 默认模型:用非 Max 变体(2026-07-06 账单实测)。`-max` 后缀模型(显示名
625
- * "Fable 5 1M Max Thinking")跑过一次后,CLI 会把 selectedModel(effort=max)
626
- * 持久化进 cli-config.json,这份残留不清则后续会话按 Max 计费(实测扣 40+)。
627
- * ensureMaxModeOff 已做残留清理,但默认模型仍选 thinking-xhigh(非 Max 最高档,
628
- * 固定扣 1 次)从根上避开这类状态机风险。
629
- */
630
583
  CliLauncher.DEFAULT_MODEL = 'claude-fable-5-thinking-xhigh';
631
584
  /** 会话时长兜底:防止无人回复的 headless 会话无限续期挂着 */
632
585
  CliLauncher.SESSION_MAX_MS = 3 * 60 * 60 * 1000;
@@ -117,24 +117,30 @@ function copySelf() {
117
117
  throw new Error(`当前包缺少 ${item}(${src}),无法安装守护`);
118
118
  fs.cpSync(src, path.join(app, item), { recursive: true });
119
119
  }
120
+ // 依赖来源可能有两处且可同时存在:父级 node_modules(npm 扁平布局的共享依赖)、
121
+ // pkgRoot/node_modules(源码布局的全部依赖 / npm 版本冲突时的嵌套依赖)。
122
+ // 必须两处都拷(父级先拷、嵌套后拷覆盖同名),只拷一处会丢依赖导致守护起不来。
120
123
  const ownDeps = path.join(pkgRoot, 'node_modules');
121
124
  const parentDeps = path.resolve(pkgRoot, '..');
122
- let depsSrc = null;
123
- if (fs.existsSync(ownDeps)) {
124
- depsSrc = ownDeps;
125
- }
126
- else if (path.basename(parentDeps) === 'node_modules') {
127
- depsSrc = parentDeps;
128
- }
129
- if (!depsSrc)
125
+ const sources = [];
126
+ if (path.basename(parentDeps) === 'node_modules')
127
+ sources.push(parentDeps);
128
+ if (fs.existsSync(ownDeps))
129
+ sources.push(ownDeps);
130
+ if (sources.length === 0)
130
131
  throw new Error('找不到依赖 node_modules,无法安装守护');
131
- fs.cpSync(depsSrc, path.join(app, 'node_modules'), {
132
- recursive: true,
133
- dereference: true,
134
- // npm 布局下父级 node_modules 里含 cursor-feedback 自身,跳过避免套娃拷贝
135
- filter: (src) => !src.includes(`${path.sep}cursor-feedback${path.sep}`) &&
136
- !src.endsWith(`${path.sep}cursor-feedback`),
137
- });
132
+ for (const depsSrc of sources) {
133
+ // 只精确排除 depsSrc 直下的 cursor-feedback 包目录(防套娃拷贝自身)。
134
+ // 不能按「路径含 /cursor-feedback/」模糊排除:安装路径的上级目录恰好叫
135
+ // cursor-feedback 时会把所有依赖误排掉,拷出一个空 node_modules。
136
+ const selfPkg = path.join(depsSrc, 'cursor-feedback');
137
+ fs.cpSync(depsSrc, path.join(app, 'node_modules'), {
138
+ recursive: true,
139
+ dereference: true,
140
+ force: true,
141
+ filter: (src) => src !== selfPkg && !src.startsWith(selfPkg + path.sep),
142
+ });
143
+ }
138
144
  dlog(`包已拷贝到 ${app}`);
139
145
  }
140
146
  /* ------------------------------ macOS (launchd) ------------------------------ */
@@ -292,10 +298,23 @@ function winTaskExists() {
292
298
  return false;
293
299
  }
294
300
  }
301
+ /** semver 大小比较:a < b 返回 -1,相等 0,a > b 返回 1(只比主.次.补丁数字段) */
302
+ function compareSemver(a, b) {
303
+ const pa = a.split('.').map((x) => parseInt(x, 10) || 0);
304
+ const pb = b.split('.').map((x) => parseInt(x, 10) || 0);
305
+ for (let i = 0; i < 3; i++) {
306
+ if ((pa[i] || 0) < (pb[i] || 0))
307
+ return -1;
308
+ if ((pa[i] || 0) > (pb[i] || 0))
309
+ return 1;
310
+ }
311
+ return 0;
312
+ }
295
313
  /**
296
314
  * 守护自动升级:已安装的守护是安装时的快照,不会随 npx @latest 走。
297
- * IDE 里的新版 server 启动时调用这里——发现守护版本与自己不一致就静默重装
315
+ * IDE 里的新版 server 启动时调用这里——发现守护版本【低于】自己就静默重装
298
316
  * (重装会 bootout 旧守护、拷贝新包、重新拉起),用户零操作。
317
+ * 只升不降:某个窗口 pin 了旧版包时绝不能把新守护降级回去(否则新旧窗口互相拉锯)。
299
318
  *
300
319
  * 并发防护:多窗口 = 多个 server 同时启动,重装涉及删目录 + 整树拷贝,撞上会互相
301
320
  * 写坏。用 wx 独占锁串行化,抢不到的直接跳过(反正有人在装);残锁超 10 分钟视为
@@ -306,7 +325,8 @@ function winTaskExists() {
306
325
  function upgradeDaemonIfOutdated(currentVersion) {
307
326
  try {
308
327
  const st = daemonStatus();
309
- if (!st.installed || !st.installedVersion || st.installedVersion === currentVersion) {
328
+ // 只在守护版本严格低于当前版本时才重装(防止 pin 旧版的窗口把新守护降级)
329
+ if (!st.installed || !st.installedVersion || compareSemver(st.installedVersion, currentVersion) >= 0) {
310
330
  return false;
311
331
  }
312
332
  const lock = path.join(daemonRoot(), 'upgrade.lock');
package/dist/extension.js CHANGED
@@ -776,8 +776,13 @@ class FeedbackViewProvider {
776
776
  */
777
777
  async _handleFeedbackSubmit(payload) {
778
778
  // 提交到「当前请求所属」的端口:请求与端口强绑定,绝不能退回 basePort——
779
- // 多项目同时触发时 basePort 可能是别的项目的 server,会得到 "Request not found"
780
- const port = this._currentRequestPort || this._activePort || this._basePort;
779
+ // 多项目同时触发时 basePort 可能是别的项目的 server,会得到 "Request not found"
780
+ // 两个端口都被抖动置空时明确报错让用户重试(下一秒轮询会找回端口),绝不瞎提交。
781
+ const port = this._currentRequestPort || this._activePort;
782
+ if (!port) {
783
+ vscode.window.showErrorMessage(this._i18n.submitFailed + ': ' + this._i18n.cannotConnectMCP);
784
+ return;
785
+ }
781
786
  try {
782
787
  const response = await this._httpPost(`http://127.0.0.1:${port}/api/feedback/submit`, JSON.stringify({
783
788
  requestId: payload.requestId,
@@ -1064,7 +1069,10 @@ class FeedbackViewProvider {
1064
1069
  const requestId = payload?.requestId;
1065
1070
  if (!requestId || typeof payload?.paused !== 'boolean')
1066
1071
  return;
1067
- const port = this._currentRequestPort || this._activePort || this._basePort;
1072
+ // _handleFeedbackSubmit:绝不回退 basePort(可能是别的项目的 server)
1073
+ const port = this._currentRequestPort || this._activePort;
1074
+ if (!port)
1075
+ return; // 端口抖动瞬间:静默跳过,倒计时照常走,用户可再点
1068
1076
  try {
1069
1077
  const response = await this._httpPost(`http://127.0.0.1:${port}/api/feedback/pause`, JSON.stringify({ requestId, paused: payload.paused }));
1070
1078
  const result = JSON.parse(response);
package/dist/feishu.js CHANGED
@@ -426,7 +426,24 @@ class FeishuBridge {
426
426
  */
427
427
  bufferInbound(chatId, parentId, messageId, text, images, files) {
428
428
  const key = chatId || '_';
429
- const existing = this.inboundBuffer.get(key);
429
+ let existing = this.inboundBuffer.get(key);
430
+ // 用户在合并窗口内先后回复了两张不同卡片:两条是给不同请求的独立回复,
431
+ // 绝不能合并(旧实现会丢掉第二条的 parentId、两条都窜进第一张卡片)。
432
+ // 仅当两条都显式带 parentId 且不同才拆开结算;「无 → 有」维持原合并行为
433
+ // (飞书图文拆条时部分分片可能不带 parent_id,不能误拆)。
434
+ if (existing && existing.parentId && parentId && existing.parentId !== parentId) {
435
+ clearTimeout(existing.timer);
436
+ this.inboundBuffer.delete(key);
437
+ this.onReply?.({
438
+ parentId: existing.parentId,
439
+ text: existing.texts.join('\n').trim(),
440
+ chatId,
441
+ messageId: existing.lastMessageId,
442
+ images: existing.images,
443
+ files: existing.files,
444
+ });
445
+ existing = undefined;
446
+ }
430
447
  if (existing)
431
448
  clearTimeout(existing.timer);
432
449
  const buf = existing || {
@@ -399,7 +399,7 @@ class McpFeedbackServer {
399
399
  return;
400
400
  // 斜杠命令优先于反馈路由:用户显式输入命令时不应被当成对某张卡片的回复或排队消息。
401
401
  // 大小写不敏感:手机输入法常自动把句首字母大写(/New),必须容错。
402
- if (/^\s*[//](new|stop|model|cwd|help|projects)\b/i.test(text) && this.handleCliCommand(text, chatId))
402
+ if (/^\s*[//](new|stop|model|models|cwd|help|projects|status)\b/i.test(text) && this.handleCliCommand(text, chatId))
403
403
  return;
404
404
  if (parentId) {
405
405
  // 用户「回复」了某条卡片 → 用 parent_id 精确路由
@@ -497,11 +497,13 @@ class McpFeedbackServer {
497
497
  }
498
498
  /**
499
499
  * 处理飞书斜杠命令。返回是否已消费该消息。
500
- * - /new [目录或项目名] 任务描述:拉起 headless CLI 会话(非交互,spawn 前清 Max 残留)。
500
+ * - /new [目录或项目名] 任务描述:拉起 headless CLI 会话(非交互模式)。
501
501
  * 工作目录刻意保持简单:显式指定 > 主目录,与当前开着哪些 IDE 窗口无关。
502
502
  * - /projects:列出 Cursor 打开过的项目路径(供手机上查路径 / 复制给 /new)
503
+ * - /status:查看当前 CLI 会话状态
503
504
  * - /stop:终止运行中的 CLI 会话
504
- * - /model [模型id]:查看 / 设置 CLI 会话模型(持久化;无论选什么模型都强制 maxMode=false)
505
+ * - /model [模型id]:查看 / 设置 CLI 会话模型(持久化)
506
+ * - /models [关键词]:可用模型列表(无关键词给常用推荐,带关键词过滤)
505
507
  */
506
508
  handleCliCommand(rawText, chatId) {
507
509
  // 输入容错:首尾空格(飞书入站已 trim,这里兜底;trim 同样覆盖全角空格 U+3000);
@@ -518,10 +520,14 @@ class McpFeedbackServer {
518
520
  '· AI 需要沟通时会发反馈卡片,直接回复即可;同时只能跑一个会话\n\n' +
519
521
  '📁 /projects\n' +
520
522
  '列出 Cursor 打开过的项目路径(查路径 / 复制给 /new,也可直接用项目名)\n\n' +
523
+ '🏃 /status\n' +
524
+ '查看当前 CLI 会话状态(任务、已运行时长、模型)\n\n' +
521
525
  '🛑 /stop\n' +
522
526
  '终止运行中的 CLI 会话(任何窗口拉起的都能停)\n\n' +
523
527
  '🧠 /model [模型id]\n' +
524
- '查看 / 设置会话模型(持久化)\n\n' +
528
+ '查看 / 设置会话模型(持久化),Max 变体也可以随意选\n\n' +
529
+ '📋 /models [关键词]\n' +
530
+ '查可用模型列表(不带关键词看常用推荐;带关键词搜索,如 /models fable)\n\n' +
525
531
  '❓ /help\n' +
526
532
  '看这份帮助\n\n' +
527
533
  '💬 不带斜杠的消息照常作为反馈:回复某张卡片就送达那个请求,直接发送则给当前等待中的 AI。');
@@ -549,14 +555,75 @@ class McpFeedbackServer {
549
555
  }
550
556
  return true;
551
557
  }
558
+ if (/^\/status\b/i.test(text)) {
559
+ if (this.cliLauncher.isRunning()) {
560
+ this.feishu.replyText(chatId, `🏃 CLI 会话运行中:${this.cliLauncher.describe()}\n` +
561
+ `模型:${this.cliLauncher.model()}\n` +
562
+ 'AI 需要沟通时会发反馈卡片;发 /stop 可终止。');
563
+ }
564
+ else {
565
+ this.feishu.replyText(chatId, '当前没有运行中的 CLI 会话。发 /new 任务描述 可拉起一个。');
566
+ }
567
+ return true;
568
+ }
569
+ // /models [关键词]:可用模型列表。全量 140+ 个在手机上太长,无关键词时给常用推荐 +
570
+ // 搜索引导;带关键词按 id / 显示名过滤(大小写不敏感)
571
+ const mModels = text.match(/^\/models(?:\s+(.+))?\s*$/i);
572
+ if (mModels) {
573
+ const all = this.cliLauncher.listModels();
574
+ if (all.length === 0) {
575
+ this.feishu.replyText(chatId, '拉取模型列表失败(cursor-agent 未安装或未登录)。可直接 /model 模型id 手动设置。');
576
+ return true;
577
+ }
578
+ const kw = (mModels[1] || '').trim().toLowerCase();
579
+ if (kw) {
580
+ const hits = all.filter((m) => m.id.toLowerCase().includes(kw) || m.name.toLowerCase().includes(kw));
581
+ if (hits.length === 0) {
582
+ this.feishu.replyText(chatId, `没有匹配「${mModels[1]?.trim()}」的模型。试试 /models fable、/models opus、/models gpt`);
583
+ }
584
+ else {
585
+ const MAX_LIST = 40;
586
+ const lines = hits.slice(0, MAX_LIST).map((m) => `· ${m.id}\n ${m.name}`);
587
+ this.feishu.replyText(chatId, `匹配「${mModels[1]?.trim()}」的模型(${hits.length} 个${hits.length > MAX_LIST ? `,只列前 ${MAX_LIST}` : ''}):\n` +
588
+ lines.join('\n') +
589
+ '\n\n设置:/model 模型id');
590
+ }
591
+ }
592
+ else {
593
+ // 常用推荐:从实时列表里挑出存在的(模型下线自动消失,不硬编码失效项)
594
+ const picks = [
595
+ 'claude-fable-5-thinking-xhigh',
596
+ 'claude-fable-5-thinking-max',
597
+ 'claude-opus-4-8-thinking-high',
598
+ 'claude-sonnet-5-thinking-high',
599
+ 'gpt-5.5-medium',
600
+ 'gpt-5.3-codex-high',
601
+ 'composer-2.5-fast',
602
+ 'gemini-3.1-pro',
603
+ ];
604
+ const byId = new Map(all.map((m) => [m.id, m]));
605
+ const lines = picks
606
+ .filter((id) => byId.has(id))
607
+ .map((id) => `· ${id}\n ${byId.get(id).name}`);
608
+ this.feishu.replyText(chatId, `共 ${all.length} 个可用模型。常用推荐:\n${lines.join('\n')}\n\n` +
609
+ '🔍 按关键词搜完整列表:/models fable、/models opus、/models sonnet、/models gpt、/models max\n' +
610
+ `设置:/model 模型id(当前:${this.cliLauncher.model()})`);
611
+ }
612
+ return true;
613
+ }
552
614
  const mModel = text.match(/^\/model(?:\s+(\S+))?\s*$/i);
553
615
  if (mModel) {
554
616
  if (mModel[1]) {
617
+ // 尽力校验:列表拉得到且不含该 id 时提醒(可能是笔误),但不拦截——
618
+ // 列表可能滞后于新模型上线,用户明确要设就尊重
619
+ const all = this.cliLauncher.listModels();
620
+ const known = all.length === 0 || all.some((m) => m.id === mModel[1]);
555
621
  this.cliLauncher.writeSettings({ model: mModel[1] });
556
- this.feishu.replyText(chatId, `✅ CLI 模型已设为 ${mModel[1]}`);
622
+ this.feishu.replyText(chatId, `✅ CLI 模型已设为 ${mModel[1]}` +
623
+ (known ? '' : '\n⚠️ 这个 id 不在当前模型列表里(可能拼错了),发 /models 可查可用模型。'));
557
624
  }
558
625
  else {
559
- this.feishu.replyText(chatId, `当前 CLI 模型:${this.cliLauncher.model()}\n设置:/model 模型id(例如 /model claude-fable-5-thinking-xhigh)`);
626
+ this.feishu.replyText(chatId, `当前 CLI 模型:${this.cliLauncher.model()}\n设置:/model 模型id\n查列表:/models(可加关键词,如 /models fable)`);
560
627
  }
561
628
  return true;
562
629
  }
@@ -620,11 +687,11 @@ class McpFeedbackServer {
620
687
  this.feishu.replyText(chatId, '❌ 拉起 CLI 会话失败:' + err);
621
688
  }
622
689
  else {
623
- this.feishu.replyText(chatId, '🚀 CLI 会话已拉起(非交互模式)\n' +
690
+ this.feishu.replyText(chatId, '🚀 CLI 会话已拉起\n' +
624
691
  `模型:${this.cliLauncher.model()}\n` +
625
692
  `工作目录:${cwd}\n` +
626
693
  `任务:${task.length > 100 ? task.slice(0, 100) + '…' : task}\n\n` +
627
- 'AI 需要和你沟通时会发反馈卡片,直接回复卡片即可;发 /stop 可随时终止。');
694
+ 'AI 需要和你沟通时会发反馈卡片,直接回复卡片即可。\n随时可发 /status 看进度、/stop 终止。');
628
695
  }
629
696
  return true;
630
697
  }
@@ -1129,6 +1196,11 @@ class McpFeedbackServer {
1129
1196
  };
1130
1197
  }
1131
1198
  else {
1199
+ // 覆盖别的会话(chatId 不同)的未过期暂存时,先给旧消息回执「未送达」——
1200
+ // 静默覆盖会让旧消息连过期提示都收不到(定时器随即被重置)
1201
+ if (prev && prev.chatId !== chatId && Date.now() - prev.at <= prev.ttlMs) {
1202
+ this.feishu.replyToMessage(prev.messageId, prev.chatId, '⚠️ 消息未能送达 AI(已被更新的消息顶替)。需要的话请待 AI 回复后重新发送一次。');
1203
+ }
1132
1204
  this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId, ttlMs, forProjectDir };
1133
1205
  }
1134
1206
  this.armStashExpiryNotice();
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.7.1",
5
+ "version": "2.7.3",
6
6
  "icon": "icon.png",
7
7
  "author": "jianger666",
8
8
  "license": "MIT",