cursor-feedback 2.0.4 → 2.1.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 +24 -0
- package/README.md +29 -0
- package/README_CN.md +29 -0
- package/dist/extension.js +139 -11
- package/dist/i18n/en.json +19 -1
- package/dist/i18n/index.js +19 -1
- package/dist/i18n/zh-CN.json +19 -1
- package/dist/mcp-server.js +306 -58
- package/dist/webview/index.html +22 -0
- package/dist/webview/script.js +351 -10
- package/dist/webview/styles.css +163 -0
- package/package.json +2 -1
package/dist/mcp-server.js
CHANGED
|
@@ -90,6 +90,10 @@ class McpFeedbackServer {
|
|
|
90
90
|
* 暂存后等下一轮 pending 一注册立即兑现,避免回复石沉大海(修复用户反馈的竞态 bug)。
|
|
91
91
|
*/
|
|
92
92
|
this.stashedInbound = null;
|
|
93
|
+
/** 最近超时结束的请求(id → 归属窗口/时刻),供面板提交与飞书旧卡片回复「续接」下一轮 */
|
|
94
|
+
this.recentlyTimedOut = new Map();
|
|
95
|
+
/** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
|
|
96
|
+
this.panelStash = null;
|
|
93
97
|
/** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
|
|
94
98
|
this.stashExpiryTimer = null;
|
|
95
99
|
// 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
@@ -250,7 +254,7 @@ class McpFeedbackServer {
|
|
|
250
254
|
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
251
255
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
252
256
|
//(即你看到的两个一模一样的 cursor-feedback-extension)。
|
|
253
|
-
this.cancelStalePending();
|
|
257
|
+
this.cancelStalePending(projectDir);
|
|
254
258
|
// AI 调用 feedback 时设置 ownerWorkspace(这是唯一正确的时机)
|
|
255
259
|
this.ownerWorkspace = this.normalizePath(projectDir);
|
|
256
260
|
debugLog(`Owner workspace set to: ${this.ownerWorkspace}`);
|
|
@@ -273,16 +277,25 @@ class McpFeedbackServer {
|
|
|
273
277
|
}
|
|
274
278
|
try {
|
|
275
279
|
// 等待用户反馈
|
|
276
|
-
const
|
|
277
|
-
|
|
280
|
+
const outcome = await this.waitForFeedback(requestId, timeout * 1000, projectDir);
|
|
281
|
+
// 被同窗口/同进程的新一轮请求取代:安静结束,绝不能重试(否则多 agent 互相取消、忙等刷屏)。
|
|
282
|
+
if (outcome.kind === 'superseded') {
|
|
283
|
+
debugLog(`Request ${requestId} superseded by a newer request; ending this turn quietly`);
|
|
284
|
+
return {
|
|
285
|
+
content: [
|
|
286
|
+
{
|
|
287
|
+
type: 'text',
|
|
288
|
+
text: '[SUPERSEDED] This feedback request was replaced by a newer interactive_feedback call (the same workspace started a new round, or another agent shares this MCP process). Do NOT call interactive_feedback again for this turn — just end your turn quietly. 【本反馈请求已被同窗口/同进程的新请求取代:不要再次调用 interactive_feedback,安静结束本轮,切勿重试。】',
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (outcome.kind === 'timeout') {
|
|
278
294
|
debugLog('Feedback wait window elapsed without user input');
|
|
279
295
|
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
280
296
|
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
281
297
|
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
282
|
-
|
|
283
|
-
const autoRetry = this.autoRetryOverride !== null
|
|
284
|
-
? this.autoRetryOverride
|
|
285
|
-
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
298
|
+
const autoRetry = this.effectiveAutoRetry();
|
|
286
299
|
const timeoutMessage = autoRetry
|
|
287
300
|
? '[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 继续等待,不要结束对话、不要当作任务完成。】'
|
|
288
301
|
: '[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),可以结束本轮。】';
|
|
@@ -295,6 +308,7 @@ class McpFeedbackServer {
|
|
|
295
308
|
],
|
|
296
309
|
};
|
|
297
310
|
}
|
|
311
|
+
const result = outcome.data;
|
|
298
312
|
debugLog(`Received feedback: ${result.interactive_feedback?.substring(0, 100)}...`);
|
|
299
313
|
const contentItems = [];
|
|
300
314
|
// 构建反馈文本
|
|
@@ -381,14 +395,20 @@ class McpFeedbackServer {
|
|
|
381
395
|
if (this.pendingRequests.has(reqId)) {
|
|
382
396
|
this.submitFromFeishu(reqId, text, chatId, images, files, messageId);
|
|
383
397
|
}
|
|
398
|
+
else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
|
|
399
|
+
// 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
|
|
400
|
+
}
|
|
384
401
|
else {
|
|
385
|
-
//
|
|
402
|
+
// 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
|
|
386
403
|
this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
|
|
387
404
|
}
|
|
388
405
|
}
|
|
389
406
|
else {
|
|
390
|
-
// 不是本实例发出的卡片 → 广播给其他窗口的 server
|
|
391
|
-
this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
407
|
+
// 不是本实例发出的卡片 → 广播给其他窗口的 server;无人认领必须回执,绝不静默丢弃
|
|
408
|
+
const claimed = await this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
409
|
+
if (!claimed) {
|
|
410
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, '⚠️ 这张卡片对应的反馈已结束或其所在窗口已关闭,消息未能送达。请回复最新的卡片,或等 AI 下次询问时再发。');
|
|
411
|
+
}
|
|
392
412
|
}
|
|
393
413
|
return;
|
|
394
414
|
}
|
|
@@ -405,8 +425,7 @@ class McpFeedbackServer {
|
|
|
405
425
|
// 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
|
|
406
426
|
// 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
|
|
407
427
|
// 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
|
|
408
|
-
this.
|
|
409
|
-
this.armStashExpiryNotice();
|
|
428
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.STASH_TTL_MS);
|
|
410
429
|
}
|
|
411
430
|
else if (globalCount === 1) {
|
|
412
431
|
if (localPending.length === 1) {
|
|
@@ -427,6 +446,12 @@ class McpFeedbackServer {
|
|
|
427
446
|
}
|
|
428
447
|
});
|
|
429
448
|
}
|
|
449
|
+
/** 当前生效的超时续期开关(优先 UI 开关 autoRetryOverride,未设置时回退环境变量/默认开启) */
|
|
450
|
+
effectiveAutoRetry() {
|
|
451
|
+
return this.autoRetryOverride !== null
|
|
452
|
+
? this.autoRetryOverride
|
|
453
|
+
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
454
|
+
}
|
|
430
455
|
projectName(dir) {
|
|
431
456
|
return dir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || dir;
|
|
432
457
|
}
|
|
@@ -444,7 +469,8 @@ class McpFeedbackServer {
|
|
|
444
469
|
interactive_feedback: text,
|
|
445
470
|
images,
|
|
446
471
|
attachedFiles,
|
|
447
|
-
|
|
472
|
+
// 用 pending 自己的归属窗口:共享进程多窗口时 currentRequest 可能已是另一窗口的请求
|
|
473
|
+
project_directory: pending.projectDir || this.currentRequest?.projectDir || '',
|
|
448
474
|
});
|
|
449
475
|
this.pendingRequests.delete(requestId);
|
|
450
476
|
this.feishu.clearPending(requestId);
|
|
@@ -457,31 +483,46 @@ class McpFeedbackServer {
|
|
|
457
483
|
this.feishu.reactDone(ackMessageId || undefined, chatId);
|
|
458
484
|
}
|
|
459
485
|
}
|
|
460
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* 把飞书回复广播给其他窗口的 server(跨实例路由兜底)。
|
|
488
|
+
* 返回是否有任一实例认领(handled)——无人认领时调用方必须回执用户,不能静默丢弃。
|
|
489
|
+
*/
|
|
461
490
|
broadcastFeishuInbound(parentId, text, chatId, images = [], attachedFiles = [], messageId) {
|
|
462
491
|
const body = JSON.stringify({ parentId, text, chatId, images, attachedFiles, messageId });
|
|
492
|
+
const posts = [];
|
|
463
493
|
for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
|
|
464
494
|
if (p === this.port)
|
|
465
495
|
continue;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
496
|
+
posts.push(new Promise((resolve) => {
|
|
497
|
+
const req = http.request({
|
|
498
|
+
hostname: '127.0.0.1',
|
|
499
|
+
port: p,
|
|
500
|
+
path: '/api/feishu/inbound',
|
|
501
|
+
method: 'POST',
|
|
502
|
+
timeout: 2000,
|
|
503
|
+
headers: {
|
|
504
|
+
'Content-Type': 'application/json',
|
|
505
|
+
'Content-Length': Buffer.byteLength(body),
|
|
506
|
+
},
|
|
507
|
+
}, (res) => {
|
|
508
|
+
let resBody = '';
|
|
509
|
+
res.on('data', (chunk) => { resBody += chunk.toString(); });
|
|
510
|
+
res.on('end', () => {
|
|
511
|
+
try {
|
|
512
|
+
resolve(!!JSON.parse(resBody).handled);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
resolve(false);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
req.on('error', () => resolve(false));
|
|
520
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
521
|
+
req.write(body);
|
|
522
|
+
req.end();
|
|
523
|
+
}));
|
|
484
524
|
}
|
|
525
|
+
return Promise.all(posts).then((results) => results.some(Boolean));
|
|
485
526
|
}
|
|
486
527
|
/**
|
|
487
528
|
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
|
|
@@ -532,48 +573,160 @@ class McpFeedbackServer {
|
|
|
532
573
|
req.end();
|
|
533
574
|
}
|
|
534
575
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
576
|
+
* 作废「同一窗口(projectDir)」此前残留的未决反馈请求(每次新请求进来时调用)。
|
|
577
|
+
* 关键:绝不能动其他窗口 / agent 的 pending——多窗口或 AgentWindow 会复用同一个 MCP 进程,
|
|
578
|
+
* 误清它们会让对方被当成超时而立即重试,多个 agent 互相取消,形成「取消风暴」(高频刷调用)。
|
|
579
|
+
* 被作废者 resolve(null) → waitForFeedback 映射为 superseded,旧 await 安静结束、不重试;
|
|
580
|
+
* 其 finally 因 id 不匹配也不会误清新请求。
|
|
538
581
|
*/
|
|
539
|
-
cancelStalePending() {
|
|
582
|
+
cancelStalePending(projectDir) {
|
|
540
583
|
if (this.pendingRequests.size === 0)
|
|
541
584
|
return;
|
|
585
|
+
const owner = this.normalizePath(projectDir);
|
|
542
586
|
for (const [reqId, pending] of this.pendingRequests) {
|
|
587
|
+
if (this.normalizePath(pending.projectDir) !== owner)
|
|
588
|
+
continue;
|
|
543
589
|
clearTimeout(pending.timeout);
|
|
544
|
-
pending.resolve(null);
|
|
590
|
+
pending.resolve(null); // → superseded:旧 await 安静结束,不触发重试
|
|
545
591
|
this.feishu.clearPending(reqId);
|
|
592
|
+
this.pendingRequests.delete(reqId);
|
|
546
593
|
}
|
|
547
|
-
this.pendingRequests.clear();
|
|
548
594
|
}
|
|
549
595
|
/**
|
|
550
596
|
* 等待用户反馈
|
|
551
597
|
*/
|
|
552
|
-
waitForFeedback(requestId, timeoutMs) {
|
|
598
|
+
waitForFeedback(requestId, timeoutMs, projectDir) {
|
|
553
599
|
return new Promise((resolve) => {
|
|
554
|
-
const
|
|
600
|
+
const onTimeout = () => {
|
|
555
601
|
debugLog(`Request ${requestId} timed out`);
|
|
556
602
|
this.pendingRequests.delete(requestId);
|
|
603
|
+
// 记录「刚超时」:续期空窗内的面板提交 / 旧卡片回复可续接到下一轮(顺手清理过期记录)
|
|
604
|
+
this.recentlyTimedOut.set(requestId, { projectDir, at: Date.now() });
|
|
605
|
+
for (const [id, v] of this.recentlyTimedOut) {
|
|
606
|
+
if (Date.now() - v.at > McpFeedbackServer.REJOIN_TTL_MS * 4)
|
|
607
|
+
this.recentlyTimedOut.delete(id);
|
|
608
|
+
}
|
|
557
609
|
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
558
|
-
resolve(
|
|
559
|
-
}
|
|
610
|
+
resolve({ kind: 'timeout' });
|
|
611
|
+
};
|
|
612
|
+
const timeout = setTimeout(onTimeout, timeoutMs);
|
|
560
613
|
this.pendingRequests.set(requestId, {
|
|
561
|
-
resolve
|
|
562
|
-
|
|
563
|
-
|
|
614
|
+
// resolve 包一层:沿用「外部 resolve(feedback) / resolve(null)」旧约定,
|
|
615
|
+
// 但 null 一律映射为 superseded(被同进程新请求取代),绝不再走「超时重试」路径。
|
|
616
|
+
resolve: (value) => {
|
|
617
|
+
resolve(value === null ? { kind: 'superseded' } : { kind: 'feedback', data: value });
|
|
618
|
+
},
|
|
619
|
+
reject: () => resolve({ kind: 'superseded' }),
|
|
620
|
+
timeout,
|
|
621
|
+
projectDir,
|
|
622
|
+
onTimeout,
|
|
623
|
+
deadline: Date.now() + timeoutMs,
|
|
624
|
+
paused: false,
|
|
625
|
+
remainingMs: timeoutMs,
|
|
564
626
|
});
|
|
565
|
-
//
|
|
627
|
+
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
628
|
+
// 本轮 pending 一注册立即作为回复提交
|
|
629
|
+
this.tryConsumePanelStash(requestId);
|
|
566
630
|
this.tryConsumeStash(requestId);
|
|
567
631
|
});
|
|
568
632
|
}
|
|
569
633
|
/**
|
|
570
|
-
*
|
|
634
|
+
* 暂停/恢复某个待反馈请求的超时倒计时(用户在面板点暂停按钮触发)。
|
|
635
|
+
* 暂停 = 冻结真实计时器并记下剩余时间;恢复 = 用剩余时间重新起表。
|
|
636
|
+
* 暂停期间 AI 一直挂在 interactive_feedback 调用上等待,正是期望行为(等同无限超时)。
|
|
637
|
+
*/
|
|
638
|
+
setPaused(requestId, paused) {
|
|
639
|
+
const p = this.pendingRequests.get(requestId);
|
|
640
|
+
if (!p)
|
|
641
|
+
return { ok: false, paused: false, remainingMs: 0 };
|
|
642
|
+
if (paused && !p.paused) {
|
|
643
|
+
clearTimeout(p.timeout);
|
|
644
|
+
p.remainingMs = Math.max(0, p.deadline - Date.now());
|
|
645
|
+
p.paused = true;
|
|
646
|
+
debugLog(`Request ${requestId} countdown paused (${p.remainingMs}ms left)`);
|
|
647
|
+
}
|
|
648
|
+
else if (!paused && p.paused) {
|
|
649
|
+
p.paused = false;
|
|
650
|
+
p.deadline = Date.now() + p.remainingMs;
|
|
651
|
+
p.timeout = setTimeout(p.onTimeout, p.remainingMs);
|
|
652
|
+
debugLog(`Request ${requestId} countdown resumed (${p.remainingMs}ms left)`);
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
paused: p.paused,
|
|
657
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** 当前请求的暂停态快照(随 /api/feedback/current 下发,供面板重建后恢复显示) */
|
|
661
|
+
getPauseState() {
|
|
662
|
+
const cur = this.currentRequest;
|
|
663
|
+
if (!cur)
|
|
664
|
+
return null;
|
|
665
|
+
const p = this.pendingRequests.get(cur.id);
|
|
666
|
+
if (!p)
|
|
667
|
+
return null;
|
|
668
|
+
return {
|
|
669
|
+
requestId: cur.id,
|
|
670
|
+
paused: p.paused,
|
|
671
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* 写入/合并抢跑暂存:短时间内连发多条(含图文拆条后超出合并窗口的)合并为一条,
|
|
676
|
+
* 绝不让后一条覆盖前一条(旧实现会把第一条静默丢掉)。
|
|
677
|
+
*/
|
|
678
|
+
stashInbound(text, chatId, images, files, messageId, ttlMs, forProjectDir) {
|
|
679
|
+
const prev = this.stashedInbound;
|
|
680
|
+
if (prev && Date.now() - prev.at <= prev.ttlMs && prev.chatId === chatId) {
|
|
681
|
+
// 未过期的同会话暂存 → 合并追加,刷新时间与 TTL
|
|
682
|
+
this.stashedInbound = {
|
|
683
|
+
text: [prev.text, text].filter(Boolean).join('\n'),
|
|
684
|
+
chatId,
|
|
685
|
+
images: [...prev.images, ...images],
|
|
686
|
+
files: [...prev.files, ...files],
|
|
687
|
+
at: Date.now(),
|
|
688
|
+
messageId: messageId || prev.messageId,
|
|
689
|
+
ttlMs: Math.max(prev.ttlMs, ttlMs),
|
|
690
|
+
forProjectDir: forProjectDir ?? prev.forProjectDir,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId, ttlMs, forProjectDir };
|
|
695
|
+
}
|
|
696
|
+
this.armStashExpiryNotice();
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* 用户回复了一张「刚超时」的卡片:超时续期开启时 AI 马上会重新发起下一轮,
|
|
700
|
+
* 把回复暂存续接到下一轮,而不是回一句「已结束」把用户的内容丢掉。
|
|
701
|
+
* 返回 true 表示已暂存(调用方无需再回执)。
|
|
702
|
+
*/
|
|
703
|
+
maybeStashForEndedCard(reqId, text, chatId, images, files, messageId) {
|
|
704
|
+
const rt = this.recentlyTimedOut.get(reqId);
|
|
705
|
+
if (!rt)
|
|
706
|
+
return false; // 不是「刚超时」的请求(可能已被回复过,或结束太久)
|
|
707
|
+
if (Date.now() - rt.at > McpFeedbackServer.REJOIN_TTL_MS)
|
|
708
|
+
return false;
|
|
709
|
+
if (!this.effectiveAutoRetry())
|
|
710
|
+
return false; // 续期关闭 → 不会有下一轮,别让用户空等
|
|
711
|
+
// 同窗口已有新一轮在等 → 用户该回复新卡片,不做续接(避免旧回复窜入错误轮次)
|
|
712
|
+
const owner = this.normalizePath(rt.projectDir);
|
|
713
|
+
for (const [, pending] of this.pendingRequests) {
|
|
714
|
+
if (this.normalizePath(pending.projectDir) === owner)
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
debugLog(`Reply to recently timed-out card ${reqId}, stashing for next round`);
|
|
718
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.REJOIN_TTL_MS, rt.projectDir);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 暂存后启动过期提示定时器:到 TTL 仍没被 AI 认领,就「回复」
|
|
571
723
|
* 那条消息明确告知没送到、引导重发(不再静默丢弃,也不给虚假回执)。
|
|
572
|
-
*
|
|
724
|
+
* 每来一条都重置,避免连发时多个定时器并存。
|
|
573
725
|
*/
|
|
574
726
|
armStashExpiryNotice() {
|
|
575
727
|
if (this.stashExpiryTimer)
|
|
576
728
|
clearTimeout(this.stashExpiryTimer);
|
|
729
|
+
const ttl = this.stashedInbound?.ttlMs ?? McpFeedbackServer.STASH_TTL_MS;
|
|
577
730
|
this.stashExpiryTimer = setTimeout(() => {
|
|
578
731
|
this.stashExpiryTimer = null;
|
|
579
732
|
const stash = this.stashedInbound;
|
|
@@ -585,23 +738,31 @@ class McpFeedbackServer {
|
|
|
585
738
|
// 且引导「待 AI 回复后再重发」:AI 只有重新调起 feedback(即回复)时才会等待接收,
|
|
586
739
|
// 此刻立即重发仍会落空。
|
|
587
740
|
this.feishu.replyToMessage(stash.messageId, stash.chatId, '⚠️ 消息未能送达 AI(它可能正忙于上一轮任务,或这轮对话已结束)。需要的话请待 AI 回复后重新发送一次。');
|
|
588
|
-
},
|
|
741
|
+
}, ttl);
|
|
589
742
|
}
|
|
590
743
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
744
|
+
* 抢跑暂存兑现:若存在近期暂存的飞书消息,立即作为指定请求的回复提交。
|
|
745
|
+
* 过期的暂存视为与当前任务无关,直接丢弃;带窗口限定的暂存只给对应窗口。
|
|
593
746
|
*/
|
|
594
747
|
tryConsumeStash(requestId) {
|
|
595
748
|
const stash = this.stashedInbound;
|
|
596
749
|
if (!stash)
|
|
597
750
|
return;
|
|
751
|
+
const pending = this.pendingRequests.get(requestId);
|
|
752
|
+
if (!pending)
|
|
753
|
+
return; // 请求已不在(不能消费暂存,留给真正等待中的下一轮)
|
|
754
|
+
// 带窗口限定的暂存(回复旧卡片的续接)只给同一窗口的新一轮,不给别的项目
|
|
755
|
+
if (stash.forProjectDir &&
|
|
756
|
+
this.normalizePath(stash.forProjectDir) !== this.normalizePath(pending.projectDir)) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
598
759
|
this.stashedInbound = null;
|
|
599
760
|
// 已被 AI 认领,取消「没送到」提示定时器
|
|
600
761
|
if (this.stashExpiryTimer) {
|
|
601
762
|
clearTimeout(this.stashExpiryTimer);
|
|
602
763
|
this.stashExpiryTimer = null;
|
|
603
764
|
}
|
|
604
|
-
if (Date.now() - stash.at >
|
|
765
|
+
if (Date.now() - stash.at > stash.ttlMs) {
|
|
605
766
|
debugLog('Stashed inbound expired, dropped');
|
|
606
767
|
return;
|
|
607
768
|
}
|
|
@@ -610,6 +771,30 @@ class McpFeedbackServer {
|
|
|
610
771
|
// 暂存阶段不再预先回执,避免「没人接却假装已收到」误导用户。
|
|
611
772
|
this.submitFromFeishu(requestId, stash.text, stash.chatId, stash.images, stash.files, stash.messageId);
|
|
612
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* 面板暂存兑现:用户在「刚超时、AI 正要续期重调」的空窗内点了提交,
|
|
776
|
+
* 反馈已被 /api/feedback/submit 暂存,这里在下一轮注册时立即送达。
|
|
777
|
+
*/
|
|
778
|
+
tryConsumePanelStash(requestId) {
|
|
779
|
+
const stash = this.panelStash;
|
|
780
|
+
if (!stash)
|
|
781
|
+
return;
|
|
782
|
+
const pending = this.pendingRequests.get(requestId);
|
|
783
|
+
if (!pending)
|
|
784
|
+
return;
|
|
785
|
+
if (this.normalizePath(stash.projectDir) !== this.normalizePath(pending.projectDir))
|
|
786
|
+
return;
|
|
787
|
+
this.panelStash = null;
|
|
788
|
+
if (Date.now() - stash.at > McpFeedbackServer.REJOIN_TTL_MS) {
|
|
789
|
+
debugLog('Panel stash expired, dropped');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
debugLog(`Consuming panel stash for request: ${requestId}`);
|
|
793
|
+
clearTimeout(pending.timeout);
|
|
794
|
+
pending.resolve(stash.feedback);
|
|
795
|
+
this.pendingRequests.delete(requestId);
|
|
796
|
+
this.feishu.clearPending(requestId);
|
|
797
|
+
}
|
|
613
798
|
/**
|
|
614
799
|
* 处理获取系统信息请求
|
|
615
800
|
*/
|
|
@@ -775,6 +960,7 @@ class McpFeedbackServer {
|
|
|
775
960
|
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
776
961
|
feishu: this.feishu.getStatus(),
|
|
777
962
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
963
|
+
pause: this.getPauseState(),
|
|
778
964
|
}));
|
|
779
965
|
return;
|
|
780
966
|
}
|
|
@@ -790,6 +976,7 @@ class McpFeedbackServer {
|
|
|
790
976
|
const { requestId, feedback } = data;
|
|
791
977
|
debugLog(`Received feedback submission for request: ${requestId}`);
|
|
792
978
|
const pending = this.pendingRequests.get(requestId);
|
|
979
|
+
const recentTimeout = this.recentlyTimedOut.get(requestId);
|
|
793
980
|
if (pending) {
|
|
794
981
|
clearTimeout(pending.timeout);
|
|
795
982
|
pending.resolve(feedback);
|
|
@@ -798,6 +985,16 @@ class McpFeedbackServer {
|
|
|
798
985
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
799
986
|
res.end(JSON.stringify({ success: true }));
|
|
800
987
|
}
|
|
988
|
+
else if (recentTimeout &&
|
|
989
|
+
this.effectiveAutoRetry() &&
|
|
990
|
+
Date.now() - recentTimeout.at <= McpFeedbackServer.REJOIN_TTL_MS) {
|
|
991
|
+
// 提交撞上「刚超时、AI 正要续期重调」的空窗:暂存并在下一轮注册时立即送达,
|
|
992
|
+
// 不再报 "Request not found" 把用户刚敲的反馈打回去
|
|
993
|
+
debugLog(`Request ${requestId} timed out moments ago; queueing panel feedback for next round`);
|
|
994
|
+
this.panelStash = { feedback, projectDir: recentTimeout.projectDir, at: Date.now() };
|
|
995
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
996
|
+
res.end(JSON.stringify({ success: true, queued: true }));
|
|
997
|
+
}
|
|
801
998
|
else {
|
|
802
999
|
debugLog(`Request ${requestId} not found`);
|
|
803
1000
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -842,6 +1039,35 @@ class McpFeedbackServer {
|
|
|
842
1039
|
});
|
|
843
1040
|
return;
|
|
844
1041
|
}
|
|
1042
|
+
// 暂停/恢复倒计时(来自插件 UI 的暂停按钮)
|
|
1043
|
+
if (req.method === 'POST' && req.url === '/api/feedback/pause') {
|
|
1044
|
+
let body = '';
|
|
1045
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1046
|
+
req.on('end', () => {
|
|
1047
|
+
try {
|
|
1048
|
+
const { requestId, paused } = JSON.parse(body);
|
|
1049
|
+
if (!requestId || typeof paused !== 'boolean') {
|
|
1050
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1051
|
+
res.end(JSON.stringify({ error: 'requestId and paused are required' }));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const result = this.setPaused(requestId, paused);
|
|
1055
|
+
if (result.ok) {
|
|
1056
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1057
|
+
res.end(JSON.stringify({ success: true, paused: result.paused, remainingMs: result.remainingMs }));
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1061
|
+
res.end(JSON.stringify({ error: 'Request not found' }));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1066
|
+
res.end(JSON.stringify({ error: 'Invalid body' }));
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
845
1071
|
// 超时续期开关(来自插件 UI;磁盘做真相源,跨窗口/重启一致;不再走轮询 query)
|
|
846
1072
|
if (req.method === 'POST' && req.url === '/api/settings/autoRetry') {
|
|
847
1073
|
let body = '';
|
|
@@ -883,6 +1109,12 @@ class McpFeedbackServer {
|
|
|
883
1109
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
884
1110
|
res.end(JSON.stringify({ handled: true }));
|
|
885
1111
|
}
|
|
1112
|
+
else if (reqId &&
|
|
1113
|
+
this.maybeStashForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
|
|
1114
|
+
// 本实例的卡片刚超时 → 暂存续接下一轮,向广播方声明已认领(未送达时由过期提示回执)
|
|
1115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1116
|
+
res.end(JSON.stringify({ handled: true }));
|
|
1117
|
+
}
|
|
886
1118
|
else {
|
|
887
1119
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
888
1120
|
res.end(JSON.stringify({ handled: false }));
|
|
@@ -1085,10 +1317,13 @@ class McpFeedbackServer {
|
|
|
1085
1317
|
* Cursor 关闭后,stdin 的 EOF 在该中间层下不可靠,node 易变成孤儿进程残留;
|
|
1086
1318
|
* 叠加 HTTP server 是常驻 active handle,进程无法自然退出。
|
|
1087
1319
|
*
|
|
1088
|
-
*
|
|
1089
|
-
* 1) 父进程死亡(被 init/launchd 收养,ppid 变为 1)→
|
|
1090
|
-
* 2) 超过 IDLE_TIMEOUT
|
|
1091
|
-
*
|
|
1320
|
+
* 防线(按环境自适应):
|
|
1321
|
+
* 1) 父进程死亡(被 init/launchd 收养,ppid 变为 1)→ 立即退出。**所有环境通用**。
|
|
1322
|
+
* 2) 超过 IDLE_TIMEOUT 无活动 → 判定所有 Cursor 窗口已关闭 → 退出。
|
|
1323
|
+
* **仅 Cursor 插件环境(everOwnerPolled)且无 pending 等待时才退**:插件每秒轮询 HTTP 刷新活动时间、
|
|
1324
|
+
* 停了就是关窗。无插件 host(Claude Desktop / CLI / 其他 MCP 客户端 / fe-ai-flow)没有轮询心跳、
|
|
1325
|
+
* idle 是常态、不靠它退出、只走防线 1 + stdin EOF(见 main 的 stdin close/end/error)。
|
|
1326
|
+
* 3) 曾被本窗口插件轮询、但久未再收到(僵尸实例、只剩别的窗口扫端口续命)→ 退出。
|
|
1092
1327
|
*/
|
|
1093
1328
|
startWatchdog() {
|
|
1094
1329
|
const IDLE_TIMEOUT = process.env.MCP_FEEDBACK_IDLE_TIMEOUT
|
|
@@ -1101,9 +1336,16 @@ class McpFeedbackServer {
|
|
|
1101
1336
|
this.stop();
|
|
1102
1337
|
process.exit(0);
|
|
1103
1338
|
}
|
|
1104
|
-
// 防线 2:长时间无任何活动 = Cursor
|
|
1339
|
+
// 防线 2:长时间无任何活动 = Cursor 已全部关闭。
|
|
1340
|
+
// 仅对「Cursor 插件环境」(everOwnerPolled:曾被自己窗口插件轮询过)生效——插件每秒轮询、
|
|
1341
|
+
// 停了就是关窗。无插件 host(Claude Desktop / CLI / 其他 MCP 客户端 / fe-ai-flow 等)没有轮询心跳、
|
|
1342
|
+
// idle 是常态、绝不能据此退出:否则 AI 调 interactive_feedback 等用户飞书回复的那几分钟里进程会自杀、
|
|
1343
|
+
// 回复永远回不来。这类 host 完全靠 stdin close/end/error(见 main)+ 防线 1(ppid===1)退出。
|
|
1344
|
+
// 另外:只要还有 pending 反馈在等用户、任何环境都不 idle 退出(正在等用户,绝不能死)。
|
|
1105
1345
|
const idle = Date.now() - this.lastActivityTime;
|
|
1106
|
-
if (
|
|
1346
|
+
if (this.everOwnerPolled &&
|
|
1347
|
+
this.pendingRequests.size === 0 &&
|
|
1348
|
+
idle > IDLE_TIMEOUT) {
|
|
1107
1349
|
debugLog(`No activity for ${idle}ms (> ${IDLE_TIMEOUT}ms), assuming Cursor closed, exiting...`);
|
|
1108
1350
|
this.stop();
|
|
1109
1351
|
process.exit(0);
|
|
@@ -1150,6 +1392,12 @@ McpFeedbackServer.PORT_SCAN_RANGE = 20; // 与插件端扫描范围保持一致
|
|
|
1150
1392
|
/** 抢跑暂存有效期:覆盖「AI 刚结束、正要调下一轮 feedback」的竞态间隙(通常 1-3s)。
|
|
1151
1393
|
* 取 5s——太长会把已脱离语境的旧消息硬塞进很久后才出现的新一轮,造成上下文错乱(像答非所问)。 */
|
|
1152
1394
|
McpFeedbackServer.STASH_TTL_MS = 5000;
|
|
1395
|
+
/**
|
|
1396
|
+
* 「刚超时」请求的续接窗口:超时续期开启时,AI 收到 TIMEOUT_KEEP_WAITING 后重新发起下一轮
|
|
1397
|
+
* 通常要 2~10s(取决于模型和上下文大小)。这段空窗内用户的面板提交 / 对旧卡片的回复
|
|
1398
|
+
* 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
|
|
1399
|
+
*/
|
|
1400
|
+
McpFeedbackServer.REJOIN_TTL_MS = 15000;
|
|
1153
1401
|
/**
|
|
1154
1402
|
* 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
|
|
1155
1403
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
package/dist/webview/index.html
CHANGED
|
@@ -73,6 +73,9 @@
|
|
|
73
73
|
<h2 id="feedbackLabel" class="card__title">{{i18n.yourFeedback}}</h2>
|
|
74
74
|
</div>
|
|
75
75
|
|
|
76
|
+
<!-- 快捷回复短语:点击直接发送;铅笔进入编辑模式可增删自定义 -->
|
|
77
|
+
<div id="quickReplies" class="quick-replies"></div>
|
|
78
|
+
|
|
76
79
|
<div class="composer">
|
|
77
80
|
<div id="refChips" class="ref-chips"></div>
|
|
78
81
|
<textarea id="feedbackInput" class="composer__input" rows="4"
|
|
@@ -88,6 +91,9 @@
|
|
|
88
91
|
<button id="insertContextBtn" class="iconbtn" type="button" data-tip="{{i18n.insertSelection}}" aria-label="{{i18n.insertSelection}}">
|
|
89
92
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
90
93
|
</button>
|
|
94
|
+
<button id="historyBtn" class="iconbtn" type="button" data-tip="{{i18n.historyBtn}}" aria-label="{{i18n.historyBtn}}">
|
|
95
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
|
96
|
+
</button>
|
|
91
97
|
</div>
|
|
92
98
|
<span class="composer__hint">{{i18n.contextHint}}</span>
|
|
93
99
|
<span id="charCount" class="composer__count">0</span>
|
|
@@ -104,6 +110,10 @@
|
|
|
104
110
|
<footer id="submitBar" class="submitbar hidden">
|
|
105
111
|
<!-- 倒计时进度 -->
|
|
106
112
|
<div id="timeoutWrap" class="countdown" hidden>
|
|
113
|
+
<button id="pauseBtn" class="iconbtn countdown__pause" type="button" aria-label="{{i18n.pauseCountdown}}" data-tip="{{i18n.pauseCountdown}}">
|
|
114
|
+
<svg class="countdown__pause-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
|
|
115
|
+
<svg class="countdown__play-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
|
116
|
+
</button>
|
|
107
117
|
<div class="countdown__track" role="progressbar" aria-label="Time remaining">
|
|
108
118
|
<div id="timeoutBar" class="countdown__bar"></div>
|
|
109
119
|
</div>
|
|
@@ -164,6 +174,12 @@
|
|
|
164
174
|
<span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
|
|
165
175
|
</label>
|
|
166
176
|
<p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.osNotifyDesc}}</p>
|
|
177
|
+
<!-- 权限被拒的排查指引(按平台注入文案,linux 为空则整行隐藏) -->
|
|
178
|
+
<p class="feishu-modal__desc feishu-modal__desc--sub feishu-troubleshoot">{{i18n.notifyTroubleshoot}}</p>
|
|
179
|
+
<div class="feishu-test-row">
|
|
180
|
+
<button id="notifyTestBtn" class="feishu-link" type="button">{{i18n.notifyTestBtn}}</button>
|
|
181
|
+
<span id="notifyTestHint" class="feishu-test-hint"></span>
|
|
182
|
+
</div>
|
|
167
183
|
</div>
|
|
168
184
|
</div>
|
|
169
185
|
|
|
@@ -212,6 +228,12 @@
|
|
|
212
228
|
<!-- @ 文件引用浮层(fixed 定位,避免被裁切) -->
|
|
213
229
|
<div id="mentionPopup" class="mention-popup hidden" role="listbox" aria-label="{{i18n.mentionFiles}}"></div>
|
|
214
230
|
|
|
231
|
+
<!-- 反馈历史浮层(点击条目回填到输入框) -->
|
|
232
|
+
<div id="historyPopup" class="history-popup hidden" role="listbox" aria-label="{{i18n.historyBtn}}"></div>
|
|
233
|
+
|
|
234
|
+
<!-- 提交结果轻提示(toast,几秒后自动消失) -->
|
|
235
|
+
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
|
236
|
+
|
|
215
237
|
<!-- 注入 i18n 数据 -->
|
|
216
238
|
<script>window.i18n = {{I18N_JSON}};</script>
|
|
217
239
|
<script src="{{SCRIPT_JS_URI}}"></script>
|