cursor-feedback 2.3.1 → 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.
@@ -59,6 +59,9 @@ const os = __importStar(require("os"));
59
59
  const fs = __importStar(require("fs"));
60
60
  const path = __importStar(require("path"));
61
61
  const feishu_js_1 = require("./feishu.js");
62
+ const cli_launcher_js_1 = require("./cli-launcher.js");
63
+ const keep_awake_js_1 = require("./keep-awake.js");
64
+ const daemon_install_js_1 = require("./daemon-install.js");
62
65
  // ⚠️ MCP stdio 协议要求 stdout 只承载 JSON-RPC 消息。第三方库(尤其飞书 SDK 的内置 logger,
63
66
  // 输出形如 "[info]: [...]")会用 console.log/info/debug 往 stdout 打日志,一旦混入就会让
64
67
  // Cursor 端 JSON 解析失败、连接进入 failed(表现为 "Not connected")。这里在进程最早期把这三个
@@ -86,7 +89,7 @@ const PKG_VERSION = (() => {
86
89
  * MCP Feedback Server
87
90
  */
88
91
  class McpFeedbackServer {
89
- constructor(port = 8766) {
92
+ constructor(port = 8766, daemonMode = false) {
90
93
  this.httpServer = null;
91
94
  // 待处理的反馈请求
92
95
  this.pendingRequests = new Map();
@@ -95,6 +98,8 @@ class McpFeedbackServer {
95
98
  // 飞书桥接(可选;未配置时不加载 SDK、零开销)
96
99
  this.feishu = new feishu_js_1.FeishuBridge();
97
100
  this.feishuRoutingSetup = false;
101
+ // 飞书 /new 命令拉起的 headless CLI 会话(同一实例同时最多一个)
102
+ this.cliLauncher = new cli_launcher_js_1.CliLauncher();
98
103
  /**
99
104
  * 抢跑暂存:用户在「上一轮反馈刚结束、下一轮卡片还没注册」的空窗里直接发来的「无主」消息。
100
105
  * 暂存后等下一轮 pending 一注册立即兑现,避免回复石沉大海(修复用户反馈的竞态 bug)。
@@ -148,6 +153,8 @@ class McpFeedbackServer {
148
153
  // stop() 防重入:server.close() 会触发 transport.onclose → 回调里又调 stop(),
149
154
  // 无标志位会无限递归直至 "Maximum call stack size exceeded"(日志曾刷出数万条 Stopping server...)
150
155
  this.stopping = false;
156
+ // 防睡眠(守护模式启用;仅接电源时阻止系统睡眠)
157
+ this.keepAwake = new keep_awake_js_1.KeepAwake();
151
158
  /**
152
159
  * 已结束请求的结果缓存(wire id → 当时的结果):供「迟到的重复投递」精确重放。
153
160
  * 容量与时效都收紧(feedback 结果可能含 base64 图片,不能无限囤积)。
@@ -155,6 +162,7 @@ class McpFeedbackServer {
155
162
  this.settledByWire = new Map();
156
163
  this.port = port;
157
164
  this.basePort = port;
165
+ this.daemonMode = daemonMode;
158
166
  this.server = new index_js_1.Server({
159
167
  name: 'cursor-feedback-server',
160
168
  version: PKG_VERSION,
@@ -387,6 +395,9 @@ class McpFeedbackServer {
387
395
  // 文本、图片、文件任一非空都算有效反馈(此前只认文本,导致纯图片/文件被丢弃)
388
396
  if (!text && images.length === 0 && files.length === 0)
389
397
  return;
398
+ // 斜杠命令优先于反馈路由:用户显式输入命令时不应被当成对某张卡片的回复或排队消息。
399
+ if (/^\/(new|stop|model|cwd)\b/.test(text) && this.handleCliCommand(text, chatId))
400
+ return;
390
401
  if (parentId) {
391
402
  // 用户「回复」了某条卡片 → 用 parent_id 精确路由
392
403
  const reqId = this.feishu.resolveParent(parentId);
@@ -462,6 +473,132 @@ class McpFeedbackServer {
462
473
  }
463
474
  });
464
475
  }
476
+ /** 展开命令里的目录写法(支持 ~ 前缀和 Windows 盘符路径),非法/不存在返回 null */
477
+ expandDirToken(token) {
478
+ if (!token)
479
+ return null;
480
+ const looksLikePath = token.startsWith('/') || token.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(token);
481
+ if (!looksLikePath)
482
+ return null;
483
+ const expanded = token.startsWith('~')
484
+ ? path.join(os.homedir(), token.slice(1))
485
+ : token;
486
+ try {
487
+ if (fs.statSync(expanded).isDirectory())
488
+ return expanded;
489
+ }
490
+ catch {
491
+ // 不存在
492
+ }
493
+ return null;
494
+ }
495
+ /**
496
+ * 处理飞书斜杠命令。返回是否已消费该消息。
497
+ * - /new [工作目录] 任务描述:拉起一个 headless CLI 会话(非交互 + maxMode=false,扣 1 次请求)
498
+ * - /stop:终止运行中的 CLI 会话
499
+ * - /model [模型id]:查看 / 设置 CLI 会话模型(持久化;无论选什么模型都强制 maxMode=false)
500
+ * - /cwd [目录]:查看 / 设置默认工作目录(持久化;IDE 不开时 /new 用它)
501
+ */
502
+ handleCliCommand(text, chatId) {
503
+ if (/^\/stop\b/.test(text)) {
504
+ if (this.cliLauncher.stop()) {
505
+ this.feishu.replyText(chatId, '🛑 正在终止 CLI 会话…结束后我会再发一条收尾消息。');
506
+ }
507
+ else {
508
+ this.feishu.replyText(chatId, '当前没有运行中的 CLI 会话。');
509
+ }
510
+ return true;
511
+ }
512
+ const mModel = text.match(/^\/model(?:\s+(\S+))?\s*$/);
513
+ if (mModel) {
514
+ if (mModel[1]) {
515
+ this.cliLauncher.writeSettings({ model: mModel[1] });
516
+ this.feishu.replyText(chatId, `✅ CLI 模型已设为 ${mModel[1]}\n(无论什么模型,拉起前都会强制 maxMode=false,不会走 Max 计费)`);
517
+ }
518
+ else {
519
+ this.feishu.replyText(chatId, `当前 CLI 模型:${this.cliLauncher.model()}\n设置:/model 模型id(例如 /model claude-fable-5-thinking-max)`);
520
+ }
521
+ return true;
522
+ }
523
+ const mCwd = text.match(/^\/cwd(?:\s+(\S+))?\s*$/);
524
+ if (mCwd) {
525
+ if (mCwd[1]) {
526
+ const dir = this.expandDirToken(mCwd[1]);
527
+ if (!dir) {
528
+ this.feishu.replyText(chatId, `❌ 目录不存在或不是有效路径:${mCwd[1]}`);
529
+ }
530
+ else {
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
+ }
539
+ return true;
540
+ }
541
+ const m = text.match(/^\/new\s*([\s\S]*)$/);
542
+ if (!m)
543
+ return false;
544
+ let task = (m[1] || '').trim();
545
+ if (!task) {
546
+ this.feishu.replyText(chatId, '用法:/new 任务描述\n' +
547
+ '可选在任务前带一个已存在的目录作为工作目录:\n' +
548
+ '/new /Users/me/proj 帮我看下测试为什么挂了\n' +
549
+ '相关命令:/cwd 设默认目录、/model 设模型、/stop 终止会话。');
550
+ return true;
551
+ }
552
+ if (this.cliLauncher.isRunning()) {
553
+ this.feishu.replyText(chatId, '已有一个 CLI 会话在运行:' + this.cliLauncher.describe() + '\n发 /stop 可先终止它。');
554
+ return true;
555
+ }
556
+ // 工作目录优先级:命令里显式路径 > /cwd 持久化默认目录 > 本实例归属窗口 > 主目录。
557
+ // 注意 ownerWorkspace 是小写归一化路径,macOS 默认大小写不敏感文件系统下可直接使用。
558
+ let cwd = this.cliLauncher.defaultCwd() ||
559
+ (this.ownerWorkspace && fs.existsSync(this.ownerWorkspace)
560
+ ? this.ownerWorkspace
561
+ : os.homedir());
562
+ const explicitDir = this.expandDirToken(task.split(/\s+/)[0]);
563
+ if (explicitDir) {
564
+ cwd = explicitDir;
565
+ task = task.slice(task.split(/\s+/)[0].length).trim();
566
+ }
567
+ if (!task) {
568
+ this.feishu.replyText(chatId, '只给了目录没给任务。用法:/new [工作目录] 任务描述');
569
+ return true;
570
+ }
571
+ const err = this.cliLauncher.start(task, cwd, (result) => this.onCliSessionDone(result, chatId));
572
+ if (err) {
573
+ this.feishu.replyText(chatId, '❌ 拉起 CLI 会话失败:' + err);
574
+ }
575
+ else {
576
+ this.feishu.replyText(chatId, '🚀 CLI 会话已拉起(非交互模式,已强制 maxMode=false,按 1 次请求计费)\n' +
577
+ `模型:${this.cliLauncher.model()}\n` +
578
+ `工作目录:${cwd}\n` +
579
+ `任务:${task.length > 100 ? task.slice(0, 100) + '…' : task}\n\n` +
580
+ 'AI 需要和你沟通时会发反馈卡片,直接回复卡片即可;发 /stop 可随时终止。');
581
+ }
582
+ return true;
583
+ }
584
+ /** CLI 会话结束的收尾回执:把 agent 的最终输出(尾部)发回飞书 */
585
+ onCliSessionDone(result, chatId) {
586
+ const mins = Math.max(1, Math.round(result.elapsedMs / 60000));
587
+ let head;
588
+ if (result.stopped)
589
+ head = '🛑 CLI 会话已按你的要求终止';
590
+ else if (result.timedOut)
591
+ head = '⏱ CLI 会话超过时长上限,已被终止';
592
+ else if (result.code === 0)
593
+ head = '✅ CLI 会话已完成';
594
+ else
595
+ head = `⚠️ CLI 会话异常退出(code=${result.code})`;
596
+ let body = result.output || result.errorOutput || '(无输出)';
597
+ // 飞书单条消息别太长:保留尾部(结论一般在最后)
598
+ if (body.length > 1800)
599
+ body = '…' + body.slice(body.length - 1800);
600
+ this.feishu.replyText(chatId, `${head}(用时约 ${mins} 分钟)\n\n${body}`);
601
+ }
465
602
  /** 当前生效的超时续期开关(优先 UI 开关 autoRetryOverride,未设置时回退环境变量/默认开启) */
466
603
  effectiveAutoRetry() {
467
604
  return this.autoRetryOverride !== null
@@ -1622,6 +1759,30 @@ class McpFeedbackServer {
1622
1759
  res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
1623
1760
  return;
1624
1761
  }
1762
+ // 常驻守护:状态查询 / 安装 / 卸载(供插件面板「常驻服务」开关调用)
1763
+ if (req.method === 'GET' && req.url === '/api/daemon/status') {
1764
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1765
+ res.end(JSON.stringify({ ...(0, daemon_install_js_1.daemonStatus)(), currentVersion: PKG_VERSION }));
1766
+ return;
1767
+ }
1768
+ if (req.method === 'POST' && (req.url === '/api/daemon/install' || req.url === '/api/daemon/uninstall')) {
1769
+ const isInstall = req.url === '/api/daemon/install';
1770
+ try {
1771
+ if (isInstall && !(0, daemon_install_js_1.daemonSupported)()) {
1772
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1773
+ res.end(JSON.stringify({ error: `平台 ${process.platform} 暂不支持常驻守护` }));
1774
+ return;
1775
+ }
1776
+ const status = isInstall ? (0, daemon_install_js_1.installDaemon)() : (0, daemon_install_js_1.uninstallDaemon)();
1777
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1778
+ res.end(JSON.stringify({ success: true, ...status, currentVersion: PKG_VERSION }));
1779
+ }
1780
+ catch (e) {
1781
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1782
+ res.end(JSON.stringify({ error: String(e) }));
1783
+ }
1784
+ return;
1785
+ }
1625
1786
  // 跨实例转发的飞书回复(由其他窗口的 server 广播过来)
1626
1787
  if (req.method === 'POST' && req.url === '/api/feishu/inbound') {
1627
1788
  let body = '';
@@ -1855,6 +2016,16 @@ class McpFeedbackServer {
1855
2016
  if (settings && typeof settings.autoRetry === 'boolean') {
1856
2017
  this.autoRetryOverride = settings.autoRetry;
1857
2018
  }
2019
+ if (this.daemonMode) {
2020
+ // 守护模式:没有 stdio 客户端(launchd/计划任务拉起,stdin 是 /dev/null),
2021
+ // 不连 MCP 传输、不装 stdin 退出钩子、不开看门狗(父进程本来就是 launchd)。
2022
+ // 顺带启动防睡眠:锁屏后手机随时能唤起(仅接电源时生效,不偷耗电池)。
2023
+ if (process.env.CURSOR_FEEDBACK_KEEP_AWAKE !== 'false') {
2024
+ this.keepAwake.start();
2025
+ }
2026
+ debugLog(`Daemon mode started (v${PKG_VERSION}), Feishu bridge + /new launcher only`);
2027
+ return;
2028
+ }
1858
2029
  // 启动 MCP stdio 传输
1859
2030
  const transport = new stdio_js_1.StdioServerTransport();
1860
2031
  await this.server.connect(transport);
@@ -1940,6 +2111,8 @@ class McpFeedbackServer {
1940
2111
  }
1941
2112
  this.stopping = true;
1942
2113
  debugLog('Stopping server...');
2114
+ // 停止防睡眠(子进程退出,电源断言自动释放)
2115
+ this.keepAwake.stop();
1943
2116
  // 关闭看门狗定时器
1944
2117
  if (this.watchdogTimer) {
1945
2118
  clearInterval(this.watchdogTimer);
@@ -1989,8 +2162,26 @@ McpFeedbackServer.SETTLED_WIRE_TTL_MS = 10 * 60 * 1000;
1989
2162
  McpFeedbackServer.SETTLED_WIRE_MAX = 20;
1990
2163
  // 主函数
1991
2164
  async function main() {
2165
+ // 子命令:常驻守护的安装 / 卸载 / 状态查询(供 npx cursor-feedback install-daemon 等直接使用)
2166
+ const argv = process.argv.slice(2);
2167
+ const sub = argv.find((a) => !a.startsWith('-'));
2168
+ if (sub === 'install-daemon' || sub === 'uninstall-daemon' || sub === 'daemon-status') {
2169
+ try {
2170
+ const status = sub === 'install-daemon' ? (0, daemon_install_js_1.installDaemon)()
2171
+ : sub === 'uninstall-daemon' ? (0, daemon_install_js_1.uninstallDaemon)()
2172
+ : (0, daemon_install_js_1.daemonStatus)();
2173
+ // 子命令是给人看的,结果走 stdout(无 MCP 客户端,无 stdio 污染问题)
2174
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
2175
+ process.exit(0);
2176
+ }
2177
+ catch (e) {
2178
+ process.stderr.write(String(e) + '\n');
2179
+ process.exit(1);
2180
+ }
2181
+ }
2182
+ const daemonMode = argv.includes('--daemon');
1992
2183
  const port = 61927;
1993
- const server = new McpFeedbackServer(port);
2184
+ const server = new McpFeedbackServer(port, daemonMode);
1994
2185
  // 处理进程信号
1995
2186
  process.on('SIGINT', () => {
1996
2187
  debugLog('Received SIGINT');
@@ -2002,24 +2193,28 @@ async function main() {
2002
2193
  server.stop();
2003
2194
  process.exit(0);
2004
2195
  });
2005
- // 监听 stdin 关闭(Cursor 关闭时会触发)
2006
- process.stdin.on('close', () => {
2007
- debugLog('stdin closed, exiting...');
2008
- server.stop();
2009
- // 100ms 缓冲后强制退出
2010
- setTimeout(() => process.exit(0), 100);
2011
- });
2012
- process.stdin.on('end', () => {
2013
- debugLog('stdin ended, exiting...');
2014
- server.stop();
2015
- setTimeout(() => process.exit(0), 100);
2016
- });
2017
- // stdin 出错(父进程经 npx 中间层异常断开时可能触发)同样退出,避免残留
2018
- process.stdin.on('error', (error) => {
2019
- debugLog(`stdin error, exiting: ${error}`);
2020
- server.stop();
2021
- setTimeout(() => process.exit(0), 100);
2022
- });
2196
+ // stdin 退出钩子仅用于「被 MCP 客户端拉起」的场景。守护模式下 stdin /dev/null,
2197
+ // 启动瞬间就会触发 close/end,装上这些钩子进程会立刻自杀。
2198
+ if (!daemonMode) {
2199
+ // 监听 stdin 关闭(Cursor 关闭时会触发)
2200
+ process.stdin.on('close', () => {
2201
+ debugLog('stdin closed, exiting...');
2202
+ server.stop();
2203
+ // 100ms 缓冲后强制退出
2204
+ setTimeout(() => process.exit(0), 100);
2205
+ });
2206
+ process.stdin.on('end', () => {
2207
+ debugLog('stdin ended, exiting...');
2208
+ server.stop();
2209
+ setTimeout(() => process.exit(0), 100);
2210
+ });
2211
+ // stdin 出错(父进程经 npx 中间层异常断开时可能触发)同样退出,避免残留
2212
+ process.stdin.on('error', (error) => {
2213
+ debugLog(`stdin error, exiting: ${error}`);
2214
+ server.stop();
2215
+ setTimeout(() => process.exit(0), 100);
2216
+ });
2217
+ }
2023
2218
  // 捕获未处理的异常:记录日志后退出。
2024
2219
  // ⚠️ 绝不能“吞掉异常继续运行”——否则 stdin 在父进程断开后产生的反复错误会形成
2025
2220
  // busy-loop,导致 CPU 占满且进程永不退出(本次修复的核心症状之一)。
@@ -210,7 +210,18 @@
210
210
  <p class="feishu-modal__desc">{{i18n.queueWhenBusyDesc}}</p>
211
211
  </div>
212
212
 
213
- <!-- 区块③:飞书通知(推送开关 + 凭证 + 绑定状态) -->
213
+ <!-- 区块③:常驻服务(IDE 关闭时仍保持飞书链路 + /new 拉起 CLI;接电自动防睡眠) -->
214
+ <div class="feishu-section">
215
+ <label class="feishu-switch">
216
+ <span class="feishu-switch__label feishu-switch__label--strong">{{i18n.daemonLabel}}</span>
217
+ <input id="daemonToggle" type="checkbox" class="feishu-switch__input">
218
+ <span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
219
+ </label>
220
+ <p class="feishu-modal__desc">{{i18n.daemonDesc}}</p>
221
+ <p id="daemonStatusText" class="feishu-modal__desc feishu-modal__desc--sub"></p>
222
+ </div>
223
+
224
+ <!-- 区块④:飞书通知(推送开关 + 凭证 + 绑定状态) -->
214
225
  <div class="feishu-section">
215
226
  <label class="feishu-switch">
216
227
  <span class="feishu-switch__label feishu-switch__label--strong">{{i18n.feishuSettings}}</span>
@@ -206,6 +206,8 @@
206
206
  fillFeishuInputs();
207
207
  feishuModal.classList.remove('hidden');
208
208
  feishuModal.setAttribute('aria-hidden', 'false');
209
+ // 打开设置时向 server 查一次常驻服务状态(安装与否、版本)
210
+ requestDaemonStatus();
209
211
  setTimeout(() => feishuAppIdInput.focus(), 0);
210
212
  }
211
213
  function closeFeishuModal() {
@@ -228,6 +230,34 @@
228
230
  }
229
231
  });
230
232
 
233
+ // ---------- 常驻服务(IDE 关闭也可用)开关 ----------
234
+ const daemonToggle = document.getElementById('daemonToggle');
235
+ const daemonStatusText = document.getElementById('daemonStatusText');
236
+ function requestDaemonStatus() {
237
+ daemonToggle.disabled = true;
238
+ vscode.postMessage({ type: 'requestDaemonStatus' });
239
+ }
240
+ daemonToggle.addEventListener('change', () => {
241
+ // 安装/卸载是耗时操作(拷贝依赖 + 注册自启),期间锁住开关避免连点
242
+ daemonToggle.disabled = true;
243
+ daemonStatusText.textContent = i18n.daemonWorking || 'Working…';
244
+ vscode.postMessage({ type: 'toggleDaemon', payload: { enabled: daemonToggle.checked } });
245
+ });
246
+ function updateDaemonUI(s) {
247
+ if (!s) return;
248
+ daemonToggle.disabled = !s.supported;
249
+ daemonToggle.checked = !!s.installed;
250
+ let txt = '';
251
+ if (!s.supported) {
252
+ txt = i18n.daemonNotSupported || 'Not supported on this platform';
253
+ } else if (s.error) {
254
+ txt = (i18n.daemonFailed || 'Operation failed: {error}').replace('{error}', s.error);
255
+ } else if (s.installed) {
256
+ txt = (i18n.daemonInstalled || 'Installed v{version}').replace('{version}', s.installedVersion || '?');
257
+ }
258
+ daemonStatusText.textContent = txt;
259
+ }
260
+
231
261
  // ---------- 扫码一键创建飞书应用 ----------
232
262
  // 点「扫码快速创建」→ extension 向 server 发起 Device Grant 流程 → 面板展示二维码 →
233
263
  // 用户在飞书确认 → 凭证自动写入 server 磁盘并生效,这里刷新输入框回显
@@ -1613,6 +1643,10 @@
1613
1643
  updateFeishuUI(message.payload);
1614
1644
  break;
1615
1645
 
1646
+ case 'daemonState':
1647
+ updateDaemonUI(message.payload);
1648
+ break;
1649
+
1616
1650
  case 'feishuRegisterState':
1617
1651
  handleRegisterState(message.payload);
1618
1652
  break;
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.3.1",
5
+ "version": "2.4.1",
6
6
  "icon": "icon.png",
7
7
  "author": "jianger666",
8
8
  "license": "MIT",
@@ -147,7 +147,7 @@
147
147
  "watch": "tsc -watch -p ./",
148
148
  "lint": "eslint src --ext ts",
149
149
  "start:mcp": "node dist/mcp-server.js",
150
- "test:smoke": "npm run compile && node scripts/smoke-queue.js && node scripts/smoke-dup.js",
150
+ "test:smoke": "npm run compile && node scripts/smoke-queue.js && node scripts/smoke-dup.js && node scripts/smoke-cli-launcher.js && node scripts/smoke-daemon.js",
151
151
  "prepublishOnly": "npm run check-changelog && npm run compile",
152
152
  "check-changelog": "node scripts/check-changelog.js",
153
153
  "release": "standard-version",