cursor-feedback 1.1.1 → 2.0.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 +48 -4
- package/README_CN.md +48 -4
- package/dist/extension.js +546 -53
- package/dist/feishu.js +667 -0
- package/dist/i18n/en.json +49 -7
- package/dist/i18n/index.js +46 -4
- package/dist/i18n/zh-CN.json +49 -7
- package/dist/mcp-server.js +514 -20
- package/dist/webview/index.html +193 -43
- package/dist/webview/script.js +795 -145
- package/dist/webview/styles.css +921 -298
- package/package.json +38 -1
- package/.github/workflows/release-please.yml +0 -19
- package/.husky/pre-commit +0 -2
- package/.husky/pre-push +0 -1
- package/.versionrc.json +0 -15
- package/.vscode/launch.json +0 -28
- package/.vscode/tasks.json +0 -22
- package/.vscodeignore +0 -11
- package/demo.gif +0 -0
- package/dist/extension.js.map +0 -1
- package/dist/i18n/index.js.map +0 -1
- package/dist/mcp-server.js.map +0 -1
- package/icon.png +0 -0
- package/mcp-install-dark.png +0 -0
- package/resources/icon.svg +0 -4
- package/resources/vendor/marked.min.js +0 -69
- package/scripts/check-changelog.js +0 -42
- package/src/extension.ts +0 -692
- package/src/i18n/en.json +0 -34
- package/src/i18n/index.ts +0 -131
- package/src/i18n/zh-CN.json +0 -34
- package/src/mcp-server.ts +0 -768
- package/src/webview/index.html +0 -69
- package/src/webview/script.js +0 -318
- package/src/webview/styles.css +0 -422
- package/tsconfig.json +0 -17
package/dist/mcp-server.js
CHANGED
|
@@ -56,6 +56,17 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
56
56
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
57
57
|
const http = __importStar(require("http"));
|
|
58
58
|
const os = __importStar(require("os"));
|
|
59
|
+
const fs = __importStar(require("fs"));
|
|
60
|
+
const path = __importStar(require("path"));
|
|
61
|
+
const feishu_js_1 = require("./feishu.js");
|
|
62
|
+
// ⚠️ MCP stdio 协议要求 stdout 只承载 JSON-RPC 消息。第三方库(尤其飞书 SDK 的内置 logger,
|
|
63
|
+
// 输出形如 "[info]: [...]")会用 console.log/info/debug 往 stdout 打日志,一旦混入就会让
|
|
64
|
+
// Cursor 端 JSON 解析失败、连接进入 failed(表现为 "Not connected")。这里在进程最早期把这三个
|
|
65
|
+
// 统一重定向到 stderr,保证 stdout 纯净。
|
|
66
|
+
// 注:warn/error 在 Node 中本就走 stderr;MCP SDK 用 process.stdout.write 直接发消息,不经 console,故不受影响。
|
|
67
|
+
console.log = (...args) => console.error(...args);
|
|
68
|
+
console.info = (...args) => console.error(...args);
|
|
69
|
+
console.debug = (...args) => console.error(...args);
|
|
59
70
|
// 调试日志输出到 stderr(不影响 stdio 通信)
|
|
60
71
|
function debugLog(message) {
|
|
61
72
|
const timestamp = new Date().toISOString();
|
|
@@ -71,17 +82,46 @@ class McpFeedbackServer {
|
|
|
71
82
|
this.pendingRequests = new Map();
|
|
72
83
|
// 当前反馈请求
|
|
73
84
|
this.currentRequest = null;
|
|
85
|
+
// 飞书桥接(可选;未配置时不加载 SDK、零开销)
|
|
86
|
+
this.feishu = new feishu_js_1.FeishuBridge();
|
|
87
|
+
this.feishuRoutingSetup = false;
|
|
88
|
+
/**
|
|
89
|
+
* 抢跑暂存:用户在「上一轮反馈刚结束、下一轮卡片还没注册」的空窗里直接发来的「无主」消息。
|
|
90
|
+
* 暂存后等下一轮 pending 一注册立即兑现,避免回复石沉大海(修复用户反馈的竞态 bug)。
|
|
91
|
+
*/
|
|
92
|
+
this.stashedInbound = null;
|
|
93
|
+
// 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
94
|
+
// 避免超时续期(request 也会短暂为 null)被误判成飞书回复而错误重置面板。
|
|
95
|
+
this.lastFeishuResolved = null;
|
|
96
|
+
// 超时续期开关真相源:磁盘(UI 改过、跨窗口共享、重启保留)> env MCP_AUTO_RETRY > 默认开。
|
|
97
|
+
// 由 POST /api/settings/autoRetry 广播写入,不再从轮询 query 同步(曾致多窗口互相覆盖抖动)。
|
|
98
|
+
// null = 没被磁盘/UI 覆盖,poll 时回退环境变量 / 默认值。
|
|
99
|
+
this.autoRetryOverride = null;
|
|
100
|
+
// server 级设置的磁盘持久化路径(与飞书凭证同目录,跨进程/重启共享真相源)
|
|
101
|
+
this.settingsStorePath = path.join(os.homedir(), '.cursor-feedback', 'settings.json');
|
|
74
102
|
// 所属工作区(只在 AI 调用 feedback 时设置)
|
|
75
103
|
// 只有来自同一工作区的轮询才会更新活动时间
|
|
76
104
|
this.ownerWorkspace = null;
|
|
105
|
+
// Cursor 在 spawn 本 MCP server 时注入的「真实所属工作区」,精确指向发起对话的那个窗口。
|
|
106
|
+
// 这是可靠的归属信号:AI 传的 project_directory 可能被填成对话里聊到的另一个项目,
|
|
107
|
+
// 用它路由会把反馈发到错误窗口、当前窗口收不到(用户反馈的 bug)。拿不到时回退到 AI 传参。
|
|
108
|
+
this.realWorkspace = (process.env.WORKSPACE_FOLDER_PATHS || '').split(',')[0].trim() || null;
|
|
77
109
|
// Server 启动时间
|
|
78
110
|
this.startTime = Date.now();
|
|
79
111
|
// 最近一次活动时间(任意 HTTP 轮询 / MCP 调用都会刷新)
|
|
80
112
|
// 用于 watchdog 判定是否所有 Cursor 窗口都已关闭
|
|
81
113
|
this.lastActivityTime = Date.now();
|
|
114
|
+
// 「我的窗口」最近一次轮询时间:仅被「workspace 匹配本实例归属」的插件轮询刷新。
|
|
115
|
+
// 关键:lastActivityTime 会被任意 HTTP(含别的活跃窗口对全端口的扫描)刷新,无法识别僵尸——
|
|
116
|
+
// 别的窗口的端口扫描会给已关窗口的残留 server 续命。改用本字段判定「我的窗口是否还开着」。
|
|
117
|
+
this.lastOwnerPollTime = Date.now();
|
|
118
|
+
// 是否曾收到过「我的窗口」的插件轮询:区分「Cursor 插件环境」与「无插件 host(如 Claude Desktop)」。
|
|
119
|
+
// 只有曾被自己窗口插件轮询过的实例,才用 lastOwnerPollTime 判定僵尸 / 退出,避免误杀无插件 host。
|
|
120
|
+
this.everOwnerPolled = false;
|
|
82
121
|
// 看门狗定时器(兜底退出,防止进程残留 / CPU 占满)
|
|
83
122
|
this.watchdogTimer = null;
|
|
84
123
|
this.port = port;
|
|
124
|
+
this.basePort = port;
|
|
85
125
|
this.server = new index_js_1.Server({
|
|
86
126
|
name: 'cursor-feedback-server',
|
|
87
127
|
version: '0.0.1',
|
|
@@ -91,6 +131,7 @@ class McpFeedbackServer {
|
|
|
91
131
|
},
|
|
92
132
|
});
|
|
93
133
|
this.setupHandlers();
|
|
134
|
+
this.setupFeishuRouting();
|
|
94
135
|
}
|
|
95
136
|
setupHandlers() {
|
|
96
137
|
// 列出可用工具
|
|
@@ -114,18 +155,20 @@ class McpFeedbackServer {
|
|
|
114
155
|
用户消息 → 你工作 → 调用此工具(附带摘要)→ 用户反馈 → 你工作 → 再次调用此工具 → ...
|
|
115
156
|
|
|
116
157
|
参数:
|
|
117
|
-
project_directory:
|
|
158
|
+
project_directory: 【必填】当前所在项目空间的根目录绝对路径(指你打开的工作区,不是对话里聊到的项目)
|
|
118
159
|
summary: AI 工作摘要,供用户查看(支持 Markdown)
|
|
119
160
|
timeout: 等待用户反馈的超时时间(秒),默认 300 秒(5 分钟)
|
|
120
161
|
|
|
121
162
|
返回:
|
|
122
|
-
|
|
163
|
+
- 正常:用户反馈内容(文字/图片/文件路径)
|
|
164
|
+
- 超时续期信号:返回内容以 [TIMEOUT_KEEP_WAITING] 开头时,表示用户暂未回复(注意:这是超时,不是取消),你必须立即用相同 project_directory 再次调用本工具继续等待,不要结束对话
|
|
165
|
+
- 超时结束:以 [TIMEOUT_END] 开头时(用户已关闭续期),可以结束本轮`,
|
|
123
166
|
inputSchema: {
|
|
124
167
|
type: 'object',
|
|
125
168
|
properties: {
|
|
126
169
|
project_directory: {
|
|
127
170
|
type: 'string',
|
|
128
|
-
description: '
|
|
171
|
+
description: 'Absolute path of the root of the project workspace you are currently in (REQUIRED) — the workspace that is open, not a project merely discussed in the conversation. 【当前所在项目空间的根目录,指你打开的工作区】',
|
|
129
172
|
},
|
|
130
173
|
summary: {
|
|
131
174
|
type: 'string',
|
|
@@ -194,7 +237,9 @@ class McpFeedbackServer {
|
|
|
194
237
|
isError: true,
|
|
195
238
|
};
|
|
196
239
|
}
|
|
197
|
-
|
|
240
|
+
// 归属以 Cursor 注入的真实工作区为准(修复:AI 把 project_directory 传成对话里聊到的
|
|
241
|
+
// 另一个项目时,反馈会发到错误窗口、当前窗口收不到)。拿不到 env 时才回退到 AI 传参。
|
|
242
|
+
const projectDir = this.realWorkspace || args.project_directory;
|
|
198
243
|
// summary 支持别名 message
|
|
199
244
|
const summary = args?.summary || args?.message || '我已完成您的请求。';
|
|
200
245
|
// 超时时间优先级:环境变量 > 工具参数 > 默认值(300秒)
|
|
@@ -202,9 +247,13 @@ class McpFeedbackServer {
|
|
|
202
247
|
const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
|
|
203
248
|
const timeout = envTimeout || args?.timeout || 300;
|
|
204
249
|
const requestId = this.generateRequestId();
|
|
250
|
+
// 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
|
|
251
|
+
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
252
|
+
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
253
|
+
//(即你看到的两个一模一样的 cursor-feedback-extension)。
|
|
254
|
+
this.cancelStalePending();
|
|
205
255
|
// AI 调用 feedback 时设置 ownerWorkspace(这是唯一正确的时机)
|
|
206
|
-
|
|
207
|
-
this.ownerWorkspace = normalize(projectDir);
|
|
256
|
+
this.ownerWorkspace = this.normalizePath(projectDir);
|
|
208
257
|
debugLog(`Owner workspace set to: ${this.ownerWorkspace}`);
|
|
209
258
|
// 创建反馈请求
|
|
210
259
|
this.currentRequest = {
|
|
@@ -219,16 +268,25 @@ class McpFeedbackServer {
|
|
|
219
268
|
debugLog(`Project: ${projectDir}`);
|
|
220
269
|
debugLog(`Timeout: ${timeout}s`);
|
|
221
270
|
debugLog(`Waiting for VS Code extension to collect feedback...`);
|
|
271
|
+
// 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)
|
|
272
|
+
if (this.feishu.isConfigured()) {
|
|
273
|
+
this.feishu.sendFeedbackCard(requestId, summary, projectDir).catch(() => { });
|
|
274
|
+
}
|
|
222
275
|
try {
|
|
223
276
|
// 等待用户反馈
|
|
224
277
|
const result = await this.waitForFeedback(requestId, timeout * 1000);
|
|
225
278
|
if (!result) {
|
|
226
|
-
debugLog('Feedback
|
|
227
|
-
// MCP_AUTO_RETRY
|
|
228
|
-
|
|
279
|
+
debugLog('Feedback wait window elapsed without user input');
|
|
280
|
+
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
281
|
+
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
282
|
+
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
283
|
+
// 优先用 UI 开关(autoRetryOverride),未设置时回退到环境变量 / 默认开启
|
|
284
|
+
const autoRetry = this.autoRetryOverride !== null
|
|
285
|
+
? this.autoRetryOverride
|
|
286
|
+
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
229
287
|
const timeoutMessage = autoRetry
|
|
230
|
-
? '
|
|
231
|
-
: '
|
|
288
|
+
? '[TIMEOUT_KEEP_WAITING] No feedback was submitted within the wait window. This is a TIMEOUT, NOT a cancellation — the user is most likely still away from the keyboard. You MUST immediately call the interactive_feedback tool again with the same project_directory to keep waiting. Do NOT end the conversation, do NOT summarize the task as finished. Only stop calling it when the user EXPLICITLY tells you to stop. 【这是超时续期,并非用户取消:请立即用相同 project_directory 再次调用 interactive_feedback 继续等待,不要结束对话、不要当作任务完成。】'
|
|
289
|
+
: '[TIMEOUT_END] No feedback was submitted within the wait window and timeout auto-continue is disabled (MCP_AUTO_RETRY=false). You may end this turn now. 【超时未收到反馈,且已关闭超时续期(MCP_AUTO_RETRY=false),可以结束本轮。】';
|
|
232
290
|
return {
|
|
233
291
|
content: [
|
|
234
292
|
{
|
|
@@ -292,8 +350,200 @@ class McpFeedbackServer {
|
|
|
292
350
|
};
|
|
293
351
|
}
|
|
294
352
|
finally {
|
|
295
|
-
|
|
353
|
+
// 只清自己的 currentRequest:被 cancelStalePending 提前作废的旧(僵尸)请求,其 finally 触发时
|
|
354
|
+
// currentRequest 可能已是新一轮请求,绝不能误清空(否则刚发起的请求会被旧请求的收尾抹掉)。
|
|
355
|
+
if (this.currentRequest?.id === requestId)
|
|
356
|
+
this.currentRequest = null;
|
|
357
|
+
// 仅清「待回复」计数,保留卡片 message_id 映射:用户回复一张已结束(超时/已提交/已回复)的
|
|
358
|
+
// 旧卡片时仍能被 resolveParent 命中、回执「已结束」,不会因映射丢失被当成陌生卡片广播、石沉大海。
|
|
359
|
+
this.feishu.clearPending(requestId);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 配置飞书消息路由:用户在飞书回复 → 匹配 requestId → resolve 本地 pending 或跨实例转发。
|
|
364
|
+
*/
|
|
365
|
+
setupFeishuRouting() {
|
|
366
|
+
if (this.feishuRoutingSetup)
|
|
367
|
+
return;
|
|
368
|
+
this.feishuRoutingSetup = true;
|
|
369
|
+
this.feishu.setOnReply(async (reply) => {
|
|
370
|
+
const { parentId, text, chatId } = reply;
|
|
371
|
+
const messageId = reply.messageId || '';
|
|
372
|
+
const images = reply.images || [];
|
|
373
|
+
const files = reply.files || [];
|
|
374
|
+
// 文本、图片、文件任一非空都算有效反馈(此前只认文本,导致纯图片/文件被丢弃)
|
|
375
|
+
if (!text && images.length === 0 && files.length === 0)
|
|
376
|
+
return;
|
|
377
|
+
if (parentId) {
|
|
378
|
+
// 用户「回复」了某条卡片 → 用 parent_id 精确路由
|
|
379
|
+
const reqId = this.feishu.resolveParent(parentId);
|
|
380
|
+
if (reqId) {
|
|
381
|
+
// 这张卡片是本实例发出的
|
|
382
|
+
if (this.pendingRequests.has(reqId)) {
|
|
383
|
+
this.submitFromFeishu(reqId, text, chatId, images, files, messageId);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
// 卡片是本实例发的,但请求已结束(超时 / 已回复)→ 明确告知,不必广播
|
|
387
|
+
this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// 不是本实例发出的卡片 → 广播给其他窗口的 server
|
|
392
|
+
this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// 无 parent_id(用户直接发消息,未指明卡片)→ 需要「全局视角」:
|
|
397
|
+
// 飞书消息只会推给某一个窗口的 server,而各窗口各自维护 pending;只看本实例会误判
|
|
398
|
+
// (每个窗口都以为自己唯一),导致多窗口时被随机一个窗口抢走。故先跨实例汇总全局再决策。
|
|
399
|
+
const localPending = this.feishu.listPending();
|
|
400
|
+
const remote = await this.queryRemotePending();
|
|
401
|
+
const remoteCount = remote.reduce((n, r) => n + r.list.length, 0);
|
|
402
|
+
const globalCount = localPending.length + remoteCount;
|
|
403
|
+
if (globalCount === 0) {
|
|
404
|
+
// 抢跑兜底:此刻全局无人等待,但很可能 AI 正要发起下一轮(卡片还没注册)。
|
|
405
|
+
// 暂存到收到消息的本实例,等下一轮 pending 注册时立即兑现。
|
|
406
|
+
this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId };
|
|
407
|
+
// 轻回执:先给消息加 ✅ 表情(无未读);兑现到具体任务时不再重复回执
|
|
408
|
+
this.feishu.reactDone(messageId || undefined, chatId);
|
|
409
|
+
}
|
|
410
|
+
else if (globalCount === 1) {
|
|
411
|
+
if (localPending.length === 1) {
|
|
412
|
+
// 唯一的等待就在本窗口 → 直接给
|
|
413
|
+
this.submitFromFeishu(localPending[0].requestId, text, chatId, images, files, messageId);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
// 唯一的等待在另一个窗口 → 转发让它认领
|
|
417
|
+
const target = remote.find((r) => r.list.length === 1);
|
|
418
|
+
if (target)
|
|
419
|
+
this.forwardOrphan(target.port, text, chatId, images, files, messageId);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// 多个窗口在等 → 不猜。项目名可能重复(同一项目开多窗口),逐项列出也无法区分,
|
|
424
|
+
// 故不逐项列,直接引导用户去点想回复的那张卡片——回复哪张就精确回到哪个窗口。
|
|
425
|
+
this.feishu.replyText(chatId, `当前有 ${globalCount} 个窗口在等反馈,没法自动判断你要回复哪个。\n请直接在你想回复的那张卡片上点「回复」再发,回复哪张就回到哪个窗口。`);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
projectName(dir) {
|
|
430
|
+
return dir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || dir;
|
|
431
|
+
}
|
|
432
|
+
/** 归一化路径用于跨进程/跨平台比对(统一斜杠、去尾斜杠、小写) */
|
|
433
|
+
normalizePath(p) {
|
|
434
|
+
return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
435
|
+
}
|
|
436
|
+
/** 飞书回复命中某 requestId → resolve 该 pending,并回执用户 */
|
|
437
|
+
submitFromFeishu(requestId, text, chatId, images = [], attachedFiles = [], ackMessageId) {
|
|
438
|
+
const pending = this.pendingRequests.get(requestId);
|
|
439
|
+
if (!pending)
|
|
440
|
+
return;
|
|
441
|
+
clearTimeout(pending.timeout);
|
|
442
|
+
pending.resolve({
|
|
443
|
+
interactive_feedback: text,
|
|
444
|
+
images,
|
|
445
|
+
attachedFiles,
|
|
446
|
+
project_directory: this.currentRequest?.projectDir || '',
|
|
447
|
+
});
|
|
448
|
+
this.pendingRequests.delete(requestId);
|
|
449
|
+
this.feishu.clearPending(requestId);
|
|
450
|
+
this.lastFeishuResolved = { id: requestId, at: Date.now() };
|
|
451
|
+
debugLog(`Feedback resolved from Feishu for request: ${requestId} ` +
|
|
452
|
+
`(images=${images.length}, files=${attachedFiles.length})`);
|
|
453
|
+
// 轻回执:给用户消息加 ✅ 表情,不产生新的未读消息(方案B)。
|
|
454
|
+
// ackMessageId 为 undefined 表示回执已在别处完成(如抢跑暂存时已 react),跳过。
|
|
455
|
+
if (ackMessageId !== undefined && chatId) {
|
|
456
|
+
this.feishu.reactDone(ackMessageId || undefined, chatId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** 把飞书回复广播给其他窗口的 server(跨实例路由兜底) */
|
|
460
|
+
broadcastFeishuInbound(parentId, text, chatId, images = [], attachedFiles = [], messageId) {
|
|
461
|
+
const body = JSON.stringify({ parentId, text, chatId, images, attachedFiles, messageId });
|
|
462
|
+
for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
|
|
463
|
+
if (p === this.port)
|
|
464
|
+
continue;
|
|
465
|
+
const req = http.request({
|
|
466
|
+
hostname: '127.0.0.1',
|
|
467
|
+
port: p,
|
|
468
|
+
path: '/api/feishu/inbound',
|
|
469
|
+
method: 'POST',
|
|
470
|
+
timeout: 2000,
|
|
471
|
+
headers: {
|
|
472
|
+
'Content-Type': 'application/json',
|
|
473
|
+
'Content-Length': Buffer.byteLength(body),
|
|
474
|
+
},
|
|
475
|
+
}, (res) => {
|
|
476
|
+
res.on('data', () => { });
|
|
477
|
+
res.on('end', () => { });
|
|
478
|
+
});
|
|
479
|
+
req.on('error', () => { });
|
|
480
|
+
req.on('timeout', () => req.destroy());
|
|
481
|
+
req.write(body);
|
|
482
|
+
req.end();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
|
|
487
|
+
* 用于无 parent_id 的「无主消息」:飞书只推给一个窗口,需汇总全局才能正确决策。
|
|
488
|
+
*/
|
|
489
|
+
queryRemotePending() {
|
|
490
|
+
const ports = [];
|
|
491
|
+
for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
|
|
492
|
+
if (p !== this.port)
|
|
493
|
+
ports.push(p);
|
|
494
|
+
}
|
|
495
|
+
return Promise.all(ports.map((port) => this.fetchRemotePending(port)));
|
|
496
|
+
}
|
|
497
|
+
fetchRemotePending(port) {
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
const req = http.request({ hostname: '127.0.0.1', port, path: '/api/feishu/pending', method: 'GET', timeout: 1500 }, (res) => {
|
|
500
|
+
let body = '';
|
|
501
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
502
|
+
res.on('end', () => {
|
|
503
|
+
try {
|
|
504
|
+
const j = JSON.parse(body);
|
|
505
|
+
resolve({ port, list: Array.isArray(j.list) ? j.list : [] });
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
resolve({ port, list: [] });
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
req.on('error', () => resolve({ port, list: [] }));
|
|
513
|
+
req.on('timeout', () => { req.destroy(); resolve({ port, list: [] }); });
|
|
514
|
+
req.end();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** 把「无主消息」转发给全局唯一持有 pending 的那个远程窗口,让它认领提交 */
|
|
518
|
+
forwardOrphan(port, text, chatId, images = [], attachedFiles = [], messageId) {
|
|
519
|
+
const body = JSON.stringify({ text, chatId, images, attachedFiles, messageId });
|
|
520
|
+
const req = http.request({
|
|
521
|
+
hostname: '127.0.0.1',
|
|
522
|
+
port,
|
|
523
|
+
path: '/api/feishu/inbound-orphan',
|
|
524
|
+
method: 'POST',
|
|
525
|
+
timeout: 2000,
|
|
526
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
527
|
+
}, (res) => { res.on('data', () => { }); res.on('end', () => { }); });
|
|
528
|
+
req.on('error', () => { });
|
|
529
|
+
req.on('timeout', () => req.destroy());
|
|
530
|
+
req.write(body);
|
|
531
|
+
req.end();
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 作废本实例所有未决的反馈请求(在每次新请求进来时调用)。
|
|
535
|
+
* 单个 server 实例服务单个 Cursor 窗口的单个对话,同时只应有一个活跃反馈请求;
|
|
536
|
+
* resolve(null) 让旧的 await 自然返回走超时分支,其 finally 因 id 不匹配不会误清新请求。
|
|
537
|
+
*/
|
|
538
|
+
cancelStalePending() {
|
|
539
|
+
if (this.pendingRequests.size === 0)
|
|
540
|
+
return;
|
|
541
|
+
for (const [reqId, pending] of this.pendingRequests) {
|
|
542
|
+
clearTimeout(pending.timeout);
|
|
543
|
+
pending.resolve(null);
|
|
544
|
+
this.feishu.clearPending(reqId);
|
|
296
545
|
}
|
|
546
|
+
this.pendingRequests.clear();
|
|
297
547
|
}
|
|
298
548
|
/**
|
|
299
549
|
* 等待用户反馈
|
|
@@ -303,6 +553,7 @@ class McpFeedbackServer {
|
|
|
303
553
|
const timeout = setTimeout(() => {
|
|
304
554
|
debugLog(`Request ${requestId} timed out`);
|
|
305
555
|
this.pendingRequests.delete(requestId);
|
|
556
|
+
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
306
557
|
resolve(null);
|
|
307
558
|
}, timeoutMs);
|
|
308
559
|
this.pendingRequests.set(requestId, {
|
|
@@ -310,8 +561,26 @@ class McpFeedbackServer {
|
|
|
310
561
|
reject: () => resolve(null),
|
|
311
562
|
timeout
|
|
312
563
|
});
|
|
564
|
+
// 抢跑兑现:用户在空窗期「无主」发来的消息已被暂存,本轮 pending 一注册立即作为回复提交
|
|
565
|
+
this.tryConsumeStash(requestId);
|
|
313
566
|
});
|
|
314
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* 抢跑暂存兑现:若存在近期「无主」飞书消息,立即作为指定请求的回复提交。
|
|
570
|
+
* 超过 STASH_TTL_MS 的暂存视为与当前任务无关,直接丢弃。
|
|
571
|
+
*/
|
|
572
|
+
tryConsumeStash(requestId) {
|
|
573
|
+
const stash = this.stashedInbound;
|
|
574
|
+
if (!stash)
|
|
575
|
+
return;
|
|
576
|
+
this.stashedInbound = null;
|
|
577
|
+
if (Date.now() - stash.at > McpFeedbackServer.STASH_TTL_MS) {
|
|
578
|
+
debugLog('Stashed inbound expired, dropped');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
debugLog(`Consuming stashed inbound for request: ${requestId}`);
|
|
582
|
+
this.submitFromFeishu(requestId, stash.text, stash.chatId, stash.images, stash.files);
|
|
583
|
+
}
|
|
315
584
|
/**
|
|
316
585
|
* 处理获取系统信息请求
|
|
317
586
|
*/
|
|
@@ -357,7 +626,7 @@ class McpFeedbackServer {
|
|
|
357
626
|
* 生成唯一的请求 ID
|
|
358
627
|
*/
|
|
359
628
|
generateRequestId() {
|
|
360
|
-
return `req_${Date.now()}_${Math.random().toString(36).
|
|
629
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
361
630
|
}
|
|
362
631
|
/**
|
|
363
632
|
* 检查端口是否被我们的 MCP Server 占用,如果是则请求关闭
|
|
@@ -442,23 +711,41 @@ class McpFeedbackServer {
|
|
|
442
711
|
try {
|
|
443
712
|
// 任何来自插件的 HTTP 请求都视为 Cursor 仍存活的心跳(供 watchdog 判定)
|
|
444
713
|
this.lastActivityTime = Date.now();
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
714
|
+
// 不设置 CORS 头:本地 API 仅供本插件(Node http 客户端,不受同源策略限制)调用。
|
|
715
|
+
// 去掉 Access-Control-Allow-Origin: * 后,同机浏览器里的恶意网页无法跨域读取
|
|
716
|
+
// /api/feedback/current(可能含 AI summary 中的代码),靠浏览器同源策略兜底。
|
|
449
717
|
if (req.method === 'OPTIONS') {
|
|
450
|
-
res.writeHead(
|
|
718
|
+
res.writeHead(204);
|
|
451
719
|
res.end();
|
|
452
720
|
return;
|
|
453
721
|
}
|
|
454
722
|
// 获取当前反馈请求
|
|
455
723
|
if (req.method === 'GET' && req.url?.startsWith('/api/feedback/current')) {
|
|
724
|
+
// autoRetry 不再从轮询 query 同步(曾致多窗口互相覆盖、抖动):
|
|
725
|
+
// 改由 POST /api/settings/autoRetry 广播 + 磁盘真相源,poll 只回读做 UI 回显。
|
|
726
|
+
try {
|
|
727
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
728
|
+
// 「我的窗口」心跳:仅当轮询带的 workspace 匹配本实例归属时刷新。
|
|
729
|
+
// 别的活跃窗口对全端口的扫描虽刷 lastActivityTime,但 workspace 不匹配、不刷此字段,
|
|
730
|
+
// 故已关窗口的残留 server 不会被别人续命,可被 watchdog / 僵尸自检识别。
|
|
731
|
+
const ws = this.normalizePath(u.searchParams.get('workspace') || '');
|
|
732
|
+
if (ws && (ws === this.ownerWorkspace || ws === this.normalizePath(this.realWorkspace || ''))) {
|
|
733
|
+
this.lastOwnerPollTime = Date.now();
|
|
734
|
+
this.everOwnerPolled = true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
// 忽略解析错误
|
|
739
|
+
}
|
|
456
740
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
457
|
-
// 返回当前请求、ownerWorkspace
|
|
741
|
+
// 返回当前请求、ownerWorkspace、startTime,以及当前生效的 autoRetry(供 UI 初始同步)
|
|
458
742
|
res.end(JSON.stringify({
|
|
459
743
|
request: this.currentRequest || null,
|
|
460
744
|
ownerWorkspace: this.ownerWorkspace,
|
|
461
745
|
startTime: this.startTime,
|
|
746
|
+
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
747
|
+
feishu: this.feishu.getStatus(),
|
|
748
|
+
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
462
749
|
}));
|
|
463
750
|
return;
|
|
464
751
|
}
|
|
@@ -478,6 +765,7 @@ class McpFeedbackServer {
|
|
|
478
765
|
clearTimeout(pending.timeout);
|
|
479
766
|
pending.resolve(feedback);
|
|
480
767
|
this.pendingRequests.delete(requestId);
|
|
768
|
+
// 飞书侧清理同样交给 finally(clearPending)统一处理,这里不再重复。
|
|
481
769
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
482
770
|
res.end(JSON.stringify({ success: true }));
|
|
483
771
|
}
|
|
@@ -495,6 +783,128 @@ class McpFeedbackServer {
|
|
|
495
783
|
});
|
|
496
784
|
return;
|
|
497
785
|
}
|
|
786
|
+
// 配置飞书(来自插件 UI;凭证未变只补 chatId,变了则重建长连接)
|
|
787
|
+
if (req.method === 'POST' && req.url === '/api/feishu/config') {
|
|
788
|
+
let body = '';
|
|
789
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
790
|
+
req.on('end', () => {
|
|
791
|
+
(async () => {
|
|
792
|
+
try {
|
|
793
|
+
const cfg = JSON.parse(body);
|
|
794
|
+
// 凭证以磁盘为全局真相源:用户改/删 → 持久化(touched=true)+ configure。
|
|
795
|
+
// 多窗口下插件会把同一份 POST 给所有 server,各自写同一磁盘文件、行为一致;
|
|
796
|
+
// 不再回退 env——用户主动清空就是空(touched 标记让重启后也尊重,不被 env 复活)。
|
|
797
|
+
this.feishu.writePersistedConfig({
|
|
798
|
+
appId: cfg.appId || '',
|
|
799
|
+
appSecret: cfg.appSecret || '',
|
|
800
|
+
enabled: cfg.enabled,
|
|
801
|
+
ackReaction: cfg.ackReaction,
|
|
802
|
+
touched: true,
|
|
803
|
+
});
|
|
804
|
+
await this.feishu.configure(cfg);
|
|
805
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
806
|
+
res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
810
|
+
res.end(JSON.stringify({ error: 'Invalid config' }));
|
|
811
|
+
}
|
|
812
|
+
})();
|
|
813
|
+
});
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
// 超时续期开关(来自插件 UI;磁盘做真相源,跨窗口/重启一致;不再走轮询 query)
|
|
817
|
+
if (req.method === 'POST' && req.url === '/api/settings/autoRetry') {
|
|
818
|
+
let body = '';
|
|
819
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
820
|
+
req.on('end', () => {
|
|
821
|
+
try {
|
|
822
|
+
const { autoRetry } = JSON.parse(body);
|
|
823
|
+
if (typeof autoRetry === 'boolean') {
|
|
824
|
+
this.autoRetryOverride = autoRetry;
|
|
825
|
+
this.writePersistedSettings({ autoRetry });
|
|
826
|
+
}
|
|
827
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
828
|
+
res.end(JSON.stringify({ success: true, autoRetry: this.autoRetryOverride }));
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
832
|
+
res.end(JSON.stringify({ error: 'Invalid body' }));
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// 解除飞书绑定(用户「删绑定」后重新发消息即可重新绑定)
|
|
838
|
+
if (req.method === 'POST' && req.url === '/api/feishu/unbind') {
|
|
839
|
+
this.feishu.unbind();
|
|
840
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
841
|
+
res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
// 跨实例转发的飞书回复(由其他窗口的 server 广播过来)
|
|
845
|
+
if (req.method === 'POST' && req.url === '/api/feishu/inbound') {
|
|
846
|
+
let body = '';
|
|
847
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
848
|
+
req.on('end', () => {
|
|
849
|
+
try {
|
|
850
|
+
const { parentId, text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
|
|
851
|
+
const reqId = this.feishu.resolveParent(parentId);
|
|
852
|
+
if (reqId && this.pendingRequests.has(reqId)) {
|
|
853
|
+
this.submitFromFeishu(reqId, text, chatId, images || [], attachedFiles || [], messageId || '');
|
|
854
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
855
|
+
res.end(JSON.stringify({ handled: true }));
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
859
|
+
res.end(JSON.stringify({ handled: false }));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
res.writeHead(400);
|
|
864
|
+
res.end('{}');
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
// 查询本实例当前 pending 列表(供其他窗口做「全局视角」汇总;只暴露项目名)
|
|
870
|
+
if (req.method === 'GET' && req.url === '/api/feishu/pending') {
|
|
871
|
+
// 僵尸自检:本实例曾被自己窗口插件轮询,但已久未收到(只剩别的窗口在扫端口)→ 我的窗口已关,
|
|
872
|
+
// 不再上报 pending,避免污染全局计数(用户反馈的「没开两个窗口却显示 2 个同名」即源于此)。
|
|
873
|
+
const ownerGone = this.everOwnerPolled &&
|
|
874
|
+
Date.now() - this.lastOwnerPollTime > McpFeedbackServer.OWNER_IDLE_MS;
|
|
875
|
+
const list = ownerGone
|
|
876
|
+
? []
|
|
877
|
+
: this.feishu.listPending().map((p) => ({ projectName: this.projectName(p.projectDir) }));
|
|
878
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
879
|
+
res.end(JSON.stringify({ count: list.length, list }));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
// 接收「无主消息」转发:仅当本实例恰好持有唯一 pending 时认领提交
|
|
883
|
+
if (req.method === 'POST' && req.url === '/api/feishu/inbound-orphan') {
|
|
884
|
+
let body = '';
|
|
885
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
886
|
+
req.on('end', () => {
|
|
887
|
+
try {
|
|
888
|
+
const { text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
|
|
889
|
+
if (this.feishu.pendingCount() === 1) {
|
|
890
|
+
const only = this.feishu.theOnlyPendingId();
|
|
891
|
+
if (only) {
|
|
892
|
+
this.submitFromFeishu(only, text, chatId, images || [], attachedFiles || [], messageId || '');
|
|
893
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
894
|
+
res.end(JSON.stringify({ claimed: true }));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
899
|
+
res.end(JSON.stringify({ claimed: false }));
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
res.writeHead(400);
|
|
903
|
+
res.end('{}');
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
498
908
|
// 健康检查
|
|
499
909
|
if (req.method === 'GET' && req.url === '/api/health') {
|
|
500
910
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -534,7 +944,12 @@ class McpFeedbackServer {
|
|
|
534
944
|
});
|
|
535
945
|
this.httpServer.on('error', async (err) => {
|
|
536
946
|
if (err.code === 'EADDRINUSE') {
|
|
537
|
-
// 端口被占用,使用下一个端口(不关闭旧的 MCP Server
|
|
947
|
+
// 端口被占用,使用下一个端口(不关闭旧的 MCP Server,支持多项目独立运行)。
|
|
948
|
+
// 限制在扫描范围内:超出则放弃,避免 server 端口跑到插件扫描不到的区间导致失联。
|
|
949
|
+
if (this.port - this.basePort >= McpFeedbackServer.PORT_SCAN_RANGE - 1) {
|
|
950
|
+
reject(new Error(`No free port in scan range ${this.basePort}-${this.basePort + McpFeedbackServer.PORT_SCAN_RANGE - 1}`));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
538
953
|
debugLog(`Port ${this.port} is already in use, trying next port...`);
|
|
539
954
|
this.httpServer?.close();
|
|
540
955
|
this.port++;
|
|
@@ -550,6 +965,45 @@ class McpFeedbackServer {
|
|
|
550
965
|
});
|
|
551
966
|
});
|
|
552
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* 从 mcp.json 的 env 读取飞书默认配置。
|
|
970
|
+
* 服务于「没有 webview 面板」的 MCP host(如 Claude Desktop / CLI):它们无法用插件 UI 配飞书,
|
|
971
|
+
* 只能在 mcp.json 里配 FEISHU_*。优先级:插件 UI(非空凭证)> 这里的 env > 代码默认(开关全开)。
|
|
972
|
+
*/
|
|
973
|
+
/** 读取持久化的 server 设置(无文件返回 null)。 */
|
|
974
|
+
readPersistedSettings() {
|
|
975
|
+
try {
|
|
976
|
+
const raw = JSON.parse(fs.readFileSync(this.settingsStorePath, 'utf-8'));
|
|
977
|
+
return raw && typeof raw === 'object' ? raw : null;
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** 持久化 server 设置到磁盘(merge 写入,跨进程共享、重启不丢)。 */
|
|
984
|
+
writePersistedSettings(patch) {
|
|
985
|
+
try {
|
|
986
|
+
const cur = this.readPersistedSettings() || {};
|
|
987
|
+
const next = { ...cur, ...patch };
|
|
988
|
+
fs.mkdirSync(path.dirname(this.settingsStorePath), { recursive: true });
|
|
989
|
+
fs.writeFileSync(this.settingsStorePath, JSON.stringify(next), 'utf-8');
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// 持久化失败不影响运行
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
readFeishuEnvConfig() {
|
|
996
|
+
const appId = (process.env.FEISHU_APP_ID || '').trim();
|
|
997
|
+
const appSecret = (process.env.FEISHU_APP_SECRET || '').trim();
|
|
998
|
+
if (!appId || !appSecret)
|
|
999
|
+
return null;
|
|
1000
|
+
return {
|
|
1001
|
+
appId,
|
|
1002
|
+
appSecret,
|
|
1003
|
+
enabled: process.env.FEISHU_ENABLED !== 'false',
|
|
1004
|
+
ackReaction: process.env.FEISHU_ACK !== 'false',
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
553
1007
|
/**
|
|
554
1008
|
* 启动服务器
|
|
555
1009
|
*/
|
|
@@ -558,9 +1012,33 @@ class McpFeedbackServer {
|
|
|
558
1012
|
debugLog('Starting MCP Feedback Server...');
|
|
559
1013
|
// 启动 HTTP 服务器
|
|
560
1014
|
await this.startHttpServer();
|
|
1015
|
+
// 飞书凭证优先级:用户在面板配过(磁盘持久化、跨窗口共享、含主动清空)> mcp.json env > 不启用。
|
|
1016
|
+
// 磁盘有 touched 记录就尊重它(哪怕凭证为空 = 用户主动清空,也不被 env 复活);
|
|
1017
|
+
// 没碰过才回退 env(无面板的 host 靠 env 启用)。
|
|
1018
|
+
const persisted = this.feishu.readPersistedConfig();
|
|
1019
|
+
if (persisted && persisted.touched) {
|
|
1020
|
+
this.feishu.configure(persisted).catch(() => { });
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
const envFeishu = this.readFeishuEnvConfig();
|
|
1024
|
+
if (envFeishu) {
|
|
1025
|
+
this.feishu.configure(envFeishu).catch(() => { });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// 超时续期开关同款优先级:磁盘(UI 改过、跨窗口共享)> env MCP_AUTO_RETRY > 默认开。
|
|
1029
|
+
const settings = this.readPersistedSettings();
|
|
1030
|
+
if (settings && typeof settings.autoRetry === 'boolean') {
|
|
1031
|
+
this.autoRetryOverride = settings.autoRetry;
|
|
1032
|
+
}
|
|
561
1033
|
// 启动 MCP stdio 传输
|
|
562
1034
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
563
1035
|
await this.server.connect(transport);
|
|
1036
|
+
// stdio 关闭(Cursor reload / 关窗 / 重启该 MCP)→ 立即退出,避免旧进程残留堆积
|
|
1037
|
+
transport.onclose = () => {
|
|
1038
|
+
debugLog('stdio transport closed, exiting...');
|
|
1039
|
+
this.stop();
|
|
1040
|
+
setTimeout(() => process.exit(0), 100);
|
|
1041
|
+
};
|
|
564
1042
|
// 启动看门狗兜底退出(防止 Cursor 关闭后进程残留 / CPU 占满)
|
|
565
1043
|
this.startWatchdog();
|
|
566
1044
|
debugLog('MCP Server started successfully');
|
|
@@ -601,6 +1079,14 @@ class McpFeedbackServer {
|
|
|
601
1079
|
this.stop();
|
|
602
1080
|
process.exit(0);
|
|
603
1081
|
}
|
|
1082
|
+
// 防线 3:本实例曾被自己窗口插件轮询,但久未再收到(只剩别的活跃窗口在扫端口刷 lastActivityTime)
|
|
1083
|
+
// → 我的窗口已关、本实例是僵尸 → 退出,避免持续被全局 pending 计数误算成「另一个在等的窗口」。
|
|
1084
|
+
const ownerIdle = Date.now() - this.lastOwnerPollTime;
|
|
1085
|
+
if (this.everOwnerPolled && ownerIdle > IDLE_TIMEOUT) {
|
|
1086
|
+
debugLog(`Owner window idle for ${ownerIdle}ms, stale instance, exiting...`);
|
|
1087
|
+
this.stop();
|
|
1088
|
+
process.exit(0);
|
|
1089
|
+
}
|
|
604
1090
|
}, 5000);
|
|
605
1091
|
// 不让 watchdog 自身阻止进程的自然退出
|
|
606
1092
|
this.watchdogTimer.unref();
|
|
@@ -631,6 +1117,14 @@ class McpFeedbackServer {
|
|
|
631
1117
|
debugLog('Server stopped');
|
|
632
1118
|
}
|
|
633
1119
|
}
|
|
1120
|
+
McpFeedbackServer.PORT_SCAN_RANGE = 20; // 与插件端扫描范围保持一致
|
|
1121
|
+
/** 抢跑暂存有效期:超过则视为与当前任务无关,丢弃 */
|
|
1122
|
+
McpFeedbackServer.STASH_TTL_MS = 8000;
|
|
1123
|
+
/**
|
|
1124
|
+
* 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
|
|
1125
|
+
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
|
1126
|
+
*/
|
|
1127
|
+
McpFeedbackServer.OWNER_IDLE_MS = 12000;
|
|
634
1128
|
// 主函数
|
|
635
1129
|
async function main() {
|
|
636
1130
|
const port = 61927;
|