cursor-feedback 2.3.0 → 2.4.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/CHANGELOG.md +14 -0
- package/README.md +31 -0
- package/README_CN.md +31 -0
- package/dist/cli-launcher.js +318 -0
- package/dist/daemon-install.js +292 -0
- package/dist/extension.js +60 -0
- package/dist/i18n/en.json +6 -0
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/zh-CN.json +6 -0
- package/dist/keep-awake.js +101 -0
- package/dist/mcp-server.js +285 -39
- package/dist/webview/index.html +12 -1
- package/dist/webview/script.js +34 -0
- package/package.json +2 -2
package/dist/mcp-server.js
CHANGED
|
@@ -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,8 +153,16 @@ 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();
|
|
158
|
+
/**
|
|
159
|
+
* 已结束请求的结果缓存(wire id → 当时的结果):供「迟到的重复投递」精确重放。
|
|
160
|
+
* 容量与时效都收紧(feedback 结果可能含 base64 图片,不能无限囤积)。
|
|
161
|
+
*/
|
|
162
|
+
this.settledByWire = new Map();
|
|
151
163
|
this.port = port;
|
|
152
164
|
this.basePort = port;
|
|
165
|
+
this.daemonMode = daemonMode;
|
|
153
166
|
this.server = new index_js_1.Server({
|
|
154
167
|
name: 'cursor-feedback-server',
|
|
155
168
|
version: PKG_VERSION,
|
|
@@ -224,14 +237,17 @@ class McpFeedbackServer {
|
|
|
224
237
|
};
|
|
225
238
|
});
|
|
226
239
|
// 处理工具调用
|
|
227
|
-
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
240
|
+
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
|
|
228
241
|
// AI 发起调用,刷新活动时间,避免被 watchdog 误判为闲置
|
|
229
242
|
this.lastActivityTime = Date.now();
|
|
230
243
|
const { name, arguments: args } = request.params;
|
|
244
|
+
// JSON-RPC 请求 id(同一连接内唯一):客户端把同一次 tool call 重复投递时若复用
|
|
245
|
+
// 同一 id,可据此做精确去重 / 结果重放(比 summary 内容启发式可靠)。
|
|
246
|
+
const wireKey = extra?.requestId !== undefined ? String(extra.requestId) : undefined;
|
|
231
247
|
try {
|
|
232
248
|
switch (name) {
|
|
233
249
|
case 'interactive_feedback':
|
|
234
|
-
return await this.handleInteractiveFeedback(args);
|
|
250
|
+
return await this.handleInteractiveFeedback(args, wireKey);
|
|
235
251
|
case 'get_system_info':
|
|
236
252
|
return this.handleGetSystemInfo();
|
|
237
253
|
default:
|
|
@@ -252,8 +268,9 @@ class McpFeedbackServer {
|
|
|
252
268
|
}
|
|
253
269
|
/**
|
|
254
270
|
* 处理交互式反馈请求
|
|
271
|
+
* @param wireKey 本次调用的 JSON-RPC 请求 id(用于重复投递的精确识别与结果重放)
|
|
255
272
|
*/
|
|
256
|
-
async handleInteractiveFeedback(args) {
|
|
273
|
+
async handleInteractiveFeedback(args, wireKey) {
|
|
257
274
|
// 参数校验:project_directory 是必填项
|
|
258
275
|
if (!args?.project_directory) {
|
|
259
276
|
const receivedParams = JSON.stringify(args || {});
|
|
@@ -276,15 +293,29 @@ class McpFeedbackServer {
|
|
|
276
293
|
const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
|
|
277
294
|
const timeout = envTimeout || args?.timeout || 300;
|
|
278
295
|
const requestId = this.generateRequestId();
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
296
|
+
// 重复投递识别:客户端/传输层会把同一次 tool call 原样重复投递(实测某一时刻把进程内
|
|
297
|
+
// 所有 in-flight 调用各重投一遍,距原投递可晚至 3~8 分钟,无固定上限)。重复投递必须
|
|
298
|
+
// join 原等待共享结果,绝不能当新一轮:不发新卡、不顶旧请求——顶了会让原调用收到
|
|
299
|
+
// SUPERSEDED(AI 误以为被新会话取代而提前收尾),且重投开出的幽灵卡片没人认领,
|
|
300
|
+
// 用户回复它只会石沉大海。
|
|
301
|
+
const dup = this.findDuplicatePending(projectDir, summary, timeout, wireKey);
|
|
283
302
|
if (dup) {
|
|
284
|
-
debugLog(`Duplicate delivery detected for request ${dup.id}; joining its wait instead of superseding`);
|
|
303
|
+
debugLog(`Duplicate delivery detected (matched by ${dup.via}) for request ${dup.id}; joining its wait instead of superseding`);
|
|
285
304
|
const outcome = await new Promise((res) => dup.waiters.push(res));
|
|
305
|
+
this.rememberSettledWire(wireKey, outcome, dup.id);
|
|
286
306
|
return this.outcomeToResult(outcome, dup.id);
|
|
287
307
|
}
|
|
308
|
+
// 迟到的重复投递(原请求已结束):同 wire id 精确命中已结束请求 → 原样重放当时的结果。
|
|
309
|
+
// 绝不能开新一轮:原调用早已返回、没人会认领新卡,用户回复只会丢失。
|
|
310
|
+
// 只按 wire id 匹配、绝不按 summary——AI 超时续期的新一轮常复用同一句 summary,
|
|
311
|
+
// 按内容重放旧超时会造成「新调用秒收超时 → 立即重调 → 又秒收」的快速空转。
|
|
312
|
+
if (wireKey) {
|
|
313
|
+
const settled = this.settledByWire.get(wireKey);
|
|
314
|
+
if (settled) {
|
|
315
|
+
debugLog(`Late duplicate delivery (wire id ${wireKey}) for settled request ${settled.requestId}; replaying its outcome`);
|
|
316
|
+
return this.outcomeToResult(settled.outcome, settled.requestId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
288
319
|
// 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
|
|
289
320
|
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
290
321
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
@@ -324,7 +355,8 @@ class McpFeedbackServer {
|
|
|
324
355
|
}
|
|
325
356
|
try {
|
|
326
357
|
// 等待用户反馈
|
|
327
|
-
const outcome = await this.waitForFeedback(request, timeout * 1000);
|
|
358
|
+
const outcome = await this.waitForFeedback(request, timeout * 1000, wireKey);
|
|
359
|
+
this.rememberSettledWire(wireKey, outcome, requestId);
|
|
328
360
|
return this.outcomeToResult(outcome, requestId);
|
|
329
361
|
}
|
|
330
362
|
catch (error) {
|
|
@@ -363,6 +395,9 @@ class McpFeedbackServer {
|
|
|
363
395
|
// 文本、图片、文件任一非空都算有效反馈(此前只认文本,导致纯图片/文件被丢弃)
|
|
364
396
|
if (!text && images.length === 0 && files.length === 0)
|
|
365
397
|
return;
|
|
398
|
+
// 斜杠命令优先于反馈路由:用户显式输入命令时不应被当成对某张卡片的回复或排队消息。
|
|
399
|
+
if (/^\/(new|stop|model|cwd)\b/.test(text) && this.handleCliCommand(text, chatId))
|
|
400
|
+
return;
|
|
366
401
|
if (parentId) {
|
|
367
402
|
// 用户「回复」了某条卡片 → 用 parent_id 精确路由
|
|
368
403
|
const reqId = this.feishu.resolveParent(parentId);
|
|
@@ -438,6 +473,132 @@ class McpFeedbackServer {
|
|
|
438
473
|
}
|
|
439
474
|
});
|
|
440
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
|
+
}
|
|
441
602
|
/** 当前生效的超时续期开关(优先 UI 开关 autoRetryOverride,未设置时回退环境变量/默认开启) */
|
|
442
603
|
effectiveAutoRetry() {
|
|
443
604
|
return this.autoRetryOverride !== null
|
|
@@ -606,19 +767,49 @@ class McpFeedbackServer {
|
|
|
606
767
|
this.pendingRequests.delete(reqId);
|
|
607
768
|
}
|
|
608
769
|
}
|
|
609
|
-
|
|
770
|
+
/**
|
|
771
|
+
* 查找可 join 的「重复投递」等待,两级匹配:
|
|
772
|
+
* 1) wire id 精确匹配:同一条 JSON-RPC 请求(同 id)还在等待 → 必是重复投递;
|
|
773
|
+
* 2) 内容启发式:同窗口 + 同 summary + 同 timeout,且原请求仍在 pending(覆盖客户端
|
|
774
|
+
* 重投时换了新 wire id 的情况)。
|
|
775
|
+
* 内容启发式不设时间窗——旧实现的 90s 窗口被实测击穿(重投可晚至 3~8 分钟,无固定上限)。
|
|
776
|
+
* 不设窗口是安全的:pending 仍存活 = 原调用尚未返回 = 同一会话不可能已合法开启新一轮;
|
|
777
|
+
* 不同会话在同一窗口撞出一字不差的 summary + timeout 概率可忽略,即便撞上,
|
|
778
|
+
* join(双方共享同一份回复)也远比 supersede(悄悄掐掉别人的等待)安全。
|
|
779
|
+
*/
|
|
780
|
+
findDuplicatePending(projectDir, summary, timeout, wireKey) {
|
|
610
781
|
const owner = this.normalizePath(projectDir);
|
|
611
782
|
for (const [id, pending] of this.pendingRequests) {
|
|
783
|
+
if (wireKey !== undefined && pending.wireId === wireKey) {
|
|
784
|
+
return { id, via: 'wire id', waiters: pending.waiters };
|
|
785
|
+
}
|
|
612
786
|
if (this.normalizePath(pending.projectDir) !== owner)
|
|
613
787
|
continue;
|
|
614
788
|
if (pending.request.summary !== summary)
|
|
615
789
|
continue;
|
|
616
|
-
if (
|
|
790
|
+
if (pending.request.timeout !== timeout)
|
|
617
791
|
continue;
|
|
618
|
-
return { id, waiters: pending.waiters };
|
|
792
|
+
return { id, via: 'content', waiters: pending.waiters };
|
|
619
793
|
}
|
|
620
794
|
return null;
|
|
621
795
|
}
|
|
796
|
+
rememberSettledWire(wireKey, outcome, requestId) {
|
|
797
|
+
if (!wireKey)
|
|
798
|
+
return;
|
|
799
|
+
const now = Date.now();
|
|
800
|
+
this.settledByWire.set(wireKey, { outcome, requestId, at: now });
|
|
801
|
+
for (const [k, v] of this.settledByWire) {
|
|
802
|
+
if (now - v.at > McpFeedbackServer.SETTLED_WIRE_TTL_MS)
|
|
803
|
+
this.settledByWire.delete(k);
|
|
804
|
+
}
|
|
805
|
+
// Map 按插入序迭代,超限时先淘汰最早的
|
|
806
|
+
while (this.settledByWire.size > McpFeedbackServer.SETTLED_WIRE_MAX) {
|
|
807
|
+
const oldest = this.settledByWire.keys().next().value;
|
|
808
|
+
if (oldest === undefined)
|
|
809
|
+
break;
|
|
810
|
+
this.settledByWire.delete(oldest);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
622
813
|
/**
|
|
623
814
|
* 把等待结果翻译成 MCP 工具响应(主等待与重复投递 join 的等待共用同一段收尾语义)。
|
|
624
815
|
*/
|
|
@@ -700,7 +891,7 @@ class McpFeedbackServer {
|
|
|
700
891
|
* 等待用户反馈:注册 pending 并挂起,结果通过 waiters 广播——
|
|
701
892
|
* 首个调用与后续 join 进来的重复投递调用都会收到同一份结果。
|
|
702
893
|
*/
|
|
703
|
-
waitForFeedback(request, timeoutMs) {
|
|
894
|
+
waitForFeedback(request, timeoutMs, wireKey) {
|
|
704
895
|
return new Promise((resolve) => {
|
|
705
896
|
const requestId = request.id;
|
|
706
897
|
const projectDir = request.projectDir;
|
|
@@ -738,6 +929,7 @@ class McpFeedbackServer {
|
|
|
738
929
|
remainingMs: timeoutMs,
|
|
739
930
|
request,
|
|
740
931
|
waiters,
|
|
932
|
+
wireId: wireKey,
|
|
741
933
|
});
|
|
742
934
|
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
743
935
|
// 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
|
|
@@ -1567,6 +1759,30 @@ class McpFeedbackServer {
|
|
|
1567
1759
|
res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
|
|
1568
1760
|
return;
|
|
1569
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
|
+
}
|
|
1570
1786
|
// 跨实例转发的飞书回复(由其他窗口的 server 广播过来)
|
|
1571
1787
|
if (req.method === 'POST' && req.url === '/api/feishu/inbound') {
|
|
1572
1788
|
let body = '';
|
|
@@ -1800,6 +2016,16 @@ class McpFeedbackServer {
|
|
|
1800
2016
|
if (settings && typeof settings.autoRetry === 'boolean') {
|
|
1801
2017
|
this.autoRetryOverride = settings.autoRetry;
|
|
1802
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
|
+
}
|
|
1803
2029
|
// 启动 MCP stdio 传输
|
|
1804
2030
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
1805
2031
|
await this.server.connect(transport);
|
|
@@ -1885,6 +2111,8 @@ class McpFeedbackServer {
|
|
|
1885
2111
|
}
|
|
1886
2112
|
this.stopping = true;
|
|
1887
2113
|
debugLog('Stopping server...');
|
|
2114
|
+
// 停止防睡眠(子进程退出,电源断言自动释放)
|
|
2115
|
+
this.keepAwake.stop();
|
|
1888
2116
|
// 关闭看门狗定时器
|
|
1889
2117
|
if (this.watchdogTimer) {
|
|
1890
2118
|
clearInterval(this.watchdogTimer);
|
|
@@ -1930,16 +2158,30 @@ McpFeedbackServer.QUEUE_MAX = 100;
|
|
|
1930
2158
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
|
1931
2159
|
*/
|
|
1932
2160
|
McpFeedbackServer.OWNER_IDLE_MS = 12000;
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
* AI 每一轮的 summary 几乎不可能与上一轮一字不差,短窗口内完全相同基本可断定为
|
|
1936
|
-
* 客户端/传输层对同一次 tool call 的重复投递。
|
|
1937
|
-
*/
|
|
1938
|
-
McpFeedbackServer.DUP_JOIN_MS = 90000;
|
|
2161
|
+
McpFeedbackServer.SETTLED_WIRE_TTL_MS = 10 * 60 * 1000;
|
|
2162
|
+
McpFeedbackServer.SETTLED_WIRE_MAX = 20;
|
|
1939
2163
|
// 主函数
|
|
1940
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');
|
|
1941
2183
|
const port = 61927;
|
|
1942
|
-
const server = new McpFeedbackServer(port);
|
|
2184
|
+
const server = new McpFeedbackServer(port, daemonMode);
|
|
1943
2185
|
// 处理进程信号
|
|
1944
2186
|
process.on('SIGINT', () => {
|
|
1945
2187
|
debugLog('Received SIGINT');
|
|
@@ -1951,24 +2193,28 @@ async function main() {
|
|
|
1951
2193
|
server.stop();
|
|
1952
2194
|
process.exit(0);
|
|
1953
2195
|
});
|
|
1954
|
-
//
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
+
}
|
|
1972
2218
|
// 捕获未处理的异常:记录日志后退出。
|
|
1973
2219
|
// ⚠️ 绝不能“吞掉异常继续运行”——否则 stdin 在父进程断开后产生的反复错误会形成
|
|
1974
2220
|
// busy-loop,导致 CPU 占满且进程永不退出(本次修复的核心症状之一)。
|
package/dist/webview/index.html
CHANGED
|
@@ -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>
|
package/dist/webview/script.js
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "2.4.0",
|
|
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",
|
|
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",
|