cursor-feedback 2.1.0 → 2.1.2
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/dist/extension.js +72 -39
- package/dist/i18n/en.json +1 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/zh-CN.json +1 -0
- package/dist/mcp-server.js +221 -97
- package/dist/webview/script.js +5 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [2.1.2](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.1...v2.1.2) (2026-07-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **mcp:** 多窗口/多对话共用进程的反馈路由修复——按窗口查询 + 重复投递去重 + 暂停保护 ([3b3d451](https://github.com/jianger666/cursor-feedback-extension/commit/3b3d451453ee5a04ce29929845d22191116e0f96))
|
|
11
|
+
|
|
12
|
+
### [2.1.1](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.0...v2.1.1) (2026-07-02)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **mcp:** 等待反馈期间不再被僵尸判定误杀——Connection closed 断连根因修复 ([39cc9d5](https://github.com/jianger666/cursor-feedback-extension/commit/39cc9d5fe5730023069014c8a749841993117efa))
|
|
18
|
+
|
|
5
19
|
## [2.1.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.0.5...v2.1.0) (2026-07-02)
|
|
6
20
|
|
|
7
21
|
|
package/dist/extension.js
CHANGED
|
@@ -46,6 +46,12 @@ let feedbackViewProvider = null;
|
|
|
46
46
|
function normalizePath(p) {
|
|
47
47
|
return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
48
48
|
}
|
|
49
|
+
/** 两个已归一化的路径互为前缀(相等 / 一方是另一方的子目录)即视为同一窗口语境 */
|
|
50
|
+
function pathsRelated(a, b) {
|
|
51
|
+
if (!a || !b)
|
|
52
|
+
return false;
|
|
53
|
+
return a === b || a.startsWith(b + '/') || b.startsWith(a + '/');
|
|
54
|
+
}
|
|
49
55
|
function activate(context) {
|
|
50
56
|
// 注册侧边栏 WebView(端口从 61927 开始自动扫描)
|
|
51
57
|
feedbackViewProvider = new FeedbackViewProvider(context.extensionUri, 61927, context.globalState);
|
|
@@ -109,8 +115,9 @@ function getWorkspacePaths() {
|
|
|
109
115
|
return folders.map(f => f.uri.fsPath);
|
|
110
116
|
}
|
|
111
117
|
/**
|
|
112
|
-
*
|
|
113
|
-
* -
|
|
118
|
+
* 检查路径是否匹配当前工作区
|
|
119
|
+
* - 有工作区的窗口:接收匹配工作区路径的消息(相等或互为子目录——AI 传的
|
|
120
|
+
* project_directory 常是工作区的子目录,精确匹配会漏收,面板收不到、心跳也失配)
|
|
114
121
|
* - 没有工作区的窗口:只接收没有指定项目路径的消息
|
|
115
122
|
*/
|
|
116
123
|
function isPathInWorkspace(targetPath) {
|
|
@@ -128,9 +135,7 @@ function isPathInWorkspace(targetPath) {
|
|
|
128
135
|
return false;
|
|
129
136
|
}
|
|
130
137
|
for (const wsPath of workspacePaths) {
|
|
131
|
-
|
|
132
|
-
// 精确匹配:只匹配完全相同的路径
|
|
133
|
-
if (normalizedTarget === normalizedWs) {
|
|
138
|
+
if (pathsRelated(normalizedTarget, normalizePath(wsPath))) {
|
|
134
139
|
return true;
|
|
135
140
|
}
|
|
136
141
|
}
|
|
@@ -156,7 +161,12 @@ class FeedbackViewProvider {
|
|
|
156
161
|
this._pendingInsert = null;
|
|
157
162
|
this._activePort = null;
|
|
158
163
|
this._portScanRange = 20; // 扫描端口范围
|
|
159
|
-
this._seenRequestIds = new Set(); //
|
|
164
|
+
this._seenRequestIds = new Set(); // 见过的请求 ID(只用于「新鲜提醒」去重,不再挡显示)
|
|
165
|
+
// 本窗口已提交 / 已被外部渠道结束的请求 ID:绝不复显。
|
|
166
|
+
// 与 _seenRequestIds 分开的原因:同窗口多对话并存时(如一个等待被暂停、另一对话又发起新等待),
|
|
167
|
+
// 被覆盖的旧请求在新请求结束后会重新从 server 返回,此时它虽「见过」但没提交过,必须能回到面板;
|
|
168
|
+
// 旧实现用 seen 一刀切挡显示,旧请求就永远回不来了。
|
|
169
|
+
this._resolvedRequestIds = new Set();
|
|
160
170
|
// 超时续期开关(由侧边栏按钮切换,随轮询同步给 MCP server)
|
|
161
171
|
this._autoRetry = true;
|
|
162
172
|
// 飞书配置(持久化在 globalState;secret 会回显给 webview,前端用小眼睛切换明文/掩码)
|
|
@@ -331,15 +341,19 @@ class FeedbackViewProvider {
|
|
|
331
341
|
// 如果有活跃端口,先尝试只轮询该端口
|
|
332
342
|
if (this._activePort) {
|
|
333
343
|
const result = await this._checkPortForRequest(this._activePort);
|
|
334
|
-
// 检查是否仍然是我们的 Server
|
|
344
|
+
// 检查是否仍然是我们的 Server。注意:多窗口/多对话共用一个 MCP 进程时,
|
|
345
|
+
// server 的 ownerWorkspace(进程归属,单值)可能是别的窗口——但只要它按窗口
|
|
346
|
+
// 返回了本工作区的请求(request 已在 _checkPortForRequest 里按 projectDir 过滤),
|
|
347
|
+
// 这个端口对本窗口就是有效的,不能因 owner 不匹配而丢弃请求。
|
|
335
348
|
if (result.connected) {
|
|
336
349
|
const serverOwner = result.ownerWorkspace ? normalizePath(result.ownerWorkspace) : '';
|
|
337
|
-
const isMyServer = !serverOwner || serverOwner
|
|
350
|
+
const isMyServer = !!result.request || !serverOwner || pathsRelated(serverOwner, normalizedCurrentWorkspace);
|
|
338
351
|
if (isMyServer) {
|
|
339
352
|
// 端口仍然有效,保持使用
|
|
340
353
|
this._debugInfo.connectedPorts = [this._activePort];
|
|
341
354
|
this._debugInfo.activePort = this._activePort;
|
|
342
|
-
|
|
355
|
+
// 只挡「已提交/已结束」的请求;_handleNewRequest 内部按当前显示态去重,重复调用无害
|
|
356
|
+
if (result.request && !this._resolvedRequestIds.has(result.request.id)) {
|
|
343
357
|
this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
|
|
344
358
|
this._handleNewRequest(result.request, this._activePort);
|
|
345
359
|
this._updateDebugInfo();
|
|
@@ -370,14 +384,13 @@ class FeedbackViewProvider {
|
|
|
370
384
|
const results = await Promise.all(ports.map(port => this._checkPortForRequest(port)));
|
|
371
385
|
// 更新已连接的端口列表
|
|
372
386
|
this._debugInfo.connectedPorts = results.filter(r => r.connected).map(r => r.port);
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}).sort((a, b) => b.request.timestamp - a.request.timestamp);
|
|
387
|
+
// 找出属于当前工作区的请求(只排除已提交/已结束的;「见过但没提交」的仍要能回到面板)。
|
|
388
|
+
// 不再用 server 进程级 ownerWorkspace 二次过滤:request 已在 _checkPortForRequest
|
|
389
|
+
// 按本窗口工作区过滤过(isPathInWorkspace);多窗口共用一个 MCP 进程时 owner 只反映
|
|
390
|
+
// 首个对话的窗口,按它过滤会把其他窗口的请求全部滤掉(面板不显示、只有飞书收到)。
|
|
391
|
+
const myRequests = results
|
|
392
|
+
.filter(r => !!r.request && !this._resolvedRequestIds.has(r.request.id))
|
|
393
|
+
.sort((a, b) => b.request.timestamp - a.request.timestamp);
|
|
381
394
|
// 处理最新的请求
|
|
382
395
|
if (myRequests.length > 0) {
|
|
383
396
|
const newest = myRequests[0];
|
|
@@ -466,41 +479,50 @@ class FeedbackViewProvider {
|
|
|
466
479
|
}
|
|
467
480
|
}
|
|
468
481
|
/**
|
|
469
|
-
*
|
|
482
|
+
* 处理新的反馈请求。
|
|
483
|
+
* 幂等:同一请求正在显示时重复调用直接跳过;已提交/已结束的绝不复显;
|
|
484
|
+
* 「见过但没提交」的请求(同窗口多对话时被新请求覆盖过)允许重新显示,只是不再重复提醒。
|
|
470
485
|
*/
|
|
471
486
|
_handleNewRequest(request, port) {
|
|
472
|
-
//
|
|
473
|
-
if (this.
|
|
487
|
+
// 已提交 / 已被外部渠道结束 → 绝不复显(防旧版 server 的 currentRequest 清理滞后导致复弹)
|
|
488
|
+
if (this._resolvedRequestIds.has(request.id)) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
// 正在显示的就是它 → 无事可做
|
|
492
|
+
if (this._currentRequest && request.id === this._currentRequest.id) {
|
|
474
493
|
return;
|
|
475
494
|
}
|
|
476
495
|
// 判断是否为"新鲜"请求:创建后 10 秒内被发现
|
|
477
496
|
const requestAge = Date.now() - request.timestamp;
|
|
478
497
|
const isFreshRequest = requestAge < 10000; // 10秒内
|
|
479
|
-
//
|
|
498
|
+
// seen 只用于「主动提醒」去重:同一请求只 focus / 系统通知一次,重新回到面板时安静显示
|
|
499
|
+
const alreadySeen = this._seenRequestIds.has(request.id);
|
|
480
500
|
this._seenRequestIds.add(request.id);
|
|
481
501
|
// 清理旧的请求 ID(保留最近 100 个)
|
|
482
502
|
if (this._seenRequestIds.size > 100) {
|
|
483
503
|
const ids = Array.from(this._seenRequestIds);
|
|
484
504
|
this._seenRequestIds = new Set(ids.slice(-50));
|
|
485
505
|
}
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
this.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
if (this._resolvedRequestIds.size > 100) {
|
|
507
|
+
const ids = Array.from(this._resolvedRequestIds);
|
|
508
|
+
this._resolvedRequestIds = new Set(ids.slice(-50));
|
|
509
|
+
}
|
|
510
|
+
this._currentRequest = request;
|
|
511
|
+
this._activePort = port;
|
|
512
|
+
this._currentRequestPort = port;
|
|
513
|
+
// 「插件通知」主开关(配置 key 历史原因仍叫 systemNotification):关掉后本窗口完全静默——
|
|
514
|
+
// 不推送内容、不弹面板、不抢焦点、不发系统通知;连用户之后主动切回 / 打开面板也不显示
|
|
515
|
+
// (见 resolveWebviewView 里 ready 与 onDidChangeVisibility 的同款判断)。
|
|
516
|
+
// 请求仍记录在 _currentRequest,仅用于去重与外部渠道(飞书 / 超时)resolve 关联。
|
|
517
|
+
if (!this._isPluginNotifyEnabled()) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// 推送内容并显示面板
|
|
521
|
+
this._showFeedbackRequest(request);
|
|
522
|
+
// 只对「新鲜且首次见到」的请求做主动提醒(聚焦面板 + IDE 提示 + 失焦系统通知)
|
|
523
|
+
if (isFreshRequest && !alreadySeen) {
|
|
524
|
+
vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
|
|
525
|
+
this._sendSystemNotification(request);
|
|
504
526
|
}
|
|
505
527
|
}
|
|
506
528
|
/**
|
|
@@ -682,6 +704,7 @@ class FeedbackViewProvider {
|
|
|
682
704
|
if (!this._currentRequest)
|
|
683
705
|
return;
|
|
684
706
|
this._seenRequestIds.add(this._currentRequest.id);
|
|
707
|
+
this._resolvedRequestIds.add(this._currentRequest.id);
|
|
685
708
|
this._currentRequest = null;
|
|
686
709
|
this._currentRequestPort = null;
|
|
687
710
|
this._showWaitingState();
|
|
@@ -717,6 +740,8 @@ class FeedbackViewProvider {
|
|
|
717
740
|
}));
|
|
718
741
|
const result = JSON.parse(response);
|
|
719
742
|
if (result.success) {
|
|
743
|
+
// 记入「已提交」:该请求此后绝不复显(server 端清理有滞后,轮询可能还会拿到它)
|
|
744
|
+
this._resolvedRequestIds.add(payload.requestId);
|
|
720
745
|
this._currentRequest = null;
|
|
721
746
|
this._currentRequestPort = null;
|
|
722
747
|
this._showWaitingState();
|
|
@@ -902,6 +927,14 @@ class FeedbackViewProvider {
|
|
|
902
927
|
payload: { requestId, paused: result.paused, remainingMs: result.remainingMs }
|
|
903
928
|
});
|
|
904
929
|
}
|
|
930
|
+
else {
|
|
931
|
+
// 请求已在 server 端结束(超时/被回复)→ 明确提示,不能静默:
|
|
932
|
+
// 用户以为暂停成功离开,实际倒计时早没了,回来发现等待消失会一头雾水
|
|
933
|
+
this._view?.webview.postMessage({
|
|
934
|
+
type: 'toast',
|
|
935
|
+
payload: { text: this._i18n.pauseFailedEnded }
|
|
936
|
+
});
|
|
937
|
+
}
|
|
905
938
|
}
|
|
906
939
|
catch {
|
|
907
940
|
// server 不支持(旧版本)或未连接:静默降级,倒计时照常走
|
package/dist/i18n/en.json
CHANGED
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"notifyTestBody": "This is a test notification · click to open Cursor",
|
|
90
90
|
"toastSubmitted": "✓ Feedback sent",
|
|
91
91
|
"toastQueued": "✓ Queued — AI will receive it next round",
|
|
92
|
+
"pauseFailedEnded": "Pause failed: this request has already ended (timed out or answered)",
|
|
92
93
|
"historyBtn": "Feedback history",
|
|
93
94
|
"historyEmpty": "No history yet — submitted feedback will show up here"
|
|
94
95
|
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -180,6 +180,7 @@ function getDefaultMessages() {
|
|
|
180
180
|
notifyTestBody: "This is a test notification · click to open Cursor",
|
|
181
181
|
toastSubmitted: "✓ Feedback sent",
|
|
182
182
|
toastQueued: "✓ Queued — AI will receive it next round",
|
|
183
|
+
pauseFailedEnded: "Pause failed: this request has already ended (timed out or answered)",
|
|
183
184
|
historyBtn: "Feedback history",
|
|
184
185
|
historyEmpty: "No history yet — submitted feedback will show up here"
|
|
185
186
|
};
|
package/dist/i18n/zh-CN.json
CHANGED
package/dist/mcp-server.js
CHANGED
|
@@ -108,6 +108,10 @@ class McpFeedbackServer {
|
|
|
108
108
|
// 所属工作区(只在 AI 调用 feedback 时设置)
|
|
109
109
|
// 只有来自同一工作区的轮询才会更新活动时间
|
|
110
110
|
this.ownerWorkspace = null;
|
|
111
|
+
// owner 身份是否已被真实窗口的轮询验证过。验证后 ownerWorkspace 不再被后续调用改写:
|
|
112
|
+
// AI 传的 project_directory 是「正在操作的项目」,不一定等于窗口工作区(同窗口操作兄弟/子目录很常见),
|
|
113
|
+
// 每次改写会让插件心跳失配 → 实例被防线 3 当僵尸误杀(等待反馈中直接 Connection closed)。
|
|
114
|
+
this.ownerConfirmed = false;
|
|
111
115
|
// Server 启动时间
|
|
112
116
|
this.startTime = Date.now();
|
|
113
117
|
// 最近一次活动时间(任意 HTTP 轮询 / MCP 调用都会刷新)
|
|
@@ -122,6 +126,9 @@ class McpFeedbackServer {
|
|
|
122
126
|
this.everOwnerPolled = false;
|
|
123
127
|
// 看门狗定时器(兜底退出,防止进程残留 / CPU 占满)
|
|
124
128
|
this.watchdogTimer = null;
|
|
129
|
+
// stop() 防重入:server.close() 会触发 transport.onclose → 回调里又调 stop(),
|
|
130
|
+
// 无标志位会无限递归直至 "Maximum call stack size exceeded"(日志曾刷出数万条 Stopping server...)
|
|
131
|
+
this.stopping = false;
|
|
125
132
|
this.port = port;
|
|
126
133
|
this.basePort = port;
|
|
127
134
|
this.server = new index_js_1.Server({
|
|
@@ -250,22 +257,41 @@ class McpFeedbackServer {
|
|
|
250
257
|
const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
|
|
251
258
|
const timeout = envTimeout || args?.timeout || 300;
|
|
252
259
|
const requestId = this.generateRequestId();
|
|
260
|
+
// 重复投递识别:同窗口 + 同 summary + 旧请求还很新 → 视为客户端/传输层对同一次调用的
|
|
261
|
+
// 重复投递(非新一轮),直接 join 旧等待共享结果:不发新卡、不顶旧请求(顶了会让
|
|
262
|
+
// 先到的那次调用收到 SUPERSEDED、AI 误以为被新会话取代而提前收尾)。
|
|
263
|
+
const dup = this.findDuplicatePending(projectDir, summary);
|
|
264
|
+
if (dup) {
|
|
265
|
+
debugLog(`Duplicate delivery detected for request ${dup.id}; joining its wait instead of superseding`);
|
|
266
|
+
const outcome = await new Promise((res) => dup.waiters.push(res));
|
|
267
|
+
return this.outcomeToResult(outcome, dup.id);
|
|
268
|
+
}
|
|
253
269
|
// 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
|
|
254
270
|
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
255
271
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
256
272
|
//(即你看到的两个一模一样的 cursor-feedback-extension)。
|
|
257
273
|
this.cancelStalePending(projectDir);
|
|
258
|
-
// AI 调用 feedback 时设置 ownerWorkspace
|
|
259
|
-
|
|
260
|
-
|
|
274
|
+
// AI 调用 feedback 时设置 ownerWorkspace;但 owner 一旦被真实窗口的轮询验证过(ownerConfirmed)
|
|
275
|
+
// 就锁定不再改写——project_directory 只代表 AI 正在操作的项目,改写已验证的窗口身份
|
|
276
|
+
// 会让心跳失配、实例被防线 3 误杀。
|
|
277
|
+
const normalizedProjectDir = this.normalizePath(projectDir);
|
|
278
|
+
if (!this.ownerConfirmed) {
|
|
279
|
+
this.ownerWorkspace = normalizedProjectDir;
|
|
280
|
+
debugLog(`Owner workspace set to: ${this.ownerWorkspace}`);
|
|
281
|
+
}
|
|
282
|
+
else if (normalizedProjectDir !== this.ownerWorkspace) {
|
|
283
|
+
debugLog(`Owner workspace kept as ${this.ownerWorkspace} (confirmed by window polling); ` +
|
|
284
|
+
`request project ${normalizedProjectDir} differs and does not rewrite owner`);
|
|
285
|
+
}
|
|
261
286
|
// 创建反馈请求
|
|
262
|
-
|
|
287
|
+
const request = {
|
|
263
288
|
id: requestId,
|
|
264
289
|
summary,
|
|
265
290
|
projectDir,
|
|
266
291
|
timeout,
|
|
267
292
|
timestamp: Date.now(),
|
|
268
293
|
};
|
|
294
|
+
this.currentRequest = request;
|
|
269
295
|
debugLog(`Feedback request created: ${requestId}`);
|
|
270
296
|
debugLog(`Summary: ${summary}`);
|
|
271
297
|
debugLog(`Project: ${projectDir}`);
|
|
@@ -277,79 +303,8 @@ class McpFeedbackServer {
|
|
|
277
303
|
}
|
|
278
304
|
try {
|
|
279
305
|
// 等待用户反馈
|
|
280
|
-
const outcome = await this.waitForFeedback(
|
|
281
|
-
|
|
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') {
|
|
294
|
-
debugLog('Feedback wait window elapsed without user input');
|
|
295
|
-
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
296
|
-
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
297
|
-
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
298
|
-
const autoRetry = this.effectiveAutoRetry();
|
|
299
|
-
const timeoutMessage = autoRetry
|
|
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 继续等待,不要结束对话、不要当作任务完成。】'
|
|
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),可以结束本轮。】';
|
|
302
|
-
return {
|
|
303
|
-
content: [
|
|
304
|
-
{
|
|
305
|
-
type: 'text',
|
|
306
|
-
text: timeoutMessage,
|
|
307
|
-
},
|
|
308
|
-
],
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
const result = outcome.data;
|
|
312
|
-
debugLog(`Received feedback: ${result.interactive_feedback?.substring(0, 100)}...`);
|
|
313
|
-
const contentItems = [];
|
|
314
|
-
// 构建反馈文本
|
|
315
|
-
let feedbackText = '';
|
|
316
|
-
// 添加文字反馈
|
|
317
|
-
if (result.interactive_feedback) {
|
|
318
|
-
feedbackText += `=== User Feedback ===\n${result.interactive_feedback}`;
|
|
319
|
-
}
|
|
320
|
-
// 添加附加文件路径
|
|
321
|
-
if (result.attachedFiles && result.attachedFiles.length > 0) {
|
|
322
|
-
debugLog(`Processing ${result.attachedFiles.length} attached files`);
|
|
323
|
-
feedbackText += `\n\n=== Attached Files ===\n`;
|
|
324
|
-
for (const filePath of result.attachedFiles) {
|
|
325
|
-
feedbackText += `${filePath}\n`;
|
|
326
|
-
}
|
|
327
|
-
feedbackText += `\nPlease read the above files to understand the context.`;
|
|
328
|
-
}
|
|
329
|
-
if (feedbackText) {
|
|
330
|
-
contentItems.push({
|
|
331
|
-
type: 'text',
|
|
332
|
-
text: feedbackText,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
// 添加图片
|
|
336
|
-
if (result.images && result.images.length > 0) {
|
|
337
|
-
debugLog(`Processing ${result.images.length} images`);
|
|
338
|
-
for (const img of result.images) {
|
|
339
|
-
contentItems.push({
|
|
340
|
-
type: 'image',
|
|
341
|
-
data: img.data,
|
|
342
|
-
mimeType: this.getMimeType(img.name),
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (contentItems.length === 0) {
|
|
347
|
-
contentItems.push({
|
|
348
|
-
type: 'text',
|
|
349
|
-
text: 'User did not provide any feedback.',
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
return { content: contentItems };
|
|
306
|
+
const outcome = await this.waitForFeedback(request, timeout * 1000);
|
|
307
|
+
return this.outcomeToResult(outcome, requestId);
|
|
353
308
|
}
|
|
354
309
|
catch (error) {
|
|
355
310
|
debugLog(`Error collecting feedback: ${error}`);
|
|
@@ -440,9 +395,14 @@ class McpFeedbackServer {
|
|
|
440
395
|
}
|
|
441
396
|
}
|
|
442
397
|
else {
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
398
|
+
// 多个等待并存 → 不猜。注意措辞:多个等待可能来自同一窗口的多个对话(用户实测
|
|
399
|
+
// 「只开了一个窗口却提示多窗口」造成困惑),按「反馈请求」计数并列出项目名,
|
|
400
|
+
// 引导用户去点想回复的那张卡片——回复哪张就精确回到哪个请求。
|
|
401
|
+
const names = [
|
|
402
|
+
...localPending.map((p) => this.projectName(p.projectDir)),
|
|
403
|
+
...remote.flatMap((r) => r.list.map((x) => x.projectName)),
|
|
404
|
+
];
|
|
405
|
+
this.feishu.replyText(chatId, `当前有 ${globalCount} 个反馈请求在等待(${names.join('、')}),可能来自不同窗口或同一窗口的多个对话,没法自动判断你要回复哪个。\n请在你想回复的那张卡片上点「回复」再发,回复哪张就送达哪个请求。`);
|
|
446
406
|
}
|
|
447
407
|
});
|
|
448
408
|
}
|
|
@@ -459,6 +419,16 @@ class McpFeedbackServer {
|
|
|
459
419
|
normalizePath(p) {
|
|
460
420
|
return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
461
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* 两个已归一化的路径是否属于同一窗口语境:相等,或一方是另一方的子目录。
|
|
424
|
+
* 用于 owner 心跳匹配——AI 传的 project_directory 常是窗口工作区的子目录(monorepo 子包等),
|
|
425
|
+
* 精确相等会漏判、心跳失配导致实例被防线 3 误杀。
|
|
426
|
+
*/
|
|
427
|
+
pathsRelated(a, b) {
|
|
428
|
+
if (!a || !b)
|
|
429
|
+
return false;
|
|
430
|
+
return a === b || a.startsWith(b + '/') || b.startsWith(a + '/');
|
|
431
|
+
}
|
|
462
432
|
/** 飞书回复命中某 requestId → resolve 该 pending,并回执用户 */
|
|
463
433
|
submitFromFeishu(requestId, text, chatId, images = [], attachedFiles = [], ackMessageId) {
|
|
464
434
|
const pending = this.pendingRequests.get(requestId);
|
|
@@ -586,17 +556,121 @@ class McpFeedbackServer {
|
|
|
586
556
|
for (const [reqId, pending] of this.pendingRequests) {
|
|
587
557
|
if (this.normalizePath(pending.projectDir) !== owner)
|
|
588
558
|
continue;
|
|
559
|
+
// 用户显式暂停的等待不作废:暂停 = 用户明确表达「保住这轮、等我回来」,
|
|
560
|
+
// 不是僵尸残留。同窗口新旧请求并存时,/api/feedback/current 按「未暂停优先」
|
|
561
|
+
// 返回,active 的结束后暂停中的会重新回到面板,可恢复可提交。
|
|
562
|
+
if (pending.paused)
|
|
563
|
+
continue;
|
|
589
564
|
clearTimeout(pending.timeout);
|
|
590
565
|
pending.resolve(null); // → superseded:旧 await 安静结束,不触发重试
|
|
591
566
|
this.feishu.clearPending(reqId);
|
|
592
567
|
this.pendingRequests.delete(reqId);
|
|
593
568
|
}
|
|
594
569
|
}
|
|
570
|
+
findDuplicatePending(projectDir, summary) {
|
|
571
|
+
const owner = this.normalizePath(projectDir);
|
|
572
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
573
|
+
if (this.normalizePath(pending.projectDir) !== owner)
|
|
574
|
+
continue;
|
|
575
|
+
if (pending.request.summary !== summary)
|
|
576
|
+
continue;
|
|
577
|
+
if (Date.now() - pending.request.timestamp > McpFeedbackServer.DUP_JOIN_MS)
|
|
578
|
+
continue;
|
|
579
|
+
return { id, waiters: pending.waiters };
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* 把等待结果翻译成 MCP 工具响应(主等待与重复投递 join 的等待共用同一段收尾语义)。
|
|
585
|
+
*/
|
|
586
|
+
outcomeToResult(outcome, requestId) {
|
|
587
|
+
// 被同窗口/同进程的新一轮请求取代:安静结束,绝不能重试(否则多 agent 互相取消、忙等刷屏)。
|
|
588
|
+
if (outcome.kind === 'superseded') {
|
|
589
|
+
debugLog(`Request ${requestId} superseded by a newer request; ending this turn quietly`);
|
|
590
|
+
return {
|
|
591
|
+
content: [
|
|
592
|
+
{
|
|
593
|
+
type: 'text',
|
|
594
|
+
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,安静结束本轮,切勿重试。】',
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (outcome.kind === 'timeout') {
|
|
600
|
+
debugLog('Feedback wait window elapsed without user input');
|
|
601
|
+
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
602
|
+
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
603
|
+
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
604
|
+
const autoRetry = this.effectiveAutoRetry();
|
|
605
|
+
const timeoutMessage = autoRetry
|
|
606
|
+
? '[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 继续等待,不要结束对话、不要当作任务完成。】'
|
|
607
|
+
: '[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),可以结束本轮。】';
|
|
608
|
+
return {
|
|
609
|
+
content: [
|
|
610
|
+
{
|
|
611
|
+
type: 'text',
|
|
612
|
+
text: timeoutMessage,
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
const result = outcome.data;
|
|
618
|
+
debugLog(`Received feedback: ${result.interactive_feedback?.substring(0, 100)}...`);
|
|
619
|
+
const contentItems = [];
|
|
620
|
+
// 构建反馈文本
|
|
621
|
+
let feedbackText = '';
|
|
622
|
+
// 添加文字反馈
|
|
623
|
+
if (result.interactive_feedback) {
|
|
624
|
+
feedbackText += `=== User Feedback ===\n${result.interactive_feedback}`;
|
|
625
|
+
}
|
|
626
|
+
// 添加附加文件路径
|
|
627
|
+
if (result.attachedFiles && result.attachedFiles.length > 0) {
|
|
628
|
+
debugLog(`Processing ${result.attachedFiles.length} attached files`);
|
|
629
|
+
feedbackText += `\n\n=== Attached Files ===\n`;
|
|
630
|
+
for (const filePath of result.attachedFiles) {
|
|
631
|
+
feedbackText += `${filePath}\n`;
|
|
632
|
+
}
|
|
633
|
+
feedbackText += `\nPlease read the above files to understand the context.`;
|
|
634
|
+
}
|
|
635
|
+
if (feedbackText) {
|
|
636
|
+
contentItems.push({
|
|
637
|
+
type: 'text',
|
|
638
|
+
text: feedbackText,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
// 添加图片
|
|
642
|
+
if (result.images && result.images.length > 0) {
|
|
643
|
+
debugLog(`Processing ${result.images.length} images`);
|
|
644
|
+
for (const img of result.images) {
|
|
645
|
+
contentItems.push({
|
|
646
|
+
type: 'image',
|
|
647
|
+
data: img.data,
|
|
648
|
+
mimeType: this.getMimeType(img.name),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (contentItems.length === 0) {
|
|
653
|
+
contentItems.push({
|
|
654
|
+
type: 'text',
|
|
655
|
+
text: 'User did not provide any feedback.',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return { content: contentItems };
|
|
659
|
+
}
|
|
595
660
|
/**
|
|
596
|
-
*
|
|
661
|
+
* 等待用户反馈:注册 pending 并挂起,结果通过 waiters 广播——
|
|
662
|
+
* 首个调用与后续 join 进来的重复投递调用都会收到同一份结果。
|
|
597
663
|
*/
|
|
598
|
-
waitForFeedback(
|
|
664
|
+
waitForFeedback(request, timeoutMs) {
|
|
599
665
|
return new Promise((resolve) => {
|
|
666
|
+
const requestId = request.id;
|
|
667
|
+
const projectDir = request.projectDir;
|
|
668
|
+
const waiters = [resolve];
|
|
669
|
+
// 广播给所有 waiter(splice 清空防重复触发:timeout 与 resolve 竞态时只结算一次)
|
|
670
|
+
const settle = (outcome) => {
|
|
671
|
+
for (const w of waiters.splice(0))
|
|
672
|
+
w(outcome);
|
|
673
|
+
};
|
|
600
674
|
const onTimeout = () => {
|
|
601
675
|
debugLog(`Request ${requestId} timed out`);
|
|
602
676
|
this.pendingRequests.delete(requestId);
|
|
@@ -607,22 +681,24 @@ class McpFeedbackServer {
|
|
|
607
681
|
this.recentlyTimedOut.delete(id);
|
|
608
682
|
}
|
|
609
683
|
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
610
|
-
|
|
684
|
+
settle({ kind: 'timeout' });
|
|
611
685
|
};
|
|
612
686
|
const timeout = setTimeout(onTimeout, timeoutMs);
|
|
613
687
|
this.pendingRequests.set(requestId, {
|
|
614
688
|
// resolve 包一层:沿用「外部 resolve(feedback) / resolve(null)」旧约定,
|
|
615
689
|
// 但 null 一律映射为 superseded(被同进程新请求取代),绝不再走「超时重试」路径。
|
|
616
690
|
resolve: (value) => {
|
|
617
|
-
|
|
691
|
+
settle(value === null ? { kind: 'superseded' } : { kind: 'feedback', data: value });
|
|
618
692
|
},
|
|
619
|
-
reject: () =>
|
|
693
|
+
reject: () => settle({ kind: 'superseded' }),
|
|
620
694
|
timeout,
|
|
621
695
|
projectDir,
|
|
622
696
|
onTimeout,
|
|
623
697
|
deadline: Date.now() + timeoutMs,
|
|
624
698
|
paused: false,
|
|
625
699
|
remainingMs: timeoutMs,
|
|
700
|
+
request,
|
|
701
|
+
waiters,
|
|
626
702
|
});
|
|
627
703
|
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
628
704
|
// 本轮 pending 一注册立即作为回复提交
|
|
@@ -657,20 +733,45 @@ class McpFeedbackServer {
|
|
|
657
733
|
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
658
734
|
};
|
|
659
735
|
}
|
|
660
|
-
/**
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (!cur)
|
|
736
|
+
/** 指定请求的暂停态快照(随 /api/feedback/current 下发,供面板重建后恢复显示) */
|
|
737
|
+
getPauseStateFor(requestId) {
|
|
738
|
+
if (!requestId)
|
|
664
739
|
return null;
|
|
665
|
-
const p = this.pendingRequests.get(
|
|
740
|
+
const p = this.pendingRequests.get(requestId);
|
|
666
741
|
if (!p)
|
|
667
742
|
return null;
|
|
668
743
|
return {
|
|
669
|
-
requestId
|
|
744
|
+
requestId,
|
|
670
745
|
paused: p.paused,
|
|
671
746
|
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
672
747
|
};
|
|
673
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* 按窗口(workspace)从 pendingRequests 挑该窗口该看到的请求。
|
|
751
|
+
* 修复「多窗口共用一个 MCP 进程时 currentRequest 单值槽互相覆盖、被覆盖的窗口面板
|
|
752
|
+
* 显示不出自己的等待(只有飞书收到)」:每个窗口轮询时按自己的路径取自己的请求。
|
|
753
|
+
* 多个匹配时:未暂停的优先(active 请求先服务);同暂停态取最新——active 的结束后
|
|
754
|
+
* 暂停中的会自然回到面板,用户可恢复。
|
|
755
|
+
*/
|
|
756
|
+
pickRequestForWorkspace(normalizedWs) {
|
|
757
|
+
let best = null;
|
|
758
|
+
for (const [, p] of this.pendingRequests) {
|
|
759
|
+
if (!this.pathsRelated(this.normalizePath(p.projectDir), normalizedWs))
|
|
760
|
+
continue;
|
|
761
|
+
if (!best) {
|
|
762
|
+
best = p;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (best.paused !== p.paused) {
|
|
766
|
+
if (best.paused)
|
|
767
|
+
best = p;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (p.request.timestamp > best.request.timestamp)
|
|
771
|
+
best = p;
|
|
772
|
+
}
|
|
773
|
+
return best ? best.request : null;
|
|
774
|
+
}
|
|
674
775
|
/**
|
|
675
776
|
* 写入/合并抢跑暂存:短时间内连发多条(含图文拆条后超出合并窗口的)合并为一条,
|
|
676
777
|
* 绝不让后一条覆盖前一条(旧实现会把第一条静默丢掉)。
|
|
@@ -937,30 +1038,38 @@ class McpFeedbackServer {
|
|
|
937
1038
|
if (req.method === 'GET' && req.url?.startsWith('/api/feedback/current')) {
|
|
938
1039
|
// autoRetry 不再从轮询 query 同步(曾致多窗口互相覆盖、抖动):
|
|
939
1040
|
// 改由 POST /api/settings/autoRetry 广播 + 磁盘真相源,poll 只回读做 UI 回显。
|
|
1041
|
+
let pollWs = '';
|
|
940
1042
|
try {
|
|
941
1043
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
942
1044
|
// 「我的窗口」心跳:仅当轮询带的 workspace 匹配本实例归属时刷新。
|
|
943
1045
|
// 别的活跃窗口对全端口的扫描虽刷 lastActivityTime,但 workspace 不匹配、不刷此字段,
|
|
944
1046
|
// 故已关窗口的残留 server 不会被别人续命,可被 watchdog / 僵尸自检识别。
|
|
1047
|
+
// 匹配放宽为「路径互为前缀」:AI 传的 project_directory 可能是窗口工作区的子目录。
|
|
1048
|
+
// 命中即确认 owner 身份(ownerConfirmed),此后 owner 不再被后续调用改写。
|
|
945
1049
|
const ws = this.normalizePath(u.searchParams.get('workspace') || '');
|
|
946
|
-
|
|
1050
|
+
pollWs = ws;
|
|
1051
|
+
if (ws && this.ownerWorkspace && this.pathsRelated(ws, this.ownerWorkspace)) {
|
|
947
1052
|
this.lastOwnerPollTime = Date.now();
|
|
948
1053
|
this.everOwnerPolled = true;
|
|
1054
|
+
this.ownerConfirmed = true;
|
|
949
1055
|
}
|
|
950
1056
|
}
|
|
951
1057
|
catch {
|
|
952
1058
|
// 忽略解析错误
|
|
953
1059
|
}
|
|
1060
|
+
// 按窗口挑请求:多窗口/多对话共用本进程时各窗口各看各的等待,互不覆盖。
|
|
1061
|
+
// 不带 workspace(无工作区窗口 / 旧版插件)时回退全局 currentRequest 老行为。
|
|
1062
|
+
const chosen = pollWs ? this.pickRequestForWorkspace(pollWs) : (this.currentRequest || null);
|
|
954
1063
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
955
|
-
//
|
|
1064
|
+
// 返回该窗口的请求、ownerWorkspace、startTime,以及当前生效的 autoRetry(供 UI 初始同步)
|
|
956
1065
|
res.end(JSON.stringify({
|
|
957
|
-
request:
|
|
1066
|
+
request: chosen,
|
|
958
1067
|
ownerWorkspace: this.ownerWorkspace,
|
|
959
1068
|
startTime: this.startTime,
|
|
960
1069
|
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
961
1070
|
feishu: this.feishu.getStatus(),
|
|
962
1071
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
963
|
-
pause: this.
|
|
1072
|
+
pause: this.getPauseStateFor(chosen?.id),
|
|
964
1073
|
}));
|
|
965
1074
|
return;
|
|
966
1075
|
}
|
|
@@ -1352,8 +1461,13 @@ class McpFeedbackServer {
|
|
|
1352
1461
|
}
|
|
1353
1462
|
// 防线 3:本实例曾被自己窗口插件轮询,但久未再收到(只剩别的活跃窗口在扫端口刷 lastActivityTime)
|
|
1354
1463
|
// → 我的窗口已关、本实例是僵尸 → 退出,避免持续被全局 pending 计数误算成「另一个在等的窗口」。
|
|
1464
|
+
// ⚠️ 与防线 2 同款约束:还有 pending 反馈在等用户时绝不退——曾因缺这一条,AI 在等待反馈期间
|
|
1465
|
+
// 传了与窗口工作区不同的 project_directory(改写 owner 后心跳失配),30 秒后实例被当僵尸杀掉,
|
|
1466
|
+
// 正在等待的 interactive_feedback 直接报 "MCP error -32000: Connection closed"。
|
|
1355
1467
|
const ownerIdle = Date.now() - this.lastOwnerPollTime;
|
|
1356
|
-
if (this.everOwnerPolled &&
|
|
1468
|
+
if (this.everOwnerPolled &&
|
|
1469
|
+
this.pendingRequests.size === 0 &&
|
|
1470
|
+
ownerIdle > IDLE_TIMEOUT) {
|
|
1357
1471
|
debugLog(`Owner window idle for ${ownerIdle}ms, stale instance, exiting...`);
|
|
1358
1472
|
this.stop();
|
|
1359
1473
|
process.exit(0);
|
|
@@ -1363,9 +1477,13 @@ class McpFeedbackServer {
|
|
|
1363
1477
|
this.watchdogTimer.unref();
|
|
1364
1478
|
}
|
|
1365
1479
|
/**
|
|
1366
|
-
*
|
|
1480
|
+
* 停止服务器(幂等:重复调用 / server.close() 触发 onclose 再次进入时直接返回)
|
|
1367
1481
|
*/
|
|
1368
1482
|
stop() {
|
|
1483
|
+
if (this.stopping) {
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
this.stopping = true;
|
|
1369
1487
|
debugLog('Stopping server...');
|
|
1370
1488
|
// 关闭看门狗定时器
|
|
1371
1489
|
if (this.watchdogTimer) {
|
|
@@ -1403,6 +1521,12 @@ McpFeedbackServer.REJOIN_TTL_MS = 15000;
|
|
|
1403
1521
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
|
1404
1522
|
*/
|
|
1405
1523
|
McpFeedbackServer.OWNER_IDLE_MS = 12000;
|
|
1524
|
+
/**
|
|
1525
|
+
* 查找可 join 的「重复投递」等待:同窗口 + 同 summary + 注册时间在 DUP_JOIN_MS 内。
|
|
1526
|
+
* AI 每一轮的 summary 几乎不可能与上一轮一字不差,短窗口内完全相同基本可断定为
|
|
1527
|
+
* 客户端/传输层对同一次 tool call 的重复投递。
|
|
1528
|
+
*/
|
|
1529
|
+
McpFeedbackServer.DUP_JOIN_MS = 90000;
|
|
1406
1530
|
// 主函数
|
|
1407
1531
|
async function main() {
|
|
1408
1532
|
const port = 61927;
|
package/dist/webview/script.js
CHANGED
|
@@ -1263,6 +1263,11 @@
|
|
|
1263
1263
|
? (i18n.toastQueued || 'Queued — will be delivered to AI next round')
|
|
1264
1264
|
: (i18n.toastSubmitted || 'Feedback sent'));
|
|
1265
1265
|
break;
|
|
1266
|
+
|
|
1267
|
+
case 'toast':
|
|
1268
|
+
// extension 侧的通用轻提示(如暂停失败:请求已结束)
|
|
1269
|
+
if (message.payload && message.payload.text) showToast(message.payload.text);
|
|
1270
|
+
break;
|
|
1266
1271
|
}
|
|
1267
1272
|
});
|
|
1268
1273
|
|
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.1.
|
|
5
|
+
"version": "2.1.2",
|
|
6
6
|
"icon": "icon.png",
|
|
7
7
|
"author": "jianger666",
|
|
8
8
|
"license": "MIT",
|